diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 08:43:02 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 08:43:02 +0000 |
commit | d9ab72d6080f594d0b3cae15f14b3ef2c6c638cb (patch) | |
tree | 2341ef426af70ad1e289c38036737e04b0aa5007 /app | |
parent | d6e514dd13db8947884cd58fe2a9c2a063400a9b (diff) | |
download | gitlab-ce-d9ab72d6080f594d0b3cae15f14b3ef2c6c638cb.tar.gz |
Add latest changes from gitlab-org/gitlab@14-4-stable-eev14.4.0-rc42
Diffstat (limited to 'app')
1165 files changed, 16839 insertions, 7792 deletions
diff --git a/app/assets/images/logos/zentao.svg b/app/assets/images/logos/zentao.svg new file mode 100644 index 00000000000..d2115b72aee --- /dev/null +++ b/app/assets/images/logos/zentao.svg @@ -0,0 +1,14 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" fill="none"> + <path fill="url(#SVGID_1_)" d="M8,1C4.1,1,1,4.1,1,8s3.1,7,7,7s7-3.1,7-7S11.9,1,8,1L8,1z M11.3,8.2C9.8,7.7,7.9,6.1,5.8,7.6 + C4,8.9,4.8,11.1,6,11.8c0.9,0.6,2.3,0.7,3,0.1C9.7,11.4,10,10,9,9.5C8.6,9.4,7.9,9.3,7.5,9.8c-0.5,0.6-0.3,1.4,0.4,1.7 + c0,0-1.2-0.1-1.4-1.3C6.2,7.9,9,7.6,10.3,8.4c2.4,1.5,1.5,4.8-2,5.4c-1.8,0.3-4.8-0.3-5.9-2.7c-0.4-0.9-0.3-0.7-0.3-0.7 + c0.1,0.1,0.3,0.3,0.4,0.4c0.8,0.6,1.6,0.1,1.4-0.8C3.3,7.2,4.4,6.7,5.1,6.2s0.4-1.5-0.9-1.3c-1.9,0.3-2.4,3-2.4,3s-0.3-4.6,3.7-5 + c4.1-0.4,4.7,3.2,6.5,3.7c2.5,0.8,1.3-2.6,1.3-2.6s1.1,1.7,0.6,3.2C13.5,8.2,12.3,8.5,11.3,8.2z" /> + <defs> + <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="8" y1="-271.1102" x2="8" y2="-257.1102" + gradientTransform="matrix(1 0 0 -1 0 -256.1102)"> + <stop offset="0" style="stop-color:#445470" /> + <stop offset="1" style="stop-color:#7A869A" /> + </linearGradient> + </defs> +</svg> diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index 7f5f0403de6..2cd3a8f12ee 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -49,7 +49,7 @@ export const initProjectsField = () => { { default: createDefaultClient }, ]) => { const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), }); Vue.use(VueApollo); diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue index a0f4a4bf382..e6dde5898e7 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete.vue @@ -14,7 +14,7 @@ export default { type: Object, required: true, }, - oncallSchedules: { + userDeletionObstacles: { type: Array, required: false, default: () => [], @@ -29,7 +29,7 @@ export default { :username="username" :paths="paths" :delete-path="paths.delete" - :oncall-schedules="oncallSchedules" + :user-deletion-obstacles="userDeletionObstacles" > <slot></slot> </shared-delete-action> diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue index 02fd3efafa1..bd920a91516 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue @@ -14,7 +14,7 @@ export default { type: Object, required: true, }, - oncallSchedules: { + userDeletionObstacles: { type: Array, required: false, default: () => [], @@ -29,7 +29,7 @@ export default { :username="username" :paths="paths" :delete-path="paths.deleteWithContributions" - :oncall-schedules="oncallSchedules" + :user-deletion-obstacles="userDeletionObstacles" > <slot></slot> </shared-delete-action> diff --git a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue index a1589c9d46d..c9f29b55dbf 100644 --- a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue +++ b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue @@ -22,7 +22,7 @@ export default { type: String, required: true, }, - oncallSchedules: { + userDeletionObstacles: { type: Array, required: true, }, @@ -34,7 +34,7 @@ export default { 'data-delete-user-url': this.deletePath, 'data-gl-modal-action': this.modalType, 'data-username': this.username, - 'data-oncall-schedules': JSON.stringify(this.oncallSchedules), + 'data-user-deletion-obstacles': JSON.stringify(this.userDeletionObstacles), }; }, }, diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue index 413163c8536..ed90343777d 100644 --- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue +++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue @@ -2,7 +2,7 @@ import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { s__, sprintf } from '~/locale'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; export default { components: { @@ -10,7 +10,7 @@ export default { GlButton, GlFormInput, GlSprintf, - OncallSchedulesList, + UserDeletionObstaclesList, }, props: { title: { @@ -45,7 +45,7 @@ export default { type: String, required: true, }, - oncallSchedules: { + userDeletionObstacles: { type: String, required: false, default: '[]', @@ -66,9 +66,9 @@ export default { canSubmit() { return this.enteredUsername === this.username; }, - schedules() { + obstacles() { try { - return JSON.parse(this.oncallSchedules); + return JSON.parse(this.userDeletionObstacles); } catch (e) { Sentry.captureException(e); } @@ -112,12 +112,16 @@ export default { </gl-sprintf> </p> - <oncall-schedules-list v-if="schedules.length" :schedules="schedules" :user-name="username" /> + <user-deletion-obstacles-list + v-if="obstacles.length" + :obstacles="obstacles" + :user-name="username" + /> <p> <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> <template #username> - <code>{{ username }}</code> + <code class="gl-white-space-pre-wrap">{{ username }}</code> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index c076e0bedf0..4f4e2947341 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -9,6 +9,7 @@ import { } from '@gitlab/ui'; import { convertArrayToCamelCase } from '~/lib/utils/common_utils'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { I18N_USER_ACTIONS } from '../constants'; import { generateUserPaths } from '../utils'; import Actions from './actions'; @@ -72,6 +73,9 @@ export default { href: this.userPaths.edit, }; }, + obstaclesForUserDeletion() { + return parseUserDeletionObstacles(this.user); + }, }, methods: { isLdapAction(action) { @@ -141,7 +145,7 @@ export default { :key="action" :paths="userPaths" :username="user.name" - :oncall-schedules="user.oncallSchedules" + :user-deletion-obstacles="obstaclesForUserDeletion" :data-testid="`delete-${action}`" > {{ $options.i18n[action] }} 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 a490111e13b..0bdb45d35c9 100644 --- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -15,6 +15,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { n__, s__, __ } from '~/locale'; import getProjects from '../graphql/projects.query.graphql'; +const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name)); + export default { name: 'ProjectsDropdownFilter', components: { @@ -88,6 +90,9 @@ export default { selectedProjectIds() { return this.selectedProjects.map((p) => p.id); }, + hasSelectedProjects() { + return Boolean(this.selectedProjects.length); + }, availableProjects() { return filterBySearchTerm(this.projects, this.searchTerm); }, @@ -95,6 +100,12 @@ export default { const { loading, availableProjects } = this; return !loading && !availableProjects.length; }, + selectedItems() { + return sortByProjectName(this.selectedProjects); + }, + unselectedItems() { + return this.availableProjects.filter(({ id }) => !this.selectedProjectIds.includes(id)); + }, }, watch: { searchTerm() { @@ -105,44 +116,53 @@ export default { this.search(); }, methods: { + handleUpdatedSelectedProjects() { + this.$emit('selected', this.selectedProjects); + }, search: debounce(function debouncedSearch() { this.fetchData(); }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - getSelectedProjects(selectedProject, isMarking) { - return isMarking + getSelectedProjects(selectedProject, isSelected) { + return isSelected ? this.selectedProjects.concat([selectedProject]) : this.selectedProjects.filter((project) => project.id !== selectedProject.id); }, singleSelectedProject(selectedObj, isMarking) { return isMarking ? [selectedObj] : []; }, - setSelectedProjects(selectedObj, isMarking) { + setSelectedProjects(project) { this.selectedProjects = this.multiSelect - ? this.getSelectedProjects(selectedObj, isMarking) - : this.singleSelectedProject(selectedObj, isMarking); + ? this.getSelectedProjects(project, !this.isProjectSelected(project)) + : this.singleSelectedProject(project, !this.isProjectSelected(project)); }, - onClick({ project, isSelected }) { - this.setSelectedProjects(project, !isSelected); - this.$emit('selected', this.selectedProjects); + onClick(project) { + this.setSelectedProjects(project); + this.handleUpdatedSelectedProjects(); }, - onMultiSelectClick({ project, isSelected }) { - this.setSelectedProjects(project, !isSelected); + onMultiSelectClick(project) { + this.setSelectedProjects(project); this.isDirty = true; }, - onSelected(ev) { + onSelected(project) { if (this.multiSelect) { - this.onMultiSelectClick(ev); + this.onMultiSelectClick(project); } else { - this.onClick(ev); + this.onClick(project); } }, onHide() { if (this.multiSelect && this.isDirty) { - this.$emit('selected', this.selectedProjects); + this.handleUpdatedSelectedProjects(); } this.searchTerm = ''; this.isDirty = false; }, + onClearAll() { + if (this.hasSelectedProjects) { + this.isDirty = true; + } + this.selectedProjects = []; + }, fetchData() { this.loading = true; @@ -168,8 +188,8 @@ export default { this.projects = nodes; }); }, - isProjectSelected(id) { - return this.selectedProjects ? this.selectedProjectIds.includes(id) : false; + isProjectSelected(project) { + return this.selectedProjectIds.includes(project.id); }, getEntityId(project) { return getIdFromGraphQLId(project.id); @@ -182,6 +202,10 @@ export default { ref="projectsDropdown" class="dropdown dropdown-projects" toggle-class="gl-shadow-none" + :show-clear-all="hasSelectedProjects" + show-highlighted-items-title + highlighted-items-title-class="gl-p-3" + @clear-all.stop="onClearAll" @hide="onHide" > <template #button-content> @@ -204,14 +228,37 @@ export default { <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header> <gl-search-box-by-type v-model.trim="searchTerm" /> </template> + <template #highlighted-items> + <gl-dropdown-item + v-for="project in selectedItems" + :key="project.id" + is-check-item + :is-checked="isProjectSelected(project)" + @click.native.capture.stop="onSelected(project)" + > + <div class="gl-display-flex"> + <gl-avatar + class="gl-mr-2 gl-vertical-align-middle" + :alt="project.name" + :size="16" + :entity-id="getEntityId(project)" + :entity-name="project.name" + :src="project.avatarUrl" + shape="rect" + /> + <div> + <div data-testid="project-name">{{ project.name }}</div> + <div class="gl-text-gray-500" data-testid="project-full-path"> + {{ project.fullPath }} + </div> + </div> + </div> + </gl-dropdown-item> + </template> <gl-dropdown-item - v-for="project in availableProjects" + v-for="project in unselectedItems" :key="project.id" - :is-check-item="true" - :is-checked="isProjectSelected(project.id)" - @click.native.capture.stop=" - onSelected({ project, isSelected: isProjectSelected(project.id) }) - " + @click.native.capture.stop="onSelected(project)" > <div class="gl-display-flex"> <gl-avatar diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index 44d9b4b4262..c06bd34f86f 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -9,4 +9,5 @@ export const dateFormats = { isoDate, defaultDate: mediumDate, defaultDateTime: 'mmm d, yyyy h:MMtt', + month: 'mmmm', }; diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js index 52901d4c5bb..f55ef99964e 100644 --- a/app/assets/javascripts/analytics/shared/utils.js +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -1,4 +1,5 @@ import dateFormat from 'dateformat'; +import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { dateFormats } from './constants'; export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => { @@ -7,3 +8,64 @@ export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'na }; export const toYmd = (date) => dateFormat(date, dateFormats.isoDate); + +/** + * Takes a url and extracts query parameters used for the shared + * filter bar + * + * @param {string} url The URL to extract query parameters from + * @returns {Object} + */ +export const extractFilterQueryParameters = (url = '') => { + const { + source_branch_name = null, + target_branch_name = null, + author_username = null, + milestone_title = null, + assignee_username = [], + label_name = [], + } = urlQueryToFilter(url); + + return { + selectedSourceBranch: source_branch_name, + selectedTargetBranch: target_branch_name, + selectedAuthor: author_username, + selectedMilestone: milestone_title, + selectedAssigneeList: assignee_username, + selectedLabelList: label_name, + }; +}; + +/** + * Takes a url and extracts sorting and pagination query parameters into an object + * + * @param {string} url The URL to extract query parameters from + * @returns {Object} + */ +export const extractPaginationQueryParameters = (url = '') => { + const { sort, direction, page } = urlQueryToFilter(url); + return { + sort: sort?.value || null, + direction: direction?.value || null, + page: page?.value || null, + }; +}; + +export const getDataZoomOption = ({ + totalItems = 0, + maxItemsPerPage = 40, + dataZoom = [{ type: 'slider', bottom: 10, start: 0 }], +}) => { + if (totalItems <= maxItemsPerPage) { + return {}; + } + + const intervalEnd = Math.ceil((maxItemsPerPage / totalItems) * 100); + + return dataZoom.map((item) => { + return { + ...item, + end: intervalEnd, + }; + }); +}; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 01e463c1965..adf3e122a64 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -499,10 +499,10 @@ const Api = { return axios.put(url, params); }, - applySuggestionBatch(ids) { + applySuggestionBatch(ids, message) { const url = Api.buildUrl(Api.applySuggestionBatchPath); - return axios.put(url, { ids }); + return axios.put(url, { ids, commit_message: message }); }, commitPipelines(projectId, sha) { diff --git a/app/assets/javascripts/api/bulk_imports_api.js b/app/assets/javascripts/api/bulk_imports_api.js new file mode 100644 index 00000000000..d636cfdff0b --- /dev/null +++ b/app/assets/javascripts/api/bulk_imports_api.js @@ -0,0 +1,7 @@ +import { buildApiUrl } from '~/api/api_utils'; +import axios from '~/lib/utils/axios_utils'; + +const BULK_IMPORT_ENTITIES_PATH = '/api/:version/bulk_imports/entities'; + +export const getBulkImportsHistory = (params) => + axios.get(buildApiUrl(BULK_IMPORT_ENTITIES_PATH), { params }); diff --git a/app/assets/javascripts/artifacts_settings/index.js b/app/assets/javascripts/artifacts_settings/index.js index 531b42bc185..5c9f1c3129c 100644 --- a/app/assets/javascripts/artifacts_settings/index.js +++ b/app/assets/javascripts/artifacts_settings/index.js @@ -6,7 +6,7 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), }); export default (containerId = 'js-artifacts-settings-app') => { diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js index 367a06ad3c1..9d0890aa1b4 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js @@ -26,6 +26,18 @@ export default class Emoji extends Node { moji: el.textContent, }), }, + { + tag: 'img.emoji', + getAttrs: (el) => { + const name = el.getAttribute('title').replace(/^:|:$/g, ''); + + return { + name, + title: name, + moji: name, + }; + }, + }, ], toDOM: (node) => [ 'gl-emoji', diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js index ade5839d10b..4cc28c45739 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/image.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js @@ -29,7 +29,7 @@ export default class Image extends BaseImage { }, // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter { - tag: 'img[src]', + tag: 'img[src]:not(.emoji)', getAttrs: (el) => { const imageSrc = el.src; const imageUrl = diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index a1911585f80..a548b283142 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -81,7 +81,7 @@ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { }) .catch(() => createFlash({ - message: __('An error occurred while fetching markdown preview'), + message: __('An error occurred while fetching Markdown preview'), }), ); }; diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index b1227fb3533..59905035257 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -38,23 +38,9 @@ $.fn.requiresInput = function requiresInput() { $form.on('change input', fieldSelector, requireInput); }; -// Hide or Show the help block when creating a new project -// based on the option selected -function hideOrShowHelpBlock(form) { - const selected = $('.js-select-namespace option:selected'); - if (selected.length && selected.data('optionsParent') === 'groups') { - form.find('.form-text.text-muted').hide(); - } else if (selected.length) { - form.find('.form-text.text-muted').show(); - } -} - $(() => { $('form.js-requires-input').each((i, el) => { const $form = $(el); - $form.requiresInput(); - hideOrShowHelpBlock($form); - $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form)); }); }); diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index ebf2ab0381e..b27dccabdf8 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -306,6 +306,12 @@ export const GO_TO_PROJECT_WIKI = { defaultKeys: ['g w'], // eslint-disable-line @gitlab/require-i18n-strings }; +export const GO_TO_PROJECT_WEBIDE = { + id: 'project.goToWebIDE', + description: __('Open in Web IDE'), + defaultKeys: ['.'], +}; + export const PROJECT_FILES_MOVE_SELECTION_UP = { id: 'projectFiles.moveSelectionUp', description: __('Move selection up'), @@ -549,6 +555,7 @@ export const PROJECT_SHORTCUTS_GROUP = { GO_TO_PROJECT_KUBERNETES, GO_TO_PROJECT_SNIPPETS, GO_TO_PROJECT_WIKI, + GO_TO_PROJECT_WEBIDE, ], }; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js index b188d3b0ec3..7d8e4dd490c 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js @@ -1,4 +1,5 @@ import Mousetrap from 'mousetrap'; +import { visitUrl, constructWebIDEPath } from '~/lib/utils/url_utility'; import findAndFollowLink from '../../lib/utils/navigation_utility'; import { keysFor, @@ -18,6 +19,7 @@ import { GO_TO_PROJECT_KUBERNETES, GO_TO_PROJECT_ENVIRONMENTS, GO_TO_PROJECT_METRICS, + GO_TO_PROJECT_WEBIDE, NEW_ISSUE, } from './keybindings'; import Shortcuts from './shortcuts'; @@ -58,6 +60,18 @@ export default class ShortcutsNavigation extends Shortcuts { findAndFollowLink('.shortcuts-environments'), ); Mousetrap.bind(keysFor(GO_TO_PROJECT_METRICS), () => findAndFollowLink('.shortcuts-metrics')); + Mousetrap.bind(keysFor(GO_TO_PROJECT_WEBIDE), ShortcutsNavigation.navigateToWebIDE); Mousetrap.bind(keysFor(NEW_ISSUE), () => findAndFollowLink('.shortcuts-new-issue')); } + + static navigateToWebIDE() { + const path = constructWebIDEPath({ + sourceProjectFullPath: window.gl.mrWidgetData?.source_project_full_path, + targetProjectFullPath: window.gl.mrWidgetData?.target_project_full_path, + iid: window.gl.mrWidgetData?.iid, + }); + if (path) { + visitUrl(path); + } + } } diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index 1a74675100b..213e026c41f 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -41,6 +41,11 @@ export default { type: Object, required: true, }, + hideLineNumbers: { + type: Boolean, + required: false, + default: false, + }, }, computed: { viewer() { @@ -80,6 +85,7 @@ export default { :is-raw-content="isRawContent" :file-name="blob.name" :type="activeViewer.fileType" + :hide-line-numbers="hideLineNumbers" data-qa-selector="file_content" /> </template> diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 136457c115d..991f98c89e7 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -247,7 +247,11 @@ export default class FileTemplateMediator { } setFilename(name) { - this.$filenameInput.val(name).trigger('change'); + const input = this.$filenameInput.get(0); + if (name !== undefined && input.value !== name) { + input.value = name; + input.dispatchEvent(new Event('change')); + } } getSelected() { diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index d113a1d39d8..c10241d00d7 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -43,7 +43,9 @@ export function formatListIssues(listIssues) { let sortedIssues = list.issues.edges.map((issueNode) => ({ ...issueNode.node, })); - sortedIssues = sortBy(sortedIssues, 'relativePosition'); + if (list.listType !== ListType.closed) { + sortedIssues = sortBy(sortedIssues, 'relativePosition'); + } return { ...map, @@ -146,7 +148,8 @@ export function getMoveData(state, params) { } export function moveItemListHelper(item, fromList, toList) { - const updatedItem = item; + const updatedItem = cloneDeep(item); + if ( toList.listType === ListType.label && !updatedItem.labels.find((label) => label.id === toList.label.id) diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue index 22ad619e76b..c5411ec313a 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column.vue @@ -52,6 +52,8 @@ export default { }, setSelectedItem(selectedId) { + this.selectedId = selectedId; + const label = this.labels.find(({ id }) => id === selectedId); if (!selectedId || !label) { this.selectedLabel = null; @@ -87,8 +89,8 @@ export default { <template #items> <gl-form-radio-group v-if="labels.length > 0" - v-model="selectedId" class="gl-overflow-y-auto gl-px-5 gl-pt-3" + :checked="selectedId" @change="setSelectedItem" > <label diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue index 2aee84b805f..14c84d3c4e5 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue @@ -1,13 +1,23 @@ <script> -import { GlButton } from '@gitlab/ui'; -import { mapActions } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; import Tracking from '~/tracking'; export default { components: { GlButton, }, + directives: { + GlTooltip: GlTooltipDirective, + }, mixins: [Tracking.mixin()], + computed: { + ...mapState({ isNewListShowing: ({ addColumnForm }) => addColumnForm.visible }), + tooltip() { + return this.isNewListShowing ? __('The list creation wizard is already open') : ''; + }, + }, methods: { ...mapActions(['setAddColumnFormVisibility']), handleClick() { @@ -19,7 +29,14 @@ export default { </script> <template> - <div class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list"> - <gl-button variant="confirm" @click="handleClick">{{ __('Create list') }} </gl-button> + <div + v-gl-tooltip="tooltip" + :tabindex="isNewListShowing ? '0' : undefined" + class="gl-ml-3 gl-display-flex gl-align-items-center" + data-testid="boards-create-list" + > + <gl-button :disabled="isNewListShowing" variant="confirm" @click="handleClick" + >{{ __('Create list') }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index db80d48239b..b6ccc6a00fe 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -316,7 +316,7 @@ export default { </p> </gl-tooltip> - <span ref="countBadge" class="issue-count-badge board-card-info gl-mr-0 gl-pr-0"> + <span ref="countBadge" class="board-card-info gl-mr-0 gl-pr-0 gl-pl-3"> <span v-if="allowSubEpics" class="gl-mr-3"> <gl-icon name="epic" /> {{ totalEpicsCount }} @@ -334,7 +334,7 @@ export default { <span v-if="shouldRenderEpicProgress" ref="progressBadge" - class="issue-count-badge board-card-info gl-pl-0" + class="board-card-info gl-pl-0" > <span class="gl-mr-3" data-testid="epic-progress"> <gl-icon name="progress" /> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index e0105d63d99..9bbb8a1a1b2 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -3,15 +3,18 @@ import { GlDrawer } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; import { mapState, mapActions, mapGetters } from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; +import { __, sprintf } from '~/locale'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; 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 { 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 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'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { @@ -23,6 +26,7 @@ export default { SidebarConfidentialityWidget, BoardSidebarTimeTracker, BoardSidebarLabelsSelect, + SidebarLabelsWidget, SidebarSubscriptionsWidget, SidebarDropdownWidget, SidebarTodoWidget, @@ -46,16 +50,20 @@ export default { weightFeatureAvailable: { default: false, }, + allowLabelEdit: { + default: false, + }, }, inheritAttrs: false, computed: { ...mapGetters([ + 'isGroupBoard', 'isSidebarOpen', 'activeBoardItem', 'groupPathForActiveIssue', 'projectPathForActiveIssue', ]), - ...mapState(['sidebarType', 'issuableType']), + ...mapState(['sidebarType', 'issuableType', 'isSettingLabels']), isIssuableSidebar() { return this.sidebarType === ISSUABLE; }, @@ -65,17 +73,48 @@ export default { fullPath() { return this.activeBoardItem?.referencePath?.split('#')[0] || ''; }, + createLabelTitle() { + return sprintf(__('Create %{workspace} label'), { + workspace: this.isGroupBoard ? 'group' : 'project', + }); + }, + manageLabelTitle() { + return sprintf(__('Manage %{workspace} labels'), { + workspace: this.isGroupBoard ? 'group' : 'project', + }); + }, + attrWorkspacePath() { + return this.isGroupBoard ? this.groupPathForActiveIssue : undefined; + }, }, methods: { ...mapActions([ 'toggleBoardItem', 'setAssignees', 'setActiveItemConfidential', + 'setActiveBoardItemLabels', 'setActiveItemWeight', ]), handleClose() { this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); }, + handleUpdateSelectedLabels(input) { + this.setActiveBoardItemLabels({ + iid: this.activeBoardItem.iid, + projectPath: this.projectPathForActiveIssue, + addLabelIds: input.map((label) => getIdFromGraphQLId(label.id)), + removeLabelIds: this.activeBoardItem.labels + .filter((label) => !input.find((selected) => selected.id === label.id)) + .map((label) => label.id), + }); + }, + handleLabelRemove(input) { + this.setActiveBoardItemLabels({ + iid: this.activeBoardItem.iid, + projectPath: this.projectPathForActiveIssue, + removeLabelIds: [input], + }); + }, }, }; </script> @@ -160,7 +199,28 @@ export default { :issuable-type="issuableType" data-testid="sidebar-due-date" /> - <board-sidebar-labels-select class="block labels" /> + <sidebar-labels-widget + v-if="glFeatures.labelsWidget" + class="block labels" + data-testid="sidebar-labels" + :iid="activeBoardItem.iid" + :full-path="projectPathForActiveIssue" + :allow-label-remove="allowLabelEdit" + :allow-multiselect="true" + :selected-labels="activeBoardItem.labels" + :labels-select-in-progress="isSettingLabels" + :footer-create-label-title="createLabelTitle" + :footer-manage-label-title="manageLabelTitle" + :labels-create-title="createLabelTitle" + :labels-filter-base-path="projectPathForActiveIssue" + :attr-workspace-path="attrWorkspacePath" + :issuable-type="issuableType" + @onLabelRemove="handleLabelRemove" + @updateSelectedLabels="handleUpdateSelectedLabels" + > + {{ __('None') }} + </sidebar-labels-widget> + <board-sidebar-labels-select v-else class="block labels" /> <sidebar-weight-widget v-if="weightFeatureAvailable" :iid="activeBoardItem.iid" diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index dc5313b1bf6..a8d71ab7a35 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -365,7 +365,7 @@ export default { > <span class="gl-display-inline-flex"> <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" /> - <span ref="itemCount" class="issue-count-badge-count"> + <span ref="itemCount" class="gl-display-inline-flex gl-align-items-center"> <gl-icon class="gl-mr-2" :name="countIcon" /> <item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" /> </span> @@ -388,7 +388,7 @@ export default { v-gl-tooltip.hover :aria-label="$options.i18n.newIssue" :title="$options.i18n.newIssue" - class="issue-count-badge-add-button no-drag" + class="no-drag" icon="plus" @click="showNewIssueForm" /> diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js new file mode 100644 index 00000000000..d8d16184936 --- /dev/null +++ b/app/assets/javascripts/boards/graphql.js @@ -0,0 +1,22 @@ +import { IntrospectionFragmentMatcher, defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import createDefaultClient from '~/lib/graphql'; +import introspectionQueryResultData from '~/sidebar/fragmentTypes.json'; + +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + +export const gqlClient = createDefaultClient( + {}, + { + cacheConfig: { + dataIdFromObject: (object) => { + // eslint-disable-next-line no-underscore-dangle + return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object); + }, + + fragmentMatcher, + }, + assumeImmutableResults: true, + }, +); diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql index 1b14396fb5c..314faae89f8 100644 --- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql @@ -1,3 +1,4 @@ +#import "~/graphql_shared/fragments/milestone.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql" fragment IssueNode on Issue { @@ -15,6 +16,9 @@ fragment IssueNode on Issue { hidden webUrl relativePosition + milestone { + ...MilestoneFragment + } assignees { nodes { ...User diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index d1cb1ecf834..787dd77b901 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -16,6 +16,7 @@ query ListIssues( nodes { id issuesCount + listType issues(first: $first, filters: $filters, after: $after) { edges { node { @@ -37,6 +38,7 @@ query ListIssues( nodes { id issuesCount + listType issues(first: $first, filters: $filters, after: $after) { edges { node { diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 21c1bb23dc6..b6b1094fb3a 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,4 +1,3 @@ -import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import PortalVue from 'portal-vue'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -14,30 +13,17 @@ import FilteredSearchBoards from '~/boards/filtered_search_boards'; import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards'; import store from '~/boards/stores'; import toggleFocusMode from '~/boards/toggle_focus'; -import createDefaultClient from '~/lib/graphql'; import { NavigationType, parseBoolean } from '~/lib/utils/common_utils'; -import introspectionQueryResultData from '~/sidebar/fragmentTypes.json'; import { fullBoardId } from './boards_util'; import boardConfigToggle from './config_toggle'; +import { gqlClient } from './graphql'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; Vue.use(VueApollo); Vue.use(PortalVue); -const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - cacheConfig: { - fragmentMatcher, - }, - assumeImmutableResults: true, - }, - ), + defaultClient: gqlClient, }); function mountBoardApp(el) { @@ -101,6 +87,9 @@ function mountBoardApp(el) { iterationListsAvailable: parseBoolean(el.dataset.iterationListsAvailable), issuableType: issuableTypes.issue, emailsDisabled: parseBoolean(el.dataset.emailsDisabled), + allowLabelCreate: parseBoolean(el.dataset.canUpdate), + allowLabelEdit: parseBoolean(el.dataset.canUpdate), + allowScopedLabels: parseBoolean(el.dataset.scopedLabels), }, render: (createComponent) => createComponent(BoardApp), }); diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index dc06b62cebb..ca993e75cf9 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -19,7 +19,6 @@ import { import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -35,6 +34,7 @@ import { FiltersInfo, filterVariables, } from '../boards_util'; +import { gqlClient } from '../graphql'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql'; import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql'; @@ -47,13 +47,6 @@ import projectBoardMilestonesQuery from '../graphql/project_board_milestones.que import * as types from './mutation_types'; -export const gqlClient = createGqClient( - {}, - { - fetchPolicy: fetchPolicies.NO_CACHE, - }, -); - export default { setInitialBoardData: ({ commit }, data) => { commit(types.SET_INITIAL_BOARD_DATA, data); @@ -603,7 +596,7 @@ export default { }); }, - addListItem: ({ commit }, { list, item, position, inProgress = false }) => { + addListItem: ({ commit, dispatch }, { list, item, position, inProgress = false }) => { commit(types.ADD_BOARD_ITEM_TO_LIST, { listId: list.id, itemId: item.id, @@ -611,6 +604,9 @@ export default { inProgress, }); commit(types.UPDATE_BOARD_ITEM, item); + if (!inProgress) { + dispatch('setActiveId', { id: item.id, sidebarType: ISSUABLE }); + } }, removeListItem: ({ commit }, { listId, itemId }) => { @@ -660,6 +656,7 @@ export default { }, setActiveIssueLabels: async ({ commit, getters }, input) => { + commit(types.SET_LABELS_LOADING, true); const { activeBoardItem } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetLabelsMutation, @@ -673,6 +670,8 @@ export default { }, }); + commit(types.SET_LABELS_LOADING, false); + if (data.updateIssue?.errors?.length > 0) { throw new Error(data.updateIssue.errors); } diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 928cece19f7..26b785932bb 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -28,6 +28,7 @@ export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST'; export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID'; +export const SET_LABELS_LOADING = 'SET_LABELS_LOADING'; export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING'; export const RESET_ISSUES = 'RESET_ISSUES'; export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index ef5b84b4575..d381c076c19 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -195,6 +195,10 @@ export default { Vue.set(state.boardItems[itemId], prop, value); }, + [mutationTypes.SET_LABELS_LOADING](state, isLoading) { + state.isSettingLabels = isLoading; + }, + [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) { state.isSettingAssignees = isLoading; }, diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 80c51c966d2..2a6605e687b 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -12,6 +12,7 @@ export default () => ({ listsFlags: {}, boardItemsByListId: {}, backupItemsList: [], + isSettingLabels: false, isSettingAssignees: false, pageInfoByListId: {}, boardItems: {}, diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js index 274aab45deb..f97590ec5db 100644 --- a/app/assets/javascripts/ci_lint/index.js +++ b/app/assets/javascripts/ci_lint/index.js @@ -8,7 +8,9 @@ import CiLint from './components/ci_lint.vue'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers), + defaultClient: createDefaultClient(resolvers, { + assumeImmutableResults: true, + }), }); export default (containerId = '#js-ci-lint') => { diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue new file mode 100644 index 00000000000..5c672d288c5 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -0,0 +1,159 @@ +<script> +import { + GlAlert, + GlBadge, + GlKeysetPagination, + GlLoadingIcon, + GlSprintf, + GlTab, + GlTabs, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { MAX_LIST_COUNT } from '../constants'; +import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql'; +import TokenTable from './token_table.vue'; + +export default { + i18n: { + installedInfo: s__('ClusterAgents|Created by %{name} %{time}'), + loadingError: s__('ClusterAgents|An error occurred while loading your agent'), + tokens: s__('ClusterAgents|Access tokens'), + unknownUser: s__('ClusterAgents|Unknown user'), + }, + apollo: { + clusterAgent: { + query: getClusterAgentQuery, + variables() { + return { + agentName: this.agentName, + projectPath: this.projectPath, + ...this.cursor, + }; + }, + update: (data) => data?.project?.clusterAgent, + error() { + this.clusterAgent = null; + }, + }, + }, + components: { + GlAlert, + GlBadge, + GlKeysetPagination, + GlLoadingIcon, + GlSprintf, + GlTab, + GlTabs, + TimeAgoTooltip, + TokenTable, + }, + props: { + agentName: { + required: true, + type: String, + }, + projectPath: { + required: true, + type: String, + }, + }, + data() { + return { + cursor: { + first: MAX_LIST_COUNT, + last: null, + }, + }; + }, + computed: { + createdAt() { + return this.clusterAgent?.createdAt; + }, + createdBy() { + return this.clusterAgent?.createdByUser?.name || this.$options.i18n.unknownUser; + }, + isLoading() { + return this.$apollo.queries.clusterAgent.loading; + }, + showPagination() { + return this.tokenPageInfo.hasPreviousPage || this.tokenPageInfo.hasNextPage; + }, + tokenCount() { + return this.clusterAgent?.tokens?.count; + }, + tokenPageInfo() { + return this.clusterAgent?.tokens?.pageInfo || {}; + }, + tokens() { + return this.clusterAgent?.tokens?.nodes || []; + }, + }, + methods: { + nextPage() { + this.cursor = { + first: MAX_LIST_COUNT, + last: null, + afterToken: this.tokenPageInfo.endCursor, + }; + }, + prevPage() { + this.cursor = { + first: null, + last: MAX_LIST_COUNT, + beforeToken: this.tokenPageInfo.startCursor, + }; + }, + }, +}; +</script> + +<template> + <section> + <h2>{{ agentName }}</h2> + + <gl-loading-icon v-if="isLoading && clusterAgent == null" size="lg" class="gl-m-3" /> + + <div v-else-if="clusterAgent"> + <p data-testid="cluster-agent-create-info"> + <gl-sprintf :message="$options.i18n.installedInfo"> + <template #name> + {{ createdBy }} + </template> + + <template #time> + <time-ago-tooltip :time="createdAt" /> + </template> + </gl-sprintf> + </p> + + <gl-tabs> + <gl-tab> + <template #title> + <span data-testid="cluster-agent-token-count"> + {{ $options.i18n.tokens }} + + <gl-badge v-if="tokenCount" size="sm" class="gl-tab-counter-badge">{{ + tokenCount + }}</gl-badge> + </span> + </template> + + <gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" /> + + <div v-else> + <TokenTable :tokens="tokens" /> + + <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-keyset-pagination v-bind="tokenPageInfo" @prev="prevPage" @next="nextPage" /> + </div> + </div> + </gl-tab> + </gl-tabs> + </div> + + <gl-alert v-else variant="danger" :dismissible="false"> + {{ $options.i18n.loadingError }} + </gl-alert> + </section> +</template> diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue new file mode 100644 index 00000000000..70ed2566134 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/components/token_table.vue @@ -0,0 +1,122 @@ +<script> +import { GlEmptyState, GlLink, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + GlEmptyState, + GlLink, + GlTable, + GlTooltip, + GlTruncate, + TimeAgoTooltip, + }, + i18n: { + createdBy: s__('ClusterAgents|Created by'), + createToken: s__('ClusterAgents|You will need to create a token to connect to your agent'), + dateCreated: s__('ClusterAgents|Date created'), + description: s__('ClusterAgents|Description'), + lastUsed: s__('ClusterAgents|Last contact'), + learnMore: s__('ClusterAgents|Learn how to create an agent access token'), + name: s__('ClusterAgents|Name'), + neverUsed: s__('ClusterAgents|Never'), + noTokens: s__('ClusterAgents|This agent has no tokens'), + unknownUser: s__('ClusterAgents|Unknown user'), + }, + props: { + tokens: { + required: true, + type: Array, + }, + }, + computed: { + fields() { + return [ + { + key: 'name', + label: this.$options.i18n.name, + tdAttr: { 'data-testid': 'agent-token-name' }, + }, + { + key: 'lastUsed', + label: this.$options.i18n.lastUsed, + tdAttr: { 'data-testid': 'agent-token-used' }, + }, + { + key: 'createdAt', + label: this.$options.i18n.dateCreated, + tdAttr: { 'data-testid': 'agent-token-created-time' }, + }, + { + key: 'createdBy', + label: this.$options.i18n.createdBy, + tdAttr: { 'data-testid': 'agent-token-created-user' }, + }, + { + key: 'description', + label: this.$options.i18n.description, + tdAttr: { 'data-testid': 'agent-token-description' }, + }, + ]; + }, + learnMoreUrl() { + return helpPagePath('user/clusters/agent/index.md', { + anchor: 'create-an-agent-record-in-gitlab', + }); + }, + }, + methods: { + createdByName(token) { + return token?.createdByUser?.name || this.$options.i18n.unknownUser; + }, + }, +}; +</script> + +<template> + <div v-if="tokens.length"> + <div class="gl-text-right gl-my-5"> + <gl-link target="_blank" :href="learnMoreUrl"> + {{ $options.i18n.learnMore }} + </gl-link> + </div> + + <gl-table :items="tokens" :fields="fields" fixed stacked="md"> + <template #cell(lastUsed)="{ item }"> + <time-ago-tooltip v-if="item.lastUsedAt" :time="item.lastUsedAt" /> + <span v-else>{{ $options.i18n.neverUsed }}</span> + </template> + + <template #cell(createdAt)="{ item }"> + <time-ago-tooltip :time="item.createdAt" /> + </template> + + <template #cell(createdBy)="{ item }"> + <span>{{ createdByName(item) }}</span> + </template> + + <template #cell(description)="{ item }"> + <div v-if="item.description" :id="`tooltip-description-container-${item.id}`"> + <gl-truncate :id="`tooltip-description-${item.id}`" :text="item.description" /> + + <gl-tooltip + :container="`tooltip-description-container-${item.id}`" + :target="`tooltip-description-${item.id}`" + placement="top" + > + {{ item.description }} + </gl-tooltip> + </div> + </template> + </gl-table> + </div> + + <gl-empty-state + v-else + :title="$options.i18n.noTokens" + :primary-button-link="learnMoreUrl" + :primary-button-text="$options.i18n.learnMore" + /> +</template> diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js new file mode 100644 index 00000000000..bbc4630f83b --- /dev/null +++ b/app/assets/javascripts/clusters/agents/constants.js @@ -0,0 +1 @@ +export const MAX_LIST_COUNT = 25; diff --git a/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql new file mode 100644 index 00000000000..1e9187e8ad1 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql @@ -0,0 +1,11 @@ +fragment Token on ClusterAgentToken { + id + createdAt + description + lastUsedAt + name + + createdByUser { + name + } +} diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql new file mode 100644 index 00000000000..d01db8f0a6a --- /dev/null +++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql @@ -0,0 +1,34 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/cluster_agent_token.fragment.graphql" + +query getClusterAgent( + $projectPath: ID! + $agentName: String! + $first: Int + $last: Int + $afterToken: String + $beforeToken: String +) { + project(fullPath: $projectPath) { + clusterAgent(name: $agentName) { + id + createdAt + + createdByUser { + name + } + + tokens(first: $first, last: $last, before: $beforeToken, after: $afterToken) { + count + + nodes { + ...Token + } + + pageInfo { + ...PageInfo + } + } + } + } +} diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js new file mode 100644 index 00000000000..bcb5b271203 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/index.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import AgentShowPage from './components/show.vue'; + +Vue.use(VueApollo); + +export default () => { + const el = document.querySelector('#js-cluster-agent-details'); + + if (!el) { + return null; + } + + const defaultClient = createDefaultClient(); + const { agentName, projectPath } = el.dataset; + + return new Vue({ + el, + apolloProvider: new VueApollo({ defaultClient }), + render(createElement) { + return createElement(AgentShowPage, { + props: { + agentName, + projectPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js new file mode 100644 index 00000000000..9b870134512 --- /dev/null +++ b/app/assets/javascripts/clusters_list/clusters_util.js @@ -0,0 +1,8 @@ +export function generateAgentRegistrationCommand(agentToken, kasAddress) { + return `docker run --pull=always --rm \\ + registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/cli:stable generate \\ + --agent-token=${agentToken} \\ + --kas-address=${kasAddress} \\ + --agent-version stable \\ + --namespace gitlab-kubernetes-agent | kubectl apply -f -`; +} diff --git a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue new file mode 100644 index 00000000000..405339b3d36 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue @@ -0,0 +1,119 @@ +<script> +import { GlButton, GlEmptyState, GlLink, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui'; +import { INSTALL_AGENT_MODAL_ID } from '../constants'; + +export default { + modalId: INSTALL_AGENT_MODAL_ID, + components: { + GlButton, + GlEmptyState, + GlLink, + GlSprintf, + GlAlert, + }, + directives: { + GlModalDirective, + }, + inject: [ + 'emptyStateImage', + 'projectPath', + 'agentDocsUrl', + 'installDocsUrl', + 'getStartedDocsUrl', + 'integrationDocsUrl', + ], + props: { + hasConfigurations: { + type: Boolean, + required: true, + }, + }, + computed: { + repositoryPath() { + return `/${this.projectPath}`; + }, + }, +}; +</script> + +<template> + <gl-empty-state + :svg-path="emptyStateImage" + :title="s__('ClusterAgents|Integrate Kubernetes with a GitLab Agent')" + class="empty-state--agent" + > + <template #description> + <p class="mw-460 gl-mx-auto"> + <gl-sprintf + :message=" + s__( + 'ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="agentDocsUrl" target="_blank" data-testid="agent-docs-link"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + + <p class="mw-460 gl-mx-auto"> + <gl-sprintf + :message=" + s__( + 'ClusterAgents|The GitLab Agent also requires %{linkStart}enabling the Agent Server%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="installDocsUrl" target="_blank" data-testid="install-docs-link"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + + <gl-alert + v-if="!hasConfigurations" + variant="warning" + class="gl-mb-5 text-left" + :dismissible="false" + > + {{ + s__( + 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.', + ) + }} + + <template #actions> + <gl-button + category="primary" + variant="info" + :href="getStartedDocsUrl" + target="_blank" + class="gl-ml-0!" + > + {{ s__('ClusterAgents|Read more about getting started') }} + </gl-button> + <gl-button category="secondary" variant="info" :href="repositoryPath"> + {{ s__('ClusterAgents|Go to the repository') }} + </gl-button> + </template> + </gl-alert> + </template> + + <template #actions> + <gl-button + v-gl-modal-directive="$options.modalId" + :disabled="!hasConfigurations" + data-testid="integration-primary-button" + category="primary" + variant="success" + > + {{ s__('ClusterAgents|Integrate with the GitLab Agent') }} + </gl-button> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue new file mode 100644 index 00000000000..487e512c06d --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -0,0 +1,152 @@ +<script> +import { + GlButton, + GlLink, + GlModalDirective, + 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 { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES, TROUBLESHOOTING_LINK } from '../constants'; + +export default { + components: { + GlButton, + GlLink, + GlTable, + GlIcon, + GlSprintf, + GlTooltip, + GlPopover, + TimeAgoTooltip, + }, + directives: { + GlModalDirective, + }, + mixins: [timeagoMixin], + inject: ['integrationDocsUrl'], + INSTALL_AGENT_MODAL_ID, + AGENT_STATUSES, + TROUBLESHOOTING_LINK, + props: { + agents: { + required: true, + type: Array, + }, + }, + computed: { + fields() { + return [ + { + key: 'name', + label: s__('ClusterAgents|Name'), + }, + { + key: 'status', + label: s__('ClusterAgents|Connection status'), + }, + { + key: 'lastContact', + label: s__('ClusterAgents|Last contact'), + }, + { + key: 'configuration', + label: s__('ClusterAgents|Configuration'), + }, + ]; + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-display-block gl-text-right gl-my-3"> + <gl-button + v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" + variant="confirm" + category="primary" + >{{ s__('ClusterAgents|Install a new GitLab Agent') }} + </gl-button> + </div> + + <gl-table + :items="agents" + :fields="fields" + stacked="md" + head-variant="white" + thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" + data-testid="cluster-agent-list-table" + > + <template #cell(name)="{ item }"> + <gl-link :href="item.webPath" data-testid="cluster-agent-name-link"> + {{ item.name }} + </gl-link> + </template> + + <template #cell(status)="{ item }"> + <span + :id="`connection-status-${item.name}`" + class="gl-pr-5" + data-testid="cluster-agent-connection-status" + > + <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3"> + <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span + >{{ $options.AGENT_STATUSES[item.status].name }} + </span> + <gl-tooltip + v-if="item.status === 'active'" + :target="`connection-status-${item.name}`" + placement="right" + > + <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title" + ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template> + </gl-sprintf> + </gl-tooltip> + <gl-popover + v-else + :target="`connection-status-${item.name}`" + :title="$options.AGENT_STATUSES[item.status].tooltip.title" + placement="right" + container="viewport" + > + <p> + <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body" + ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf + > + </p> + <p class="gl-mb-0"> + {{ s__('ClusterAgents|For more troubleshooting information go to') }} + <gl-link :href="$options.TROUBLESHOOTING_LINK" target="_blank" class="gl-font-sm"> + {{ $options.TROUBLESHOOTING_LINK }}</gl-link + > + </p> + </gl-popover> + </template> + + <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> + </template> + + <template #cell(configuration)="{ item }"> + <span data-testid="cluster-agent-configuration-link"> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <gl-link v-if="item.configFolder" :href="item.configFolder.webPath"> + .gitlab/agents/{{ item.name }} + </gl-link> + + <span v-else>.gitlab/agents/{{ item.name }}</span> + <!-- eslint-enable @gitlab/vue-require-i18n-strings --> + </span> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue new file mode 100644 index 00000000000..ed44c1f5fa7 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -0,0 +1,156 @@ +<script> +import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui'; +import { MAX_LIST_COUNT, ACTIVE_CONNECTION_TIME } from '../constants'; +import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; +import AgentEmptyState from './agent_empty_state.vue'; +import AgentTable from './agent_table.vue'; +import InstallAgentModal from './install_agent_modal.vue'; + +export default { + apollo: { + agents: { + query: getAgentsQuery, + variables() { + return { + defaultBranchName: this.defaultBranchName, + projectPath: this.projectPath, + ...this.cursor, + }; + }, + update(data) { + this.updateTreeList(data); + return data; + }, + }, + }, + components: { + AgentEmptyState, + AgentTable, + InstallAgentModal, + GlAlert, + GlKeysetPagination, + GlLoadingIcon, + }, + inject: ['projectPath'], + props: { + defaultBranchName: { + default: '.noBranch', + required: false, + type: String, + }, + }, + data() { + return { + cursor: { + first: MAX_LIST_COUNT, + last: null, + }, + folderList: {}, + }; + }, + computed: { + agentList() { + let list = this.agents?.project?.clusterAgents?.nodes; + + if (list) { + list = list.map((agent) => { + const configFolder = this.folderList[agent.name]; + const lastContact = this.getLastContact(agent); + const status = this.getStatus(lastContact); + return { ...agent, configFolder, lastContact, status }; + }); + } + + return list; + }, + agentPageInfo() { + return this.agents?.project?.clusterAgents?.pageInfo || {}; + }, + isLoading() { + return this.$apollo.queries.agents.loading; + }, + showPagination() { + return this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage; + }, + treePageInfo() { + return this.agents?.project?.repository?.tree?.trees?.pageInfo || {}; + }, + hasConfigurations() { + return Boolean(this.agents?.project?.repository?.tree?.trees?.nodes?.length); + }, + }, + methods: { + reloadAgents() { + this.$apollo.queries.agents.refetch(); + }, + nextPage() { + this.cursor = { + first: MAX_LIST_COUNT, + last: null, + afterAgent: this.agentPageInfo.endCursor, + afterTree: this.treePageInfo.endCursor, + }; + }, + prevPage() { + this.cursor = { + first: null, + last: MAX_LIST_COUNT, + beforeAgent: this.agentPageInfo.startCursor, + beforeTree: this.treePageInfo.endCursor, + }; + }, + updateTreeList(data) { + const configFolders = data?.project?.repository?.tree?.trees?.nodes; + + if (configFolders) { + configFolders.forEach((folder) => { + this.folderList[folder.name] = folder; + }); + } + }, + getLastContact(agent) { + const tokens = agent?.tokens?.nodes; + let lastContact = null; + if (tokens?.length) { + tokens.forEach((token) => { + const lastContactToDate = new Date(token.lastUsedAt).getTime(); + if (lastContactToDate > lastContact) { + lastContact = lastContactToDate; + } + }); + } + return lastContact; + }, + getStatus(lastContact) { + if (lastContact) { + const now = new Date().getTime(); + const diff = now - lastContact; + + return diff > ACTIVE_CONNECTION_TIME ? 'inactive' : 'active'; + } + return 'unused'; + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" /> + + <section v-else-if="agentList" class="gl-mt-3"> + <div v-if="agentList.length"> + <AgentTable :agents="agentList" /> + + <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" /> + </div> + </div> + + <AgentEmptyState v-else :has-configurations="hasConfigurations" /> + <InstallAgentModal @agentRegistered="reloadAgents" /> + </section> + + <gl-alert v-else variant="danger" :dismissible="false"> + {{ s__('ClusterAgents|An error occurred while loading your GitLab Agents') }} + </gl-alert> +</template> diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue new file mode 100644 index 00000000000..9fb020d2f4f --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue @@ -0,0 +1,83 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants'; +import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql'; + +export default { + name: 'AvailableAgentsDropdown', + i18n: I18N_AVAILABLE_AGENTS_DROPDOWN, + components: { + GlDropdown, + GlDropdownItem, + }, + inject: ['projectPath'], + props: { + isRegistering: { + required: true, + type: Boolean, + }, + }, + apollo: { + agents: { + query: agentConfigurations, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + this.populateAvailableAgents(data); + }, + }, + }, + data() { + return { + availableAgents: [], + selectedAgent: null, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.agents.loading; + }, + dropdownText() { + if (this.isRegistering) { + return this.$options.i18n.registeringAgent; + } else if (this.selectedAgent === null) { + return this.$options.i18n.selectAgent; + } + + return this.selectedAgent; + }, + }, + methods: { + selectAgent(agent) { + this.$emit('agentSelected', agent); + this.selectedAgent = agent; + }, + isSelected(agent) { + return this.selectedAgent === agent; + }, + populateAvailableAgents(data) { + const installedAgents = data?.project?.clusterAgents?.nodes.map((agent) => agent.name) ?? []; + const configuredAgents = + data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? []; + + this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent)); + }, + }, +}; +</script> +<template> + <gl-dropdown :text="dropdownText" :loading="isLoading || isRegistering"> + <gl-dropdown-item + v-for="agent in availableAgents" + :key="agent" + :is-checked="isSelected(agent)" + is-check-item + @click="selectAgent(agent)" + > + {{ agent }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue new file mode 100644 index 00000000000..5f192fe4d5a --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue @@ -0,0 +1,259 @@ +<script> +import { + GlAlert, + GlButton, + GlFormGroup, + GlFormInputGroup, + GlLink, + GlModal, + GlSprintf, +} from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CodeBlock from '~/vue_shared/components/code_block.vue'; +import { generateAgentRegistrationCommand } from '../clusters_util'; +import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants'; +import createAgent from '../graphql/mutations/create_agent.mutation.graphql'; +import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql'; +import AvailableAgentsDropdown from './available_agents_dropdown.vue'; + +export default { + modalId: INSTALL_AGENT_MODAL_ID, + i18n: I18N_INSTALL_AGENT_MODAL, + components: { + AvailableAgentsDropdown, + ClipboardButton, + CodeBlock, + GlAlert, + GlButton, + GlFormGroup, + GlFormInputGroup, + GlLink, + GlModal, + GlSprintf, + }, + inject: ['projectPath', 'kasAddress'], + data() { + return { + registering: false, + agentName: null, + agentToken: null, + error: null, + }; + }, + computed: { + registered() { + return Boolean(this.agentToken); + }, + nextButtonDisabled() { + return !this.registering && this.agentName !== null; + }, + canCancel() { + return !this.registered && !this.registering; + }, + agentRegistrationCommand() { + return generateAgentRegistrationCommand(this.agentToken, this.kasAddress); + }, + basicInstallPath() { + return helpPagePath('user/clusters/agent/index', { + anchor: 'install-the-agent-into-the-cluster', + }); + }, + advancedInstallPath() { + return helpPagePath('user/clusters/agent/index', { anchor: 'advanced-installation' }); + }, + }, + methods: { + setAgentName(name) { + this.agentName = name; + }, + cancelClicked() { + this.$refs.modal.hide(); + }, + doneClicked() { + this.$emit('agentRegistered'); + this.$refs.modal.hide(); + }, + resetModal() { + this.registering = null; + this.agentName = null; + this.agentToken = null; + this.error = null; + }, + createAgentMutation() { + return this.$apollo + .mutate({ + mutation: createAgent, + variables: { + input: { + name: this.agentName, + projectPath: this.projectPath, + }, + }, + }) + .then(({ data: { createClusterAgent } }) => createClusterAgent); + }, + createAgentTokenMutation(agendId) { + return this.$apollo + .mutate({ + mutation: createAgentToken, + variables: { + input: { + clusterAgentId: agendId, + name: this.agentName, + }, + }, + }) + .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate); + }, + async registerAgent() { + this.registering = true; + this.error = null; + + try { + const { errors: agentErrors, clusterAgent } = await this.createAgentMutation(); + + if (agentErrors?.length > 0) { + throw new Error(agentErrors[0]); + } + + const { errors: tokenErrors, secret } = await this.createAgentTokenMutation( + clusterAgent.id, + ); + + if (tokenErrors?.length > 0) { + throw new Error(tokenErrors[0]); + } + + this.agentToken = secret; + } catch (error) { + if (error) { + this.error = error.message; + } else { + this.error = this.$options.i18n.unknownError; + } + } finally { + this.registering = false; + } + }, + }, +}; +</script> + +<template> + <gl-modal + ref="modal" + :modal-id="$options.modalId" + :title="$options.i18n.modalTitle" + static + lazy + @hidden="resetModal" + > + <template v-if="!registered"> + <p> + <strong>{{ $options.i18n.selectAgentTitle }}</strong> + </p> + + <p> + <gl-sprintf :message="$options.i18n.selectAgentBody"> + <template #link="{ content }"> + <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + + <form> + <gl-form-group label-for="agent-name"> + <available-agents-dropdown + class="gl-w-70p" + :is-registering="registering" + @agentSelected="setAgentName" + /> + </gl-form-group> + </form> + + <p v-if="error"> + <gl-alert + :title="$options.i18n.registrationErrorTitle" + variant="danger" + :dismissible="false" + > + {{ error }} + </gl-alert> + </p> + </template> + + <template v-else> + <p> + <strong>{{ $options.i18n.tokenTitle }}</strong> + </p> + + <p> + <gl-sprintf :message="$options.i18n.tokenBody"> + <template #link="{ content }"> + <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + + <p> + <gl-alert + :title="$options.i18n.tokenSingleUseWarningTitle" + variant="warning" + :dismissible="false" + > + {{ $options.i18n.tokenSingleUseWarningBody }} + </gl-alert> + </p> + + <p> + <gl-form-input-group readonly :value="agentToken" :select-on-click="true"> + <template #append> + <clipboard-button :text="agentToken" :title="$options.i18n.copyToken" /> + </template> + </gl-form-input-group> + </p> + + <p> + <strong>{{ $options.i18n.basicInstallTitle }}</strong> + </p> + + <p> + {{ $options.i18n.basicInstallBody }} + </p> + + <p> + <code-block :code="agentRegistrationCommand" /> + </p> + + <p> + <strong>{{ $options.i18n.advancedInstallTitle }}</strong> + </p> + + <p> + <gl-sprintf :message="$options.i18n.advancedInstallBody"> + <template #link="{ content }"> + <gl-link :href="advancedInstallPath" target="_blank"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + + <template #modal-footer> + <gl-button v-if="canCancel" @click="cancelClicked">{{ $options.i18n.cancel }} </gl-button> + + <gl-button v-if="registered" variant="confirm" category="primary" @click="doneClicked" + >{{ $options.i18n.done }} + </gl-button> + + <gl-button + v-else + :disabled="!nextButtonDisabled" + variant="confirm" + category="primary" + @click="registerAgent" + >{{ $options.i18n.next }} + </gl-button> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index f39678b73dc..0bade1fc281 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -1,4 +1,10 @@ -import { __, s__ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; + +export const MAX_LIST_COUNT = 25; +export const INSTALL_AGENT_MODAL_ID = 'install-agent'; +export const ACTIVE_CONNECTION_TIME = 480000; +export const TROUBLESHOOTING_LINK = + 'https://docs.gitlab.com/ee/user/clusters/agent/#troubleshooting'; export const CLUSTER_ERRORS = { default: { @@ -58,3 +64,80 @@ export const STATUSES = { deleting: { title: __('Deleting') }, creating: { title: __('Creating') }, }; + +export const I18N_INSTALL_AGENT_MODAL = { + next: __('Next'), + done: __('Done'), + cancel: __('Cancel'), + + modalTitle: s__('ClusterAgents|Install new Agent'), + + selectAgentTitle: s__('ClusterAgents|Select which Agent you want to install'), + selectAgentBody: s__( + `ClusterAgents|Select the Agent you want to register with GitLab and install on your cluster. To learn more about the Kubernetes Agent registration process %{linkStart}go to the documentation%{linkEnd}.`, + ), + + copyToken: s__('ClusterAgents|Copy token'), + tokenTitle: s__('ClusterAgents|Registration token'), + tokenBody: s__( + `ClusterAgents|The registration token will be used to connect the Agent on your cluster to GitLab. To learn more about the registration tokens and how they are used %{linkStart}go to the documentation%{linkEnd}.`, + ), + + tokenSingleUseWarningTitle: s__( + 'ClusterAgents|The token value will not be shown again after you close this window.', + ), + tokenSingleUseWarningBody: s__( + `ClusterAgents|The recommended installation method provided below includes the token. If you want to follow the alternative installation method provided in the docs make sure you save the token value before you close the window.`, + ), + + basicInstallTitle: s__('ClusterAgents|Recommended installation method'), + basicInstallBody: s__( + `Open a CLI and connect to the cluster you want to install the Agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`, + ), + + advancedInstallTitle: s__('ClusterAgents|Alternative installation methods'), + advancedInstallBody: s__( + 'ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}.', + ), + + registrationErrorTitle: s__('Failed to register Agent'), + unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), +}; + +export const I18N_AVAILABLE_AGENTS_DROPDOWN = { + selectAgent: s__('ClusterAgents|Select an Agent'), + registeringAgent: s__('ClusterAgents|Registering Agent'), +}; + +export const AGENT_STATUSES = { + active: { + name: s__('ClusterAgents|Connected'), + icon: 'status-success', + class: 'text-success-500', + tooltip: { + title: sprintf(s__('ClusterAgents|Last connected %{timeAgo}.')), + }, + }, + inactive: { + name: s__('ClusterAgents|Not connected'), + icon: 'severity-critical', + class: 'text-danger-800', + tooltip: { + title: s__('ClusterAgents|Agent might not be connected to GitLab'), + body: sprintf( + s__( + 'ClusterAgents|The Agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.', + ), + ), + }, + }, + unused: { + name: s__('ClusterAgents|Never connected'), + icon: 'status-neutral', + class: 'text-secondary-400', + tooltip: { + title: s__('ClusterAgents|Agent never connected to GitLab'), + body: s__('ClusterAgents|Make sure you are using a valid token.'), + }, + }, +}; diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql new file mode 100644 index 00000000000..c29756159f5 --- /dev/null +++ b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql @@ -0,0 +1,8 @@ +mutation createClusterAgent($input: CreateClusterAgentInput!) { + createClusterAgent(input: $input) { + clusterAgent { + id + } + errors + } +} diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql new file mode 100644 index 00000000000..e93580cf416 --- /dev/null +++ b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql @@ -0,0 +1,9 @@ +mutation createClusterAgentToken($input: ClusterAgentTokenCreateInput!) { + clusterAgentTokenCreate(input: $input) { + secret + token { + id + } + errors + } +} diff --git a/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql new file mode 100644 index 00000000000..40b61337024 --- /dev/null +++ b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql @@ -0,0 +1,15 @@ +query agentConfigurations($projectPath: ID!) { + project(fullPath: $projectPath) { + agentConfigurations { + nodes { + agentName + } + } + + clusterAgents { + nodes { + name + } + } + } +} diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql new file mode 100644 index 00000000000..61989e00d9e --- /dev/null +++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql @@ -0,0 +1,47 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getAgents( + $defaultBranchName: String! + $projectPath: ID! + $first: Int + $last: Int + $afterAgent: String + $afterTree: String + $beforeAgent: String + $beforeTree: String +) { + project(fullPath: $projectPath) { + clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) { + nodes { + id + name + webPath + tokens { + nodes { + lastUsedAt + } + } + } + + pageInfo { + ...PageInfo + } + } + + repository { + tree(path: ".gitlab/agents", ref: $defaultBranchName) { + trees(first: $first, last: $last, after: $afterTree, before: $beforeTree) { + nodes { + name + path + webPath + } + + pageInfo { + ...PageInfo + } + } + } + } + } +} diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js index daa82892773..de18965abbd 100644 --- a/app/assets/javascripts/clusters_list/index.js +++ b/app/assets/javascripts/clusters_list/index.js @@ -1,6 +1,11 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import loadClusters from './load_clusters'; +import loadAgents from './load_agents'; + +Vue.use(VueApollo); export default () => { loadClusters(Vue); + loadAgents(Vue, VueApollo); }; diff --git a/app/assets/javascripts/clusters_list/load_agents.js b/app/assets/javascripts/clusters_list/load_agents.js new file mode 100644 index 00000000000..b77d386df20 --- /dev/null +++ b/app/assets/javascripts/clusters_list/load_agents.js @@ -0,0 +1,44 @@ +import createDefaultClient from '~/lib/graphql'; +import Agents from './components/agents.vue'; + +export default (Vue, VueApollo) => { + const el = document.querySelector('#js-cluster-agents-list'); + + if (!el) { + return null; + } + + const defaultClient = createDefaultClient({}, { assumeImmutableResults: true }); + + const { + emptyStateImage, + defaultBranchName, + projectPath, + agentDocsUrl, + installDocsUrl, + getStartedDocsUrl, + integrationDocsUrl, + kasAddress, + } = el.dataset; + + return new Vue({ + el, + apolloProvider: new VueApollo({ defaultClient }), + provide: { + emptyStateImage, + projectPath, + agentDocsUrl, + installDocsUrl, + getStartedDocsUrl, + integrationDocsUrl, + kasAddress, + }, + render(createElement) { + return createElement(Agents, { + props: { + defaultBranchName, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js deleted file mode 100644 index 2fcd40a901d..00000000000 --- a/app/assets/javascripts/comment_type_toggle.js +++ /dev/null @@ -1,71 +0,0 @@ -import DropLab from './droplab/drop_lab'; -import ISetter from './droplab/plugins/input_setter'; - -// Todo: Remove this when fixing issue in input_setter plugin -const InputSetter = { ...ISetter }; - -class CommentTypeToggle { - constructor(opts = {}) { - this.dropdownTrigger = opts.dropdownTrigger; - this.dropdownList = opts.dropdownList; - this.noteTypeInput = opts.noteTypeInput; - this.submitButton = opts.submitButton; - this.closeButton = opts.closeButton; - this.reopenButton = opts.reopenButton; - } - - initDroplab() { - this.droplab = new DropLab(); - - const config = this.setConfig(); - - this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config); - } - - setConfig() { - const config = { - InputSetter: [ - { - input: this.noteTypeInput, - valueAttribute: 'data-value', - }, - { - input: this.submitButton, - valueAttribute: 'data-submit-text', - }, - ], - }; - - if (this.closeButton) { - config.InputSetter.push( - { - input: this.closeButton, - valueAttribute: 'data-close-text', - }, - { - input: this.closeButton, - valueAttribute: 'data-close-text', - inputAttribute: 'data-alternative-text', - }, - ); - } - - if (this.reopenButton) { - config.InputSetter.push( - { - input: this.reopenButton, - valueAttribute: 'data-reopen-text', - }, - { - input: this.reopenButton, - valueAttribute: 'data-reopen-text', - inputAttribute: 'data-alternative-text', - }, - ); - } - - return config; - } -} - -export default CommentTypeToggle; diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index 82a449ae6af..89182b3a09f 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -112,6 +112,15 @@ export default { @execute="trackToolbarControlExecution" /> <toolbar-button + data-testid="details" + content-type="details" + icon-name="details-block" + class="gl-mx-2" + editor-command="toggleDetails" + :label="__('Add a collapsible section')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button data-testid="horizontal-rule" content-type="horizontalRule" icon-name="dash" diff --git a/app/assets/javascripts/content_editor/components/wrappers/details.vue b/app/assets/javascripts/content_editor/components/wrappers/details.vue new file mode 100644 index 00000000000..aff15ac3e53 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/details.vue @@ -0,0 +1,33 @@ +<script> +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; + +export default { + name: 'DetailsWrapper', + components: { + NodeViewWrapper, + NodeViewContent, + }, + props: { + node: { + type: Object, + required: true, + }, + }, + data() { + return { + open: true, + }; + }, +}; +</script> +<template> + <node-view-wrapper class="gl-display-flex"> + <div + class="details-toggle-icon" + data-testid="details-toggle-icon" + :class="{ 'is-open': open }" + @click="open = !open" + ></div> + <node-view-content as="ul" class="details-content" :class="{ 'is-open': open }" /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue b/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue new file mode 100644 index 00000000000..97b69afd12e --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue @@ -0,0 +1,32 @@ +<script> +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; +import { __ } from '~/locale'; + +export default { + name: 'FrontMatter', + components: { + NodeViewWrapper, + NodeViewContent, + }, + props: { + node: { + type: Object, + required: true, + }, + }, + i18n: { + frontmatter: __('frontmatter'), + }, +}; +</script> +<template> + <node-view-wrapper class="gl-relative code highlight" as="pre"> + <span + data-testid="frontmatter-label" + class="gl-absolute gl-top-0 gl-right-3" + contenteditable="false" + >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span + > + <node-view-content as="code" /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js index 8f2ce8feb5d..9329bbcb2c7 100644 --- a/app/assets/javascripts/content_editor/content_editor.stories.js +++ b/app/assets/javascripts/content_editor/content_editor.stories.js @@ -2,7 +2,7 @@ import { ContentEditor } from './index'; export default { component: ContentEditor, - title: 'Components/Content Editor', + title: 'content_editor/components/content_editor', }; const Template = (_, { argTypes }) => ({ 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 25f5837d2a6..1ed1ab0315f 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -11,7 +11,8 @@ export default CodeBlockLowlight.extend({ parseHTML: (element) => extractLanguage(element), }, class: { - default: 'code highlight js-syntax-highlight', + // eslint-disable-next-line @gitlab/require-i18n-strings + default: 'code highlight', }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/color_chip.js b/app/assets/javascripts/content_editor/extensions/color_chip.js new file mode 100644 index 00000000000..deb5029a1f0 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/color_chip.js @@ -0,0 +1,73 @@ +import { Node } from '@tiptap/core'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import { isValidColorExpression } from '~/lib/utils/color_utils'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +const colorExpressionTypes = ['#', 'hsl', 'rgb']; + +const isValidColor = (color) => { + if (!colorExpressionTypes.some((type) => color.toLowerCase().startsWith(type))) { + return false; + } + + return isValidColorExpression(color); +}; + +const highlightColors = (doc) => { + const decorations = []; + + doc.descendants((node, position) => { + const { text, marks } = node; + + if (!text || marks.length === 0 || marks[0].type.name !== 'code' || !isValidColor(text)) { + return; + } + + const from = position; + const to = from + text.length; + const decoration = Decoration.inline(from, to, { + class: 'gl-display-inline-flex gl-align-items-center content-editor-color-chip', + style: `--gl-color-chip-color: ${text}`, + }); + + decorations.push(decoration); + }); + + return DecorationSet.create(doc, decorations); +}; + +export const colorDecoratorPlugin = new Plugin({ + key: new PluginKey('colorDecorator'), + state: { + init(_, { doc }) { + return highlightColors(doc); + }, + apply(transaction, oldState) { + return transaction.docChanged ? highlightColors(transaction.doc) : oldState; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, +}); + +export default Node.create({ + name: 'colorChip', + + parseHTML() { + return [ + { + tag: '.gfm-color_chip', + ignore: true, + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + ]; + }, + + addProseMirrorPlugins() { + return [colorDecoratorPlugin]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/details.js b/app/assets/javascripts/content_editor/extensions/details.js new file mode 100644 index 00000000000..e3d54ed01fd --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/details.js @@ -0,0 +1,36 @@ +import { Node } from '@tiptap/core'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { wrappingInputRule } from 'prosemirror-inputrules'; +import DetailsWrapper from '../components/wrappers/details.vue'; + +export const inputRegex = /^\s*(<details>)$/; + +export default Node.create({ + name: 'details', + content: 'detailsContent+', + // eslint-disable-next-line @gitlab/require-i18n-strings + group: 'block list', + + parseHTML() { + return [{ tag: 'details' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['ul', HTMLAttributes, 0]; + }, + + addNodeView() { + return VueNodeViewRenderer(DetailsWrapper); + }, + + addInputRules() { + return [wrappingInputRule(inputRegex, this.type)]; + }, + + addCommands() { + return { + setDetails: () => ({ commands }) => commands.wrapInList('details'), + toggleDetails: () => ({ commands }) => commands.toggleList('details', 'detailsContent'), + }; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js new file mode 100644 index 00000000000..fb6c49d91aa --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/details_content.js @@ -0,0 +1,25 @@ +import { Node } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export default Node.create({ + name: 'detailsContent', + content: 'block+', + defining: true, + + parseHTML() { + return [ + { tag: '*', consuming: false, context: 'details/', priority: PARSE_HTML_PRIORITY_HIGHEST }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['li', HTMLAttributes, 0]; + }, + + addKeyboardShortcuts() { + return { + Enter: () => this.editor.commands.splitListItem('detailsContent'), + 'Shift-Tab': () => this.editor.commands.liftListItem('detailsContent'), + }; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js new file mode 100644 index 00000000000..64c84fe046b --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js @@ -0,0 +1,20 @@ +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import FrontmatterWrapper from '../components/wrappers/frontmatter.vue'; +import CodeBlockHighlight from './code_block_highlight'; + +export default CodeBlockHighlight.extend({ + name: 'frontmatter', + parseHTML() { + return [ + { + tag: 'pre[data-lang-params="frontmatter"]', + preserveWhitespace: 'full', + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + ]; + }, + addNodeView() { + return new VueNodeViewRenderer(FrontmatterWrapper); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/math_inline.js b/app/assets/javascripts/content_editor/extensions/math_inline.js new file mode 100644 index 00000000000..60f5288dcf6 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/math_inline.js @@ -0,0 +1,35 @@ +import { Mark, markInputRule } from '@tiptap/core'; +import { __ } from '~/locale'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export const inputRegex = /(?:^|\s)\$`([^`]+)`\$$/gm; + +export default Mark.create({ + name: 'mathInline', + + parseHTML() { + return [ + { + tag: 'code.math[data-math-style=inline]', + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'code', + { + title: __('Inline math'), + 'data-toggle': 'tooltip', + class: 'gl-inset-border-1-gray-400', + ...HTMLAttributes, + }, + 0, + ]; + }, + + addInputRules() { + return [markInputRule(inputRegex, this.type)]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js new file mode 100644 index 00000000000..9e31158837e --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js @@ -0,0 +1,51 @@ +import { Node } from '@tiptap/core'; +import { InputRule } from 'prosemirror-inputrules'; +import { s__ } from '~/locale'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export const inputRuleRegExps = [/^\[\[_TOC_\]\]$/, /^\[TOC\]$/]; + +export default Node.create({ + name: 'tableOfContents', + + inline: false, + + group: 'block', + + parseHTML() { + return [ + { + tag: 'ul.section-nav', + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + ]; + }, + + renderHTML() { + return [ + 'div', + { + class: + 'table-of-contents gl-border-1 gl-border-solid gl-text-center gl-border-gray-100 gl-mb-5', + }, + s__('ContentEditor|Table of Contents'), + ]; + }, + + addInputRules() { + const { type } = this; + + return inputRuleRegExps.map( + (regex) => + new InputRule(regex, (state, match, start, end) => { + const { tr } = state; + + if (match) { + tr.replaceWith(start - 1, end, type.create()); + } + + return tr; + }), + ); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js new file mode 100644 index 00000000000..93b42466850 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/word_break.js @@ -0,0 +1,29 @@ +import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core'; + +export const inputRegex = /^<wbr>$/; + +export default Node.create({ + name: 'wordBreak', + inline: true, + group: 'inline', + selectable: false, + atom: true, + + defaultOptions: { + HTMLAttributes: { + class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm', + }, + }, + + parseHTML() { + return [{ tag: 'wbr' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), '-']; + }, + + addInputRules() { + return [nodeInputRule(inputRegex, this.type)]; + }, +}); diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 9b2d4c9a062..385f1c63801 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -8,14 +8,18 @@ import Bold from '../extensions/bold'; import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; +import ColorChip from '../extensions/color_chip'; import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; +import Details from '../extensions/details'; +import DetailsContent from '../extensions/details_content'; import Division from '../extensions/division'; import Document from '../extensions/document'; import Dropcursor from '../extensions/dropcursor'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; +import Frontmatter from '../extensions/frontmatter'; import Gapcursor from '../extensions/gapcursor'; import HardBreak from '../extensions/hard_break'; import Heading from '../extensions/heading'; @@ -28,6 +32,7 @@ import Italic from '../extensions/italic'; import Link from '../extensions/link'; import ListItem from '../extensions/list_item'; import Loading from '../extensions/loading'; +import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import Reference from '../extensions/reference'; @@ -37,11 +42,13 @@ import Superscript from '../extensions/superscript'; import Table from '../extensions/table'; import TableCell from '../extensions/table_cell'; import TableHeader from '../extensions/table_header'; +import TableOfContents from '../extensions/table_of_contents'; import TableRow from '../extensions/table_row'; import TaskItem from '../extensions/task_item'; import TaskList from '../extensions/task_list'; import Text from '../extensions/text'; import Video from '../extensions/video'; +import WordBreak from '../extensions/word_break'; import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; @@ -75,15 +82,19 @@ export const createContentEditor = ({ Bold, BulletList, Code, + ColorChip, CodeBlockHighlight, DescriptionItem, DescriptionList, + Details, + DetailsContent, Document, Division, Dropcursor, Emoji, Figure, FigureCaption, + Frontmatter, Gapcursor, HardBreak, Heading, @@ -96,6 +107,7 @@ export const createContentEditor = ({ Link, ListItem, Loading, + MathInline, OrderedList, Paragraph, Reference, @@ -104,12 +116,14 @@ export const createContentEditor = ({ Superscript, TableCell, TableHeader, + TableOfContents, TableRow, Table, TaskItem, TaskList, Text, Video, + WordBreak, ]; const allExtensions = [...builtInContentEditorExtensions, ...extensions]; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index bc6d98511f9..0dd3cb5b73f 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -11,10 +11,13 @@ import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; +import Details from '../extensions/details'; +import DetailsContent from '../extensions/details_content'; import Division from '../extensions/division'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; +import Frontmatter from '../extensions/frontmatter'; import HardBreak from '../extensions/hard_break'; import Heading from '../extensions/heading'; import HorizontalRule from '../extensions/horizontal_rule'; @@ -24,6 +27,7 @@ import InlineDiff from '../extensions/inline_diff'; import Italic from '../extensions/italic'; import Link from '../extensions/link'; import ListItem from '../extensions/list_item'; +import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import Reference from '../extensions/reference'; @@ -33,11 +37,13 @@ import Superscript from '../extensions/superscript'; import Table from '../extensions/table'; import TableCell from '../extensions/table_cell'; import TableHeader from '../extensions/table_header'; +import TableOfContents from '../extensions/table_of_contents'; import TableRow from '../extensions/table_row'; import TaskItem from '../extensions/task_item'; import TaskList from '../extensions/task_list'; import Text from '../extensions/text'; import Video from '../extensions/video'; +import WordBreak from '../extensions/word_break'; import { isPlainURL, renderHardBreak, @@ -50,6 +56,7 @@ import { renderImage, renderPlayable, renderHTMLNode, + renderContent, } from './serialization_helpers'; const defaultSerializerConfig = { @@ -80,6 +87,11 @@ const defaultSerializerConfig = { : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; }, }, + [MathInline.name]: { + open: (...args) => `$${defaultMarkdownSerializer.marks.code.open(...args)}`, + close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`, + escape: false, + }, [Strike.name]: { open: '~~', close: '~~', @@ -130,11 +142,34 @@ const defaultSerializerConfig = { renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node); if (index === parent.childCount - 1) state.ensureNewLine(); }, + [Details.name]: renderHTMLNode('details', true), + [DetailsContent.name]: (state, node, parent, index) => { + if (!index) renderHTMLNode('summary')(state, node); + else { + if (index === 1) state.ensureNewLine(); + renderContent(state, node); + if (index === parent.childCount - 1) state.ensureNewLine(); + } + }, [Emoji.name]: (state, node) => { const { name } = node.attrs; state.write(`:${name}:`); }, + [Frontmatter.name]: (state, node) => { + const { language } = node.attrs; + const syntax = { + toml: '+++', + json: ';;;', + yaml: '---', + }[language]; + + state.write(`${syntax}\n`); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write(syntax); + state.closeBlock(node); + }, [Figure.name]: renderHTMLNode('figure'), [FigureCaption.name]: renderHTMLNode('figcaption'), [HardBreak.name]: renderHardBreak, @@ -147,6 +182,10 @@ const defaultSerializerConfig = { [Reference.name]: (state, node) => { state.write(node.attrs.originalText || node.attrs.text); }, + [TableOfContents.name]: (state, node) => { + state.write('[[_TOC_]]'); + state.closeBlock(node); + }, [Table.name]: renderTable, [TableCell.name]: renderTableCell, [TableHeader.name]: renderTableCell, @@ -161,6 +200,7 @@ const defaultSerializerConfig = { }, [Text.name]: defaultMarkdownSerializer.nodes.text, [Video.name]: renderPlayable, + [WordBreak.name]: (state) => state.write('<wbr>'), }, }; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index 4aee02e45c8..9d4eddc510a 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -293,7 +293,7 @@ export default { :items="roles" :loading="isLoadingRoles" :loading-text="s__('ClusterIntegration|Loading IAM Roles')" - :placeholder="s__('ClusterIntergation|Select service role')" + :placeholder="s__('ClusterIntegration|Select service role')" :search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')" :empty-text="s__('ClusterIntegration|No IAM Roles found')" :has-errors="Boolean(loadingRolesError)" @@ -330,7 +330,7 @@ export default { :disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')" :loading="isLoadingKeyPairs" :loading-text="s__('ClusterIntegration|Loading Key Pairs')" - :placeholder="s__('ClusterIntergation|Select key pair')" + :placeholder="s__('ClusterIntegration|Select key pair')" :search-field-placeholder="s__('ClusterIntegration|Search Key Pairs')" :empty-text="s__('ClusterIntegration|No Key Pairs found')" :has-errors="Boolean(loadingKeyPairsError)" @@ -359,7 +359,7 @@ export default { :disabled="vpcDropdownDisabled" :disabled-text="s__('ClusterIntegration|Select a region to choose a VPC')" :loading-text="s__('ClusterIntegration|Loading VPCs')" - :placeholder="s__('ClusterIntergation|Select a VPC')" + :placeholder="s__('ClusterIntegration|Select a VPC')" :search-field-placeholder="s__('ClusterIntegration|Search VPCs')" :empty-text="s__('ClusterIntegration|No VPCs found')" :has-errors="Boolean(loadingVpcsError)" @@ -389,7 +389,7 @@ export default { :disabled="subnetDropdownDisabled" :disabled-text="s__('ClusterIntegration|Select a VPC to choose a subnet')" :loading-text="s__('ClusterIntegration|Loading subnets')" - :placeholder="s__('ClusterIntergation|Select a subnet')" + :placeholder="s__('ClusterIntegration|Select a subnet')" :search-field-placeholder="s__('ClusterIntegration|Search subnets')" :empty-text="s__('ClusterIntegration|No subnet found')" :has-errors="displaySubnetError" @@ -420,7 +420,7 @@ export default { :disabled="securityGroupDropdownDisabled" :disabled-text="s__('ClusterIntegration|Select a VPC to choose a security group')" :loading-text="s__('ClusterIntegration|Loading security groups')" - :placeholder="s__('ClusterIntergation|Select a security group')" + :placeholder="s__('ClusterIntegration|Select a security group')" :search-field-placeholder="s__('ClusterIntegration|Search security groups')" :empty-text="s__('ClusterIntegration|No security group found')" :has-errors="Boolean(loadingSecurityGroupsError)" @@ -451,7 +451,7 @@ export default { :items="instanceTypes" :loading="isLoadingInstanceTypes" :loading-text="s__('ClusterIntegration|Loading instance types')" - :placeholder="s__('ClusterIntergation|Select an instance type')" + :placeholder="s__('ClusterIntegration|Select an instance type')" :search-field-placeholder="s__('ClusterIntegration|Search instance types')" :empty-text="s__('ClusterIntegration|No instance type found')" :has-errors="Boolean(loadingInstanceTypesError)" diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue index 12b6070a79a..8f18ac29c0f 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue @@ -43,7 +43,7 @@ export default { :loading="isLoadingItems" :has-errors="Boolean(loadingItemsError)" :loading-text="s__('ClusterIntegration|Loading networks')" - :placeholder="s__('ClusterIntergation|Select a network')" + :placeholder="s__('ClusterIntegration|Select a network')" :search-field-placeholder="s__('ClusterIntegration|Search networks')" :empty-text="s__('ClusterIntegration|No networks found')" :error-message="s__('ClusterIntegration|Could not load networks')" diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue index ec7889e2907..dab4adc3789 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue @@ -34,7 +34,7 @@ export default { :loading="isLoadingItems" :has-errors="Boolean(loadingItemsError)" :loading-text="s__('ClusterIntegration|Loading subnetworks')" - :placeholder="s__('ClusterIntergation|Select a subnetwork')" + :placeholder="s__('ClusterIntegration|Select a subnetwork')" :search-field-placeholder="s__('ClusterIntegration|Search subnetworks')" :empty-text="s__('ClusterIntegration|No subnetworks found')" :error-message="s__('ClusterIntegration|Could not load subnetworks')" diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 1c0dab11392..f4a27dc7d1f 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -5,8 +5,8 @@ import { canCreateConfidentialMergeRequest, } from './confidential_merge_request'; import confidentialMergeRequestState from './confidential_merge_request/state'; -import DropLab from './droplab/drop_lab'; -import ISetter from './droplab/plugins/input_setter'; +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'; diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index ae78ce33263..1d98a42ce58 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -51,6 +51,7 @@ export default { 'features', 'createdBefore', 'createdAfter', + 'pagination', ]), ...mapGetters(['pathNavigationData', 'filterParams']), displayStageEvents() { @@ -99,7 +100,12 @@ export default { }, }, methods: { - ...mapActions(['fetchStageData', 'setSelectedStage', 'setDateRange']), + ...mapActions([ + 'fetchStageData', + 'setSelectedStage', + 'setDateRange', + 'updateStageTablePagination', + ]), onSetDateRange({ startDate, endDate }) { this.setDateRange({ createdAfter: new Date(startDate), @@ -108,6 +114,7 @@ export default { }, onSelectStage(stage) { this.setSelectedStage(stage); + this.updateStageTablePagination({ ...this.pagination, page: 1 }); }, dismissOverviewDialog() { this.isOverviewDialogDismissed = true; @@ -117,6 +124,9 @@ export default { const { permissions } = this; return Boolean(permissions?.[id]); }, + onHandleUpdatePagination(data) { + this.updateStageTablePagination(data); + }, }, dayRangeOptions: [7, 30, 90], i18n: { @@ -163,8 +173,8 @@ export default { :empty-state-title="emptyStageTitle" :empty-state-message="emptyStageText" :no-data-svg-path="noDataSvgPath" - :pagination="null" - :sortable="false" + :pagination="pagination" + @handleUpdatePagination="onHandleUpdatePagination" /> </div> </template> diff --git a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue index 5140b05e189..016fea354fe 100644 --- a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue +++ b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue @@ -79,7 +79,6 @@ export default { title: __('Assignees'), type: 'assignees', token: AuthorToken, - defaultAuthors: [], initialAuthors: this.assigneesData, unique: false, operators: OPERATOR_IS_ONLY, diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue index 8a2667a4ab1..fc4dfafb809 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue @@ -194,6 +194,9 @@ export default { ><formatted-stage-count :stage-count="stageCount" /></gl-badge> </template> + <template #head(duration)="data"> + <span data-testid="vsa-stage-header-duration">{{ data.label }}</span> + </template> <template #cell(end_event)="{ item }"> <div data-testid="vsa-stage-event"> <div v-if="item.id" data-testid="vsa-stage-content"> diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js index c1be2ce7096..c205aa1e831 100644 --- a/app/assets/javascripts/cycle_analytics/constants.js +++ b/app/assets/javascripts/cycle_analytics/constants.js @@ -44,7 +44,7 @@ export const METRICS_POPOVER_CONTENT = { }, 'cycle-time': { description: s__( - 'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.', + "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", ), }, 'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js index 620da0104e0..34ef03409b8 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -45,6 +45,7 @@ export default () => { new Vue({ el, name: 'CycleAnalytics', + apolloProvider: {}, store, render: (createElement) => createElement(CycleAnalytics, { diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index e39cd224199..24b62849db7 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -6,6 +6,7 @@ import { getValueStreamStageRecords, getValueStreamStageCounts, } from '~/api/analytics_api'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import createFlash from '~/flash'; import { __ } from '~/locale'; import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants'; @@ -72,16 +73,21 @@ export const fetchCycleAnalyticsData = ({ }); }; -export const fetchStageData = ({ getters: { requestParams, filterParams }, commit }) => { +export const fetchStageData = ({ + getters: { requestParams, filterParams, paginationParams }, + commit, +}) => { commit(types.REQUEST_STAGE_DATA); - return getValueStreamStageRecords(requestParams, filterParams) - .then(({ data }) => { + return getValueStreamStageRecords(requestParams, { ...filterParams, ...paginationParams }) + .then(({ data, headers }) => { // when there's a query timeout, the request succeeds but the error is encoded in the response data if (data?.error) { commit(types.RECEIVE_STAGE_DATA_ERROR, data.error); } else { commit(types.RECEIVE_STAGE_DATA_SUCCESS, data); + const { page = null, nextPage = null } = parseIntPagination(normalizeHeaders(headers)); + commit(types.SET_PAGINATION, { ...paginationParams, page, hasNextPage: Boolean(nextPage) }); } }) .catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR)); @@ -176,6 +182,14 @@ export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore return refetchStageData(dispatch); }; +export const updateStageTablePagination = ( + { commit, dispatch, state: { selectedStage } }, + paginationParams, +) => { + commit(types.SET_PAGINATION, paginationParams); + return dispatch('fetchStageData', selectedStage.id); +}; + export const initializeVsa = ({ commit, dispatch }, initialData = {}) => { commit(types.INITIALIZE_VSA, initialData); diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js index 77c285f5ce0..962e1d50d12 100644 --- a/app/assets/javascripts/cycle_analytics/store/getters.js +++ b/app/assets/javascripts/cycle_analytics/store/getters.js @@ -1,6 +1,7 @@ import dateFormat from 'dateformat'; import { dateFormats } from '~/analytics/shared/constants'; import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import { PAGINATION_TYPE } from '../constants'; import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils'; export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => { @@ -21,6 +22,13 @@ export const requestParams = (state) => { return { requestPath: fullPath, valueStreamId, stageId }; }; +export const paginationParams = ({ pagination: { page, sort, direction } }) => ({ + pagination: PAGINATION_TYPE, + sort, + direction, + page, +}); + const filterBarParams = ({ filters }) => { const { authors: { selected: selectedAuthor }, diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js index 0d94aad2ca5..0ad67d4e6bd 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js +++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js @@ -4,6 +4,7 @@ export const SET_LOADING = 'SET_LOADING'; export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM'; export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; export const SET_DATE_RANGE = 'SET_DATE_RANGE'; +export const SET_PAGINATION = 'SET_PAGINATION'; export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS'; export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS'; diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js index 301e7d95f8c..64930a5b51f 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/cycle_analytics/store/mutations.js @@ -1,13 +1,24 @@ +import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants'; import { formatMedianValues } from '../utils'; import * as types from './mutation_types'; export default { - [types.INITIALIZE_VSA](state, { endpoints, features, createdBefore, createdAfter }) { + [types.INITIALIZE_VSA]( + state, + { endpoints, features, createdBefore, createdAfter, pagination = {} }, + ) { state.endpoints = endpoints; state.createdBefore = createdBefore; state.createdAfter = createdAfter; state.features = features; + + Vue.set(state, 'pagination', { + page: pagination.page ?? state.pagination.page, + sort: pagination.sort ?? state.pagination.sort, + direction: pagination.direction ?? state.pagination.direction, + }); }, [types.SET_LOADING](state, loadingState) { state.isLoading = loadingState; @@ -22,6 +33,14 @@ export default { state.createdBefore = createdBefore; state.createdAfter = createdAfter; }, + [types.SET_PAGINATION](state, { page, hasNextPage, sort, direction }) { + Vue.set(state, 'pagination', { + page, + hasNextPage, + sort: sort || PAGINATION_SORT_FIELD_END_EVENT, + direction: direction || PAGINATION_SORT_DIRECTION_DESC, + }); + }, [types.REQUEST_VALUE_STREAMS](state) { state.valueStreams = []; }, diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js index 0882db51218..52bc01cafa4 100644 --- a/app/assets/javascripts/cycle_analytics/store/state.js +++ b/app/assets/javascripts/cycle_analytics/store/state.js @@ -1,3 +1,8 @@ +import { + PAGINATION_SORT_FIELD_END_EVENT, + PAGINATION_SORT_DIRECTION_DESC, +} from '~/cycle_analytics/constants'; + export default () => ({ id: null, features: {}, @@ -20,4 +25,10 @@ export default () => ({ isLoadingStage: false, isEmptyStage: false, permissions: {}, + pagination: { + page: null, + hasNextPage: false, + sort: PAGINATION_SORT_FIELD_END_EVENT, + direction: PAGINATION_SORT_DIRECTION_DESC, + }, }); diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index fa02fdf914a..3c6267bac06 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -1,13 +1,10 @@ import dateFormat from 'dateformat'; -import { unescape } from 'lodash'; import { dateFormats } from '~/analytics/shared/constants'; import { hideFlash } from '~/flash'; -import { sanitize } from '~/lib/dompurify'; -import { roundToNearestHalf } from '~/lib/utils/common_utils'; import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility'; import { parseSeconds } from '~/lib/utils/datetime_utility'; +import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility'; import { slugify } from '~/lib/utils/text_utility'; -import { s__, sprintf } from '../locale'; export const removeFlash = (type = 'alert') => { const flashEl = document.querySelector(`.flash-${type}`); @@ -45,29 +42,6 @@ export const transformStagesForPathNavigation = ({ return formattedStages; }; -export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, weeks, months }) => { - if (months) { - return sprintf(s__('ValueStreamAnalytics|%{value}M'), { - value: roundToNearestHalf(months), - }); - } else if (weeks) { - return sprintf(s__('ValueStreamAnalytics|%{value}w'), { - value: roundToNearestHalf(weeks), - }); - } else if (days) { - return sprintf(s__('ValueStreamAnalytics|%{value}d'), { - value: roundToNearestHalf(days), - }); - } else if (hours) { - return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours }); - } else if (minutes) { - return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes }); - } else if (seconds) { - return unescape(sanitize(s__('ValueStreamAnalytics|<1m'), { ALLOWED_TAGS: [] })); - } - return '-'; -}; - /** * Takes a raw median value in seconds and converts it to a string representation * ie. converts 172800 => 2d (2 days) @@ -76,7 +50,7 @@ export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, we * @returns {String} String representation ie 2w */ export const medianTimeToParsedSeconds = (value) => - timeSummaryForPathNavigation({ + formatTimeAsSummary({ ...parseSeconds(value, { daysPerWeek: 7, hoursPerDay: 24 }), seconds: value, }); diff --git a/app/assets/javascripts/dependency_proxy.js b/app/assets/javascripts/dependency_proxy.js deleted file mode 100644 index ddf5703b28f..00000000000 --- a/app/assets/javascripts/dependency_proxy.js +++ /dev/null @@ -1,5 +0,0 @@ -import setupToggleButtons from '~/toggle_buttons'; - -export default () => { - setupToggleButtons(document.querySelector('.js-dependency-proxy-toggle-area')); -}; diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue index 051ab710e5f..7acb5549273 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue @@ -25,7 +25,7 @@ export default { lazy: true, }, translations: { - cronPlaceholder: __('* * * * *'), + cronPlaceholder: '* * * * *', cronSyntaxInstructions: __( 'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}', ), diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index a42b50edb8a..4ab3f140b61 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -19,9 +19,10 @@ import Vue from 'vue'; import '~/lib/utils/jquery_at_who'; import AjaxCache from '~/lib/utils/ajax_cache'; import syntaxHighlight from '~/syntax_highlight'; +import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue'; +import * as constants from '~/notes/constants'; import Autosave from './autosave'; import loadAwardsHandler from './awards_handler'; -import CommentTypeToggle from './comment_type_toggle'; import createFlash from './flash'; import { defaultAutocompleteConfig } from './gfm_auto_complete'; import GLForm from './gl_form'; @@ -128,7 +129,13 @@ export default class Notes { this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this)); this.$wrapperEl.on('click', '.note-edit-cancel', this.cancelEdit); // Reopen and close actions for Issue/MR combined with note form submit - this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment); + this.$wrapperEl.on( + 'click', + // this oddly written selector needs to match the old style (input with class) as + // well as the new DOM styling from the Vue-based note form + 'input.js-comment-submit-button, .js-comment-submit-button > button:first-child', + this.postComment, + ); this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment); this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons); // resolve a discussion @@ -201,23 +208,39 @@ export default class Notes { } static initCommentTypeToggle(form) { - const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle'); - const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu'); + const el = form.querySelector('.js-comment-type-dropdown'); + const { noteableName } = el.dataset; const noteTypeInput = form.querySelector('#note_type'); - const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button'); - const closeButton = form.querySelector('.js-note-target-close'); - const reopenButton = form.querySelector('.js-note-target-reopen'); - - const commentTypeToggle = new CommentTypeToggle({ - dropdownTrigger, - dropdownList, - noteTypeInput, - submitButton, - closeButton, - reopenButton, - }); + const formHasContent = form.querySelector('.js-note-text').value.trim().length > 0; - commentTypeToggle.initDroplab(); + form.commentTypeComponent = new Vue({ + el, + data() { + return { + noteType: constants.COMMENT, + disabled: !formHasContent, + }; + }, + render(createElement) { + return createElement(CommentTypeDropdown, { + props: { + noteType: this.noteType, + noteableDisplayName: noteableName, + disabled: this.disabled, + }, + on: { + change: (arg) => { + this.noteType = arg; + if (this.noteType === constants.DISCUSSION) { + noteTypeInput.value = constants.DISCUSSION_NOTE; + } else { + noteTypeInput.value = ''; + } + }, + }, + }); + }, + }); } keydownNoteText(e) { @@ -1107,6 +1130,7 @@ export default class Notes { const form = textarea.parents('form'); const reopenbtn = form.find('.js-note-target-reopen'); const closebtn = form.find('.js-note-target-close'); + const commentTypeComponent = form.get(0)?.commentTypeComponent; if (textarea.val().trim().length > 0) { reopentext = reopenbtn.attr('data-alternative-text'); @@ -1123,6 +1147,9 @@ export default class Notes { if (closebtn.is(':not(.btn-comment-and-close)')) { closebtn.addClass('btn-comment-and-close'); } + if (commentTypeComponent) { + commentTypeComponent.disabled = false; + } } else { reopentext = reopenbtn.data('originalText'); closetext = closebtn.data('originalText'); @@ -1138,6 +1165,9 @@ export default class Notes { if (closebtn.is('.btn-comment-and-close')) { closebtn.removeClass('btn-comment-and-close'); } + if (commentTypeComponent) { + commentTypeComponent.disabled = true; + } } } @@ -1308,9 +1338,6 @@ export default class Notes { } cleanForm($form) { - // Remove JS classes that are not needed here - $form.find('.js-comment-type-dropdown').removeClass('btn-group'); - // Remove dropdown $form.find('.dropdown-menu').remove(); @@ -1505,6 +1532,8 @@ export default class Notes { const $submitBtn = $(e.target); $submitBtn.prop('disabled', true); let $form = $submitBtn.parents('form'); + const commentTypeComponent = $form.get(0)?.commentTypeComponent; + if (commentTypeComponent) commentTypeComponent.disabled = true; const $closeBtn = $form.find('.js-note-target-close'); const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion'; @@ -1584,6 +1613,8 @@ export default class Notes { const note = res.data; $submitBtn.prop('disabled', false); + if (commentTypeComponent) commentTypeComponent.disabled = false; + // Submission successful! remove placeholder $notesContainer.find(`#${noteUniqueId}`).remove(); @@ -1662,6 +1693,8 @@ export default class Notes { // Submission failed, remove placeholder note and show Flash error message $notesContainer.find(`#${noteUniqueId}`).remove(); $submitBtn.prop('disabled', false); + if (commentTypeComponent) commentTypeComponent.disabled = false; + const blurEvent = new CustomEvent('blur.imageDiff', { detail: e, }); diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 38ea5406c02..837320b9423 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -273,7 +273,7 @@ export default { this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); }, onDesignDeleteError(e) { - this.onError(designDeletionError({ singular: true }), e); + this.onError(designDeletionError(), e); }, onResolveDiscussionError(e) { this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index e66ae822a34..5092c30aa60 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -255,7 +255,7 @@ export default { if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME }); }, onDesignDeleteError() { - const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 }); + const errorMessage = designDeletionError(this.selectedDesigns.length); createFlash({ message: errorMessage }); }, onDesignDropzoneError() { diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index 33c4fd5a7d9..c8f445bfb88 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -250,7 +250,7 @@ export const hasErrors = ({ errors = [] }) => errors?.length; */ export const updateStoreAfterDesignsDelete = (store, data, query, designs) => { if (hasErrors(data)) { - onError(data, designDeletionError({ singular: designs.length === 1 })); + onError(data, designDeletionError(designs.length)); } else { deleteDesignsFromStore(store, query, designs); addNewVersionToStore(store, query, data.version); diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js index afee7e81791..981b50329b2 100644 --- a/app/assets/javascripts/design_management/utils/error_messages.js +++ b/app/assets/javascripts/design_management/utils/error_messages.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-string-literal-i18n-helpers */ import { __, s__, n__, sprintf } from '~/locale'; export const ADD_DISCUSSION_COMMENT_ERROR = s__( @@ -27,12 +26,6 @@ export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.'); export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.'); -const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.'); - -const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__( - 'The designs you tried uploading did not change.', -)}`; - export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __( 'You can only upload one design when dropping onto an existing design.', ); @@ -53,12 +46,9 @@ export const DELETE_DESIGN_TODO_ERROR = __('Failed to remove a to-do item for th export const TOGGLE_TODO_ERROR = __('Failed to toggle the to-do status for the design.'); -const MAX_SKIPPED_FILES_LISTINGS = 5; +const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped. %{reason}'); -const oneDesignSkippedMessage = (filename) => - `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), { - filename, - })}`; +const MAX_SKIPPED_FILES_LISTINGS = 5; /** * Return warning message indicating that some (but not all) uploaded @@ -66,25 +56,40 @@ const oneDesignSkippedMessage = (filename) => * @param {Array<{ filename }>} skippedFiles */ const someDesignsSkippedMessage = (skippedFiles) => { - const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__( - 'Some of the designs you tried uploading did not change:', - )}`; - - const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), { - moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS, - }); - - return `${designsSkippedMessage} ${skippedFiles + const skippedFilesList = skippedFiles .slice(0, MAX_SKIPPED_FILES_LISTINGS) .map(({ filename }) => filename) - .join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`; + .join(', '); + + const uploadSkippedReason = + skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS + ? sprintf( + s__( + 'DesignManagement|Some of the designs you tried uploading did not change: %{skippedFiles} and %{moreCount} more.', + ), + { + skippedFiles: skippedFilesList, + moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS, + }, + ) + : sprintf( + s__( + 'DesignManagement|Some of the designs you tried uploading did not change: %{skippedFiles}.', + ), + { skippedFiles: skippedFilesList }, + ); + + return sprintf(DESIGN_UPLOAD_SKIPPED_MESSAGE, { + reason: uploadSkippedReason, + }); }; -export const designDeletionError = ({ singular = true } = {}) => { - const design = singular ? __('a design') : __('designs'); - return sprintf(s__('Could not archive %{design}. Please try again.'), { - design, - }); +export const designDeletionError = (designsCount = 1) => { + return n__( + 'Failed to archive a design. Please try again.', + 'Failed to archive designs. Please try again.', + designsCount, + ); }; /** @@ -101,7 +106,18 @@ export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => { if (skippedFiles.length === uploadedDesigns.length) { const { filename } = skippedFiles[0]; - return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length); + const uploadSkippedReason = sprintf( + n__( + 'DesignManagement|%{filename} did not change.', + 'DesignManagement|The designs you tried uploading did not change.', + skippedFiles.length, + ), + { filename }, + ); + + return sprintf(DESIGN_UPLOAD_SKIPPED_MESSAGE, { + reason: uploadSkippedReason, + }); } return someDesignsSkippedMessage(skippedFiles); diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index a2ea42e963c..465f9836140 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -19,6 +19,7 @@ import { updateHistory } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import notesEventHub from '../../notes/event_hub'; import { @@ -79,6 +80,7 @@ export default { MrWidgetHowToMergeModal, GlAlert, }, + mixins: [glFeatureFlagsMixin()], alerts: { ALERT_OVERFLOW_HIDDEN, ALERT_MERGE_CONFLICT, @@ -252,6 +254,10 @@ export default { return this.treeWidth <= TREE_HIDE_STATS_WIDTH; }, isLimitedContainer() { + if (this.glFeatures.mrChangesFluidLayout) { + return false; + } + return !this.renderFileTree && !this.isParallelView && !this.isFluidLayout; }, isFullChangeset() { @@ -386,6 +392,8 @@ export default { diffsApp.instrument(); }, created() { + this.mergeRequestContainers = document.querySelectorAll('.merge-request-container'); + this.adjustView(); this.subscribeToEvents(); @@ -513,6 +521,13 @@ export default { } else { this.removeEventListeners(); } + + if (!this.isFluidLayout && this.glFeatures.mrChangesFluidLayout) { + this.mergeRequestContainers.forEach((el) => { + el.classList.toggle('limit-container-width', !this.shouldShow); + el.classList.toggle('container-limited', !this.shouldShow); + }); + } }, setEventListeners() { Mousetrap.bind(keysFor(MR_PREVIOUS_FILE_IN_DIFF), () => this.jumpToFile(-1)); diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index f098d20afd1..da918947cc5 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -100,6 +100,7 @@ export default { variant="default" icon="file-tree" class="gl-mr-3 js-toggle-tree-list btn-icon" + data-qa-selector="file_tree_button" :title="toggleFileBrowserTitle" :aria-label="toggleFileBrowserTitle" :selected="showTreeList" diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 737c4d8f33c..4e33a02ca0e 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -1,4 +1,9 @@ <script> +/* eslint-disable vue/no-v-html */ +/** +NOTE: This file uses v-html over v-safe-html for performance reasons, see: +https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57842 +* */ import { memoize } from 'lodash'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { @@ -267,7 +272,9 @@ export default { ]" class="diff-td line_content with-coverage left-side" data-testid="left-content" - v-html="$options.lineContent(props.line.left) /* eslint-disable-line vue/no-v-html */" + v-html=" + $options.lineContent(props.line.left) /* v-html for performance, see top of file */ + " ></div> </template> <template @@ -389,7 +396,9 @@ export default { }, ]" class="diff-td line_content with-coverage right-side parallel" - v-html="$options.lineContent(props.line.right) /* eslint-disable-line vue/no-v-html */" + v-html=" + $options.lineContent(props.line.right) /* v-html for performance, see top of file */ + " ></div> </template> <template v-else> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 39ce849fc03..41d885d3dc1 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -62,7 +62,7 @@ export default { </script> <template> - <div class="tree-list-holder d-flex flex-column"> + <div class="tree-list-holder d-flex flex-column" data-qa-selector="file_tree_container"> <div class="gl-mb-3 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> <gl-icon name="search" class="position-absolute tree-list-icon" /> diff --git a/app/assets/javascripts/diffs/utils/workers.js b/app/assets/javascripts/diffs/utils/tree_worker_utils.js index 985e75d1a17..985e75d1a17 100644 --- a/app/assets/javascripts/diffs/utils/workers.js +++ b/app/assets/javascripts/diffs/utils/tree_worker_utils.js diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js index 6d1bc78ba1c..04010a99b52 100644 --- a/app/assets/javascripts/diffs/workers/tree_worker.js +++ b/app/assets/javascripts/diffs/workers/tree_worker.js @@ -1,5 +1,5 @@ import { sortTree } from '~/ide/stores/utils'; -import { generateTreeList } from '../utils/workers'; +import { generateTreeList } from '../utils/tree_worker_utils'; // eslint-disable-next-line no-restricted-globals self.addEventListener('message', (e) => { diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js index 410aaed86a7..7069568275d 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js @@ -1,6 +1,5 @@ -import Api from '~/api'; +import ciSchemaPath from '~/editor/schema/ci.json'; import { registerSchema } from '~/ide/utils'; -import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '../constants'; import { SourceEditorExtension } from './source_editor_extension_base'; export class CiSchemaExtension extends SourceEditorExtension { @@ -16,12 +15,7 @@ export class CiSchemaExtension extends SourceEditorExtension { * @param {String} opts.projectPath * @param {String?} opts.ref - Current ref. Defaults to main */ - registerCiSchema({ projectNamespace, projectPath, ref } = {}) { - const ciSchemaPath = Api.buildUrl(Api.projectFileSchemaPath) - .replace(':namespace_path', projectNamespace) - .replace(':project_path', projectPath) - .replace(':ref', ref) - .replace(':filename', EXTENSION_CI_SCHEMA_FILE_NAME_MATCH); + registerCiSchema() { // In order for workers loaded from `data://` as the // ones loaded by monaco editor, we use absolute URLs // to fetch schema files, hence the `gon.gitlab_url` diff --git a/app/assets/javascripts/editor/schema/NOTICE b/app/assets/javascripts/editor/schema/NOTICE new file mode 100644 index 00000000000..60a7a81f082 --- /dev/null +++ b/app/assets/javascripts/editor/schema/NOTICE @@ -0,0 +1,6 @@ +Copyright (c) 2015-present Mads Kristensen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json new file mode 100644 index 00000000000..0052bc00406 --- /dev/null +++ b/app/assets/javascripts/editor/schema/ci.json @@ -0,0 +1,1444 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://gitlab.com/.gitlab-ci.yml", + "title": "Gitlab CI configuration", + "description": "Gitlab has a built-in solution for doing CI called Gitlab CI. It is configured by supplying a file called `.gitlab-ci.yml`, which will list all the jobs that are going to run for the project. A full list of all options can be found at https://docs.gitlab.com/ee/ci/yaml/. You can read more about Gitlab CI at https://docs.gitlab.com/ee/ci/README.html.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "format": "uri" + }, + "image": { "$ref": "#/definitions/image" }, + "services": { "$ref": "#/definitions/services" }, + "before_script": { "$ref": "#/definitions/before_script" }, + "after_script": { "$ref": "#/definitions/after_script" }, + "variables": { "$ref": "#/definitions/globalVariables" }, + "cache": { "$ref": "#/definitions/cache" }, + "default": { + "type": "object", + "properties": { + "after_script": { "$ref": "#/definitions/after_script" }, + "artifacts": { "$ref": "#/definitions/artifacts" }, + "before_script": { "$ref": "#/definitions/before_script" }, + "cache": { "$ref": "#/definitions/cache" }, + "image": { "$ref": "#/definitions/image" }, + "interruptible": { "$ref": "#/definitions/interruptible" }, + "retry": { "$ref": "#/definitions/retry" }, + "services": { "$ref": "#/definitions/services" }, + "tags": { "$ref": "#/definitions/tags" }, + "timeout": { "$ref": "#/definitions/timeout" } + }, + "additionalProperties": false + }, + "stages": { + "type": "array", + "description": "Groups jobs into stages. All jobs in one stage must complete before next stage is executed. Defaults to ['build', 'test', 'deploy'].", + "default": ["build", "test", "deploy"], + "items": { + "type": "string" + }, + "uniqueItems": true, + "minItems": 1 + }, + "include": { + "description": "Can be `IncludeItem` or `IncludeItem[]`. Each `IncludeItem` will be a string, or an object with properties for the method if including external YAML file. The external content will be fetched, included and evaluated along the `.gitlab-ci.yml`.", + "oneOf": [ + { "$ref": "#/definitions/include_item" }, + { + "type": "array", + "items": { "$ref": "#/definitions/include_item" } + } + ] + }, + "pages": { + "$ref": "#/definitions/job", + "description": "A special job used to upload static sites to Gitlab pages. Requires a `public/` directory with `artifacts.path` pointing to it." + }, + "workflow": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "if": { + "type": "string" + }, + "variables": { "$ref": "#/definitions/variables" }, + "when": { + "type": "string", + "enum": ["always", "never"] + } + }, + "additionalProperties": false + } + } + } + } + }, + "patternProperties": { + "^[.]": { + "description": "Hidden keys.", + "anyOf": [ + { "$ref": "#/definitions/job_template" }, + { "description": "Arbitrary YAML anchor." } + ] + } + }, + "additionalProperties": { + "$ref": "#/definitions/job" + }, + "definitions": { + "artifacts": { + "type": "object", + "description": "Used to specify a list of files and directories that should be attached to the job if it succeeds. Artifacts are sent to Gitlab where they can be downloaded.", + "additionalProperties": false, + "properties": { + "paths": { + "type": "array", + "description": "A list of paths to files/folders that should be included in the artifact.", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "exclude": { + "type": "array", + "description": "A list of paths to files/folders that should be excluded in the artifact.", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "expose_as": { + "type": "string", + "description": "Can be used to expose job artifacts in the merge request UI. GitLab will add a link <expose_as> to the relevant merge request that points to the artifact." + }, + "name": { + "type": "string", + "description": "Name for the archive created on job success. Can use variables in the name, e.g. '$CI_JOB_NAME'" + }, + "untracked": { + "type": "boolean", + "description": "Whether to add all untracked files (along with 'artifacts.paths') to the artifact.", + "default": false + }, + "when": { + "description": "Configure when artifacts are uploaded depended on job status.", + "default": "on_success", + "oneOf": [ + { + "enum": ["on_success"], + "description": "Upload artifacts only when the job succeeds (this is the default)." + }, + { + "enum": ["on_failure"], + "description": "Upload artifacts only when the job fails." + }, + { + "enum": ["always"], + "description": "Upload artifacts regardless of job status." + } + ] + }, + "expire_in": { + "type": "string", + "description": "How long artifacts should be kept. They are saved 30 days by default. Artifacts that have expired are removed periodically via cron job. Supports a wide variety of formats, e.g. '1 week', '3 mins 4 sec', '2 hrs 20 min', '2h20min', '6 mos 1 day', '47 yrs 6 mos and 4d', '3 weeks and 2 days'.", + "default": "30 days" + }, + "reports": { + "type": "object", + "description": "Reports will be uploaded as artifacts, and often displayed in the Gitlab UI, such as in Merge Requests.", + "additionalProperties": false, + "properties": { + "junit": { + "description": "Path for file(s) that should be parsed as JUnit XML result", + "oneOf": [ + { + "type": "string", + "description": "Path to a single XML file" + }, + { + "type": "array", + "description": "A list of paths to XML files that will automatically be concatenated into a single file", + "items": { + "type": "string" + }, + "minItems": 1 + } + ] + }, + "cobertura": { + "description": "Path for file(s) that should be parsed as Cobertura XML coverage report", + "oneOf": [ + { + "type": "string", + "description": "Path to a single XML file" + }, + { + "type": "array", + "description": "A list of paths to XML files that will automatically be merged into one report", + "items": { + "type": "string" + }, + "minItems": 1 + } + ] + }, + "codequality": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with code quality report(s) (such as Code Climate)." + }, + "dotenv": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files containing runtime-created variables for this job." + }, + "lsif": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files containing code intelligence (Language Server Index Format)." + }, + "sast": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with SAST vulnerabilities report(s)." + }, + "dependency_scanning": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with Dependency scanning vulnerabilities report(s)." + }, + "container_scanning": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with Container scanning vulnerabilities report(s)." + }, + "dast": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with DAST vulnerabilities report(s)." + }, + "license_management": { + "$ref": "#/definitions/string_file_list", + "description": "Deprecated in 12.8: Path to file or list of files with license report(s)." + }, + "license_scanning": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with license report(s)." + }, + "performance": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with performance metrics report(s)." + }, + "requirements": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with requirements report(s)." + }, + "secret_detection": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with secret detection report(s)." + }, + "metrics": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with custom metrics report(s)." + }, + "terraform": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with terraform plan(s)." + } + } + } + } + }, + "string_file_list": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "include_item": { + "oneOf": [ + { + "description": "Will infer the method based on the value. E.g. `https://...` strings will be of type `include:remote`, and `/templates/...` will be of type `include:local`.", + "type": "string", + "format": "uri-reference", + "pattern": "^(https?://|/).+\\.ya?ml$" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "local": { + "description": "Relative path from local repository root (`/`) to the `yaml`/`yml` file template. The file must be on the same branch, and does not work across git submodules.", + "type": "string", + "format": "uri-reference", + "pattern": "\\.ya?ml$" + }, + "rules": { "$ref": "#/definitions/rules" } + }, + "required": ["local"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "project": { + "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.", + "type": "string", + "pattern": "\\S/\\S" + }, + "ref": { + "description": "Branch/Tag/Commit-hash for the target project.", + "type": "string" + }, + "file": { + "oneOf": [ + { + "description": "Relative path from project root (`/`) to the `yaml`/`yml` file template.", + "type": "string", + "pattern": "\\.ya?ml$" + }, + { + "description": "List of files by relative path from project root (`/`) to the `yaml`/`yml` file template.", + "type": "array", + "items": { + "type": "string", + "pattern": "\\.ya?ml$" + } + } + ] + } + }, + "required": ["project", "file"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "template": { + "description": "Use a `.gitlab-ci.yml` template as a base, e.g. `Nodejs.gitlab-ci.yml`.", + "type": "string", + "format": "uri-reference", + "pattern": "\\.ya?ml$" + } + }, + "required": ["template"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "remote": { + "description": "URL to a `yaml`/`yml` template file using HTTP/HTTPS.", + "type": "string", + "format": "uri-reference", + "pattern": "^https?://.+\\.ya?ml$" + } + }, + "required": ["remote"] + } + ] + }, + "image": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "Full name of the image that should be used. It should contain the Registry part if needed." + }, + { + "type": "object", + "description": "Specifies the docker image to use for the job or globally for all jobs. Job configuration takes precedence over global setting. Requires a certain kind of Gitlab runner executor.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Full name of the image that should be used. It should contain the Registry part if needed." + }, + "entrypoint": { + "type": "array", + "description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.", + "minItems": 1 + } + }, + "required": ["name"] + } + ], + "description": "Specifies the docker image to use for the job or globally for all jobs. Job configuration takes precedence over global setting. Requires a certain kind of Gitlab runner executor." + }, + "services": { + "type": "array", + "description": "Similar to `image` property, but will link the specified services to the `image` container.", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "Full name of the image that should be used. It should contain the Registry part if needed." + }, + { + "type": "object", + "description": "", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Full name of the image that should be used. It should contain the Registry part if needed.", + "minLength": 1 + }, + "entrypoint": { + "type": "array", + "description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.", + "minItems": 1, + "items": { + "type": "string" + } + }, + "command": { + "type": "array", + "description": "Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to Dockerfile's CMD directive, where each shell token is a separate string in the array.", + "minItems": 1, + "items": { + "type": "string" + } + }, + "alias": { + "type": "string", + "description": "Additional alias that can be used to access the service from the job's container. Read Accessing the services for more information.", + "minLength": 1 + } + }, + "required": ["name"] + } + ] + } + }, + "secrets": { + "type": "object", + "description": "Defines secrets to be injected as environment variables", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "object", + "description": "Environment variable name", + "properties": { + "vault": { + "oneOf": [ + { + "type": "string", + "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)" + }, + { + "type": "object", + "properties": { + "engine": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" } + }, + "required": ["name", "path"] + }, + "path": { "type": "string" }, + "field": { "type": "string" } + }, + "required": ["engine", "path", "field"] + } + ] + } + }, + "required": ["vault"] + } + } + }, + "before_script": { + "type": "array", + "description": "Defines scripts that should run *before* the job. Can be set globally or per job.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "after_script": { + "type": "array", + "description": "Defines scripts that should run *after* the job. Can be set globally or per job.", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "rules": { + "type": "array", + "description": "Rules allows for an array of individual rule objects to be evaluated in order, until one matches and dynamically provides attributes to the job.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "if": { + "type": "string", + "description": "Expression to evaluate whether additional attributes should be provided to the job" + }, + "changes": { + "type": "array", + "description": "Additional attributes will be provided to job if any of the provided paths matches a modified file", + "items": { + "type": "string" + } + }, + "exists": { + "type": "array", + "description": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository", + "items": { + "type": "string" + } + }, + "variables": { "$ref": "#/definitions/variables" }, + "when": { "$ref": "#/definitions/when" }, + "start_in": { "$ref": "#/definitions/start_in" }, + "allow_failure": { "$ref": "#/definitions/allow_failure" } + } + } + }, + "globalVariables": { + "description": "Defines environment variables globally. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off. You can use the value and description keywords to define variables that are prefilled when running a pipeline manually.", + "type": "object", + "additionalProperties": { + "anyOf": [ + {"type": ["string", "integer"]}, + { + "type": "object", + "properties": { + "value": { "type": "string" }, + "description": { + "type": "string", + "description": "Explains what the variable is used for, what the acceptable values are." + } + } + } + ] + } + }, + "variables": { + "type": "object", + "description": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off.", + "additionalProperties": { + "type": ["string", "integer"] + } + }, + "timeout": { + "type": "string", + "description": "Allows you to configure a timeout for a specific job (e.g. `1 minute`, `1h 30m 12s`). Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#timeout", + "minLength": 1 + }, + "start_in": { + "type": "string", + "description": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job.", + "minLength": 1 + }, + "allow_failure": { + "description": "Allow job to fail. A failed job does not cause the pipeline to fail.", + "oneOf": [ + { + "description": "Setting this option to true will allow the job to fail while still letting the pipeline pass.", + "type": "boolean", + "default": false + }, + { + "description": "Exit code that are not considered failure. The job fails for any other exit code.", + "type": "object", + "additionalProperties": false, + "required": ["exit_codes"], + "properties": { + "exit_codes": { + "type": "integer" + } + } + }, + { + "description": "You can list which exit codes are not considered failures. The job fails for any other exit code.", + "type": "object", + "additionalProperties": false, + "required": ["exit_codes"], + "properties": { + "exit_codes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "integer" + } + } + } + } + ] + }, + "when": { + "description": "Describes the conditions for when to run the job. Defaults to 'on_success'.", + "default": "on_success", + "oneOf": [ + { + "enum": ["on_success"], + "description": "Execute job only when all jobs from prior stages succeed." + }, + { + "enum": ["on_failure"], + "description": "Execute job when at least one job from prior stages fails." + }, + { + "enum": ["always"], + "description": "Execute job regardless of the status from prior stages." + }, + { + "enum": ["manual"], + "description": "Execute the job manually from Gitlab UI or API. Read more: https://docs.gitlab.com/ee/ci/yaml/#when-manual" + }, + { + "enum": ["delayed"], + "description": "Execute a job after the time limit in 'start_in' expires. Read more: https://docs.gitlab.com/ee/ci/yaml/#when-delayed" + }, + { + "enum": ["never"], + "description": "Never execute the job." + } + ] + }, + "cache": { + "properties": { + "when": { + "description": "Defines when to save the cache, based on the status of the job.", + "default": "on_success", + "oneOf": [ + { + "enum": ["on_success"], + "description": "Save the cache only when the job succeeds." + }, + { + "enum": ["on_failure"], + "description": "Save the cache only when the job fails. " + }, + { + "enum": ["always"], + "description": "Always save the cache. " + } + ] + } + } + }, + "cache_entry": { + "type": "object", + "description": "Specify files or directories to cache between jobs. Can be set globally or per job.", + "additionalProperties": false, + "properties": { + "paths": { + "type": "array", + "description": "List of files or paths to cache.", + "items": { + "type": "string" + } + }, + "key": { + "oneOf": [ + { + "type": "string", + "description": "Unique cache ID, to allow e.g. specific branch or job cache. Environment variables can be used to set up unique keys (e.g. \"$CI_COMMIT_REF_SLUG\" for per branch cache)." + }, + { + "type": "object", + "description": "When you include cache:key:files, you must also list the project files that will be used to generate the key, up to a maximum of two files. The cache key will be a SHA checksum computed from the most recent commits (up to two, if two files are listed) that changed the given files.", + "properties": { + "files": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 2 + } + } + } + ] + }, + "untracked": { + "type": "boolean", + "description": "Set to `true` to cache untracked files.", + "default": false + }, + "policy": { + "type": "string", + "description": "Determines the strategy for downloading and updating the cache.", + "default": "pull-push", + "oneOf": [ + { + "enum": ["pull"], + "description": "Pull will download cache but skip uploading after job completes." + }, + { + "enum": ["push"], + "description": "Push will skip downloading cache and always recreate cache after job completes." + }, + { + "enum": ["pull-push"], + "description": "Pull-push will both download cache at job start and upload cache on job success." + } + ] + } + } + }, + "filter_refs": { + "type": "array", + "description": "Filter job by different keywords that determine origin or state, or by supplying string/regex to check against branch/tag names.", + "items": { + "anyOf": [ + { + "oneOf": [ + { + "enum": ["branches"], + "description": "When a branch is pushed." + }, + { + "enum": ["tags"], + "description": "When a tag is pushed." + }, + { + "enum": ["api"], + "description": "When a pipeline has been triggered by a second pipelines API (not triggers API)." + }, + { + "enum": ["external"], + "description": "When using CI services other than Gitlab" + }, + { + "enum": ["pipelines"], + "description": "For multi-project triggers, created using the API with 'CI_JOB_TOKEN'." + }, + { + "enum": ["pushes"], + "description": "Pipeline is triggered by a `git push` by the user" + }, + { + "enum": ["schedules"], + "description": "For scheduled pipelines." + }, + { + "enum": ["triggers"], + "description": "For pipelines created using a trigger token." + }, + { + "enum": ["web"], + "description": "For pipelines created using *Run pipeline* button in Gitlab UI (under your project's *Pipelines*)." + } + ] + }, + { + "type": "string", + "description": "String or regular expression to match against tag or branch names." + } + ] + } + }, + "filter": { + "oneOf": [ + { + "$ref": "#/definitions/filter_refs" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "refs": { + "$ref": "#/definitions/filter_refs" + }, + "kubernetes": { + "enum": ["active"], + "description": "Filter job based on if Kubernetes integration is active." + }, + "variables": { + "type": "array", + "description": "Filter job by checking comparing values of environment variables. Read more about variable expressions: https://docs.gitlab.com/ee/ci/variables/README.html#variables-expressions", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "description": "Filter job creation based on files that were modified in a git push.", + "items": { + "type": "string" + } + } + } + } + ] + }, + "retry": { + "description": "Retry a job if it fails. Can be a simple integer or object definition.", + "oneOf": [ + { "$ref": "#/definitions/retry_max" }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "max": { "$ref": "#/definitions/retry_max" }, + "when": { + "description": "Either a single or array of error types to trigger job retry.", + "oneOf": [ + { "$ref": "#/definitions/retry_errors" }, + { + "type": "array", + "items": { + "$ref": "#/definitions/retry_errors" + } + } + ] + } + } + } + ] + }, + "retry_max": { + "type": "integer", + "description": "The number of times the job will be retried if it fails. Defaults to 0 and can max be retried 2 times (3 times total).", + "default": 0, + "minimum": 0, + "maximum": 2 + }, + "retry_errors": { + "oneOf": [ + { + "const": "always", + "description": "Retry on any failure (default)." + }, + { + "const": "unknown_failure", + "description": "Retry when the failure reason is unknown." + }, + { + "const": "script_failure", + "description": "Retry when the script failed." + }, + { + "const": "api_failure", + "description": "Retry on API failure." + }, + { + "const": "stuck_or_timeout_failure", + "description": "Retry when the job got stuck or timed out." + }, + { + "const": "runner_system_failure", + "description": "Retry if there is a runner system failure (for example, job setup failed)." + }, + { + "const": "runner_unsupported", + "description": "Retry if the runner is unsupported." + }, + { + "const": "stale_schedule", + "description": "Retry if a delayed job could not be executed." + }, + { + "const": "job_execution_timeout", + "description": "Retry if the script exceeded the maximum execution time set for the job." + }, + { + "const": "archived_failure", + "description": "Retry if the job is archived and can’t be run." + }, + { + "const": "unmet_prerequisites", + "description": "Retry if the job failed to complete prerequisite tasks." + }, + { + "const": "scheduler_failure", + "description": "Retry if the scheduler failed to assign the job to a runner." + }, + { + "const": "data_integrity_failure", + "description": "Retry if there is a structural integrity problem detected." + } + ] + }, + "interruptible": { + "type": "boolean", + "description": "Interruptible is used to indicate that a job should be canceled if made redundant by a newer pipeline run.", + "default": false + }, + "job": { + "allOf": [ + { "$ref": "#/definitions/job_template" }, + { + "anyOf": [ + { "required": ["script"] }, + { "required": ["extends"] }, + { "required": ["trigger"] } + ] + } + ] + }, + "job_template": { + "type": "object", + "additionalProperties": false, + "properties": { + "image": { "$ref": "#/definitions/image" }, + "services": { "$ref": "#/definitions/services" }, + "before_script": { "$ref": "#/definitions/before_script" }, + "after_script": { "$ref": "#/definitions/after_script" }, + "rules": { "$ref": "#/definitions/rules" }, + "variables": { "$ref": "#/definitions/variables" }, + "cache": { "$ref": "#/definitions/cache" }, + "secrets": { "$ref": "#/definitions/secrets" }, + "script": { + "description": "Shell scripts executed by the Runner. The only required property of jobs. Be careful with special characters (e.g. `:`, `{`, `}`, `&`) and use single or double quotes to avoid issues.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "minItems": 1 + } + ] + }, + "stage": { + "type": "string", + "description": "Define what stage the job will run in.", + "default": "test" + }, + "only": { + "$ref": "#/definitions/filter", + "description": "Job will run *only* when these filtering options match." + }, + "extends": { + "description": "The name of one or more jobs to inherit configuration from.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + ] + }, + "needs": { + "description": "The list of jobs in previous stages whose sole completion is needed to start the current job.", + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "job": { + "type": "string" + }, + "artifacts": { + "type": "boolean" + }, + "optional": { + "type": "boolean" + } + }, + "required": ["job"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "pipeline": { + "type": "string" + }, + "job": { + "type": "string" + }, + "artifacts": { + "type": "boolean" + } + }, + "required": ["job", "pipeline"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "job": { + "type": "string" + }, + "project": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "artifacts": { + "type": "boolean" + } + }, + "required": ["job", "project", "ref"] + } + ] + } + }, + "except": { + "$ref": "#/definitions/filter", + "description": "Job will run *except* for when these filtering options match." + }, + "tags": { + "$ref": "#/definitions/tags" + }, + "allow_failure": { + "$ref": "#/definitions/allow_failure" + }, + "timeout": { + "$ref": "#/definitions/timeout" + }, + "when": { + "$ref": "#/definitions/when" + }, + "start_in": { + "$ref": "#/definitions/start_in" + }, + "dependencies": { + "type": "array", + "description": "Specify a list of job names from earlier stages from which artifacts should be loaded. By default, all previous artifacts are passed. Use an empty array to skip downloading artifacts.", + "items": { + "type": "string" + } + }, + "artifacts": { + "$ref": "#/definitions/artifacts" + }, + "environment": { + "description": "Used to associate environment metadata with a deploy. Environment can have a name and URL attached to it, and will be displayed under /environments under the project.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the environment, e.g. 'qa', 'staging', 'production'.", + "minLength": 1 + }, + "url": { + "type": "string", + "description": "When set, this will expose buttons in various places for the current environment in Gitlab, that will take you to the defined URL.", + "format": "uri", + "pattern": "^(https?://.+|\\$[A-Za-z]+)" + }, + "on_stop": { + "type": "string", + "description": "The name of a job to execute when the environment is about to be stopped." + }, + "action": { + "enum": ["start", "prepare", "stop"], + "description": "Specifies what this job will do. 'start' (default) indicates the job will start the deployment. 'prepare' indicates this will not affect the deployment. 'stop' indicates this will stop the deployment.", + "default": "start" + }, + "auto_stop_in": { + "type": "string", + "description": "The amount of time it should take before Gitlab will automatically stop the environment. Supports a wide variety of formats, e.g. '1 week', '3 mins 4 sec', '2 hrs 20 min', '2h20min', '6 mos 1 day', '47 yrs 6 mos and 4d', '3 weeks and 2 days'." + }, + "kubernetes": { + "type": "object", + "description": "Used to configure the kubernetes deployment for this environment. This is currently not supported for kubernetes clusters that are managed by Gitlab.", + "properties": { + "namespace": { + "type": "string", + "description": "The kubernetes namespace where this environment should be deployed to.", + "minLength": 1 + } + } + }, + "deployment_tier": { + "type": "string", + "description": "Explicitly specifies the tier of the deployment environment if non-standard environment name is used.", + "enum": [ + "production", + "staging", + "testing", + "development", + "other" + ] + } + }, + "required": ["name"] + } + ] + }, + "release": { + "type": "object", + "description": "Indicates that the job creates a Release.", + "additionalProperties": false, + "properties": { + "tag_name": { + "type": "string", + "description": "The tag_name must be specified. It can refer to an existing Git tag or can be specified by the user.", + "minLength": 1 + }, + "description": { + "type": "string", + "description": "Specifies the longer description of the Release.", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "The Release name. If omitted, it is populated with the value of release: tag_name." + }, + "ref": { + "type": "string", + "description": "If the release: tag_name doesn’t exist yet, the release is created from ref. ref can be a commit SHA, another tag name, or a branch name." + }, + "milestones": { + "type": "array", + "description": "The title of each milestone the release is associated with.", + "items": { + "type": "string" + } + }, + "released_at": { + "type": "string", + "description": "The date and time when the release is ready. Defaults to the current date and time if not defined. Should be enclosed in quotes and expressed in ISO 8601 format.", + "format": "date-time", + "pattern": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$" + }, + "assets": { + "type": "object", + "additionalProperties": false, + "properties": { + "links": { + "type": "array", + "description": "Include asset links in the release.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the link.", + "minLength": 1 + }, + "url": { + "type": "string", + "description": "The URL to download a file.", + "minLength": 1 + }, + "filepath": { + "type": "string", + "description": "The redirect link to the url." + }, + "link_type": { + "type": "string", + "description": "The content kind of what users can download via url.", + "enum": [ + "runbook", + "package", + "image", + "other" + ] + } + }, + "required": ["name", "url"] + }, + "minItems": 1 + } + }, + "required": ["links"] + } + }, + "required": ["tag_name", "description"] + }, + "coverage": { + "type": "string", + "description": "Must be a regular expression, optionally but recommended to be quoted, and must be surrounded with '/'. Example: '/Code coverage: \\d+\\.\\d+/'", + "format": "regex", + "pattern": "^/.+/$" + }, + "retry": { + "$ref": "#/definitions/retry" + }, + "parallel": { + "description": "Parallel will split up a single job into several, and provide `CI_NODE_INDEX` and `CI_NODE_TOTAL` environment variables for the running jobs.", + "oneOf": [ + { + "type": "integer", + "description": "Creates N instances of the same job that run in parallel.", + "default": 0, + "minimum": 2, + "maximum": 50 + }, + { + "type": "object", + "properties": { + "matrix": { + "type": "array", + "description": "Defines different variables for jobs that are running in parallel.", + "items": { + "type": "object", + "description": "Defines environment variables for specific job.", + "additionalProperties": { + "type": ["string", "number", "array"] + } + }, + "maxItems": 50 + } + }, + "additionalProperties": false, + "required": ["matrix"] + } + ] + }, + "interruptible": { + "$ref": "#/definitions/interruptible" + }, + "resource_group": { + "type": "string", + "description": "Limit job concurrency. Can be used to ensure that the Runner will not run certain jobs simultaneously." + }, + "trigger": { + "description": "Trigger allows you to define downstream pipeline trigger. When a job created from trigger definition is started by GitLab, a downstream pipeline gets created. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#trigger", + "oneOf": [ + { + "type": "object", + "description": "Trigger a multi-project pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#simple-trigger-syntax-for-multi-project-pipelines", + "additionalProperties": false, + "properties": { + "project": { + "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.", + "type": "string", + "pattern": "\\S/\\S" + }, + "branch": { + "description": "The branch name that a downstream pipeline will use", + "type": "string" + }, + "strategy": { + "description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend", + "type": "string", + "enum": ["depend"] + } + }, + "required": ["project"], + "dependencies": { + "branch": ["project"] + } + }, + { + "type": "object", + "description": "Trigger a child pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#trigger-syntax-for-child-pipeline", + "additionalProperties": false, + "properties": { + "include": { + "oneOf": [ + { + "description": "Relative path from local repository root (`/`) to the local YAML file to define the pipeline configuration.", + "type": "string", + "format": "uri-reference", + "pattern": "\\.ya?ml$" + }, + { + "type": "array", + "description": "References a local file or an artifact from another job to define the pipeline configuration.", + "maxItems": 3, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "local": { + "description": "Relative path from local repository root (`/`) to the local YAML file to define the pipeline configuration.", + "type": "string", + "format": "uri-reference", + "pattern": "\\.ya?ml$" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "template": { + "description": "Name of the template YAML file to use in the pipeline configuration.", + "type": "string", + "format": "uri-reference", + "pattern": "\\.ya?ml$" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "artifact": { + "description": "Relative path to the generated YAML file which is extracted from the artifacts and used as the configuration for triggering the child pipeline.", + "type": "string", + "format": "uri-reference", + "pattern": "\\.ya?ml$" + }, + "job": { + "description": "Job name which generates the artifact", + "type": "string" + } + }, + "required": ["artifact", "job"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "project": { + "description": "Path to another private project under the same GitLab instance, like `group/project` or `group/sub-group/project`.", + "type": "string", + "pattern": "\\S/\\S" + }, + "ref": { + "description": "Branch/Tag/Commit hash for the target project.", + "minLength": 1, + "type": "string" + }, + "file": { + "description": "Relative path from repository root (`/`) to the pipeline configuration YAML file.", + "type": "string", + "format": "uri-reference", + "pattern": "\\.ya?ml$" + } + }, + "required": ["project", "file"] + } + ] + } + } + ] + }, + "strategy": { + "description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend", + "type": "string", + "enum": ["depend"] + } + } + }, + { + "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.", + "type": "string", + "pattern": "\\S/\\S" + } + ] + }, + "inherit": { + "type": "object", + "description": "Controls inheritance of globally-defined defaults and variables. Boolean values control inheritance of all default: or variables: keywords. To inherit only a subset of default: or variables: keywords, specify what you wish to inherit. Anything not listed is not inherited.", + "properties": { + "default": { + "description": "Whether to inherit all globally-defined defaults or not. Or subset of inherited defaults", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "after_script", + "artifacts", + "before_script", + "cache", + "image", + "interruptible", + "retry", + "services", + "tags", + "timeout" + ] + } + } + ] + }, + "variables": { + "description": "Whether to inherit all globally-defined variables or not. Or subset of inherited variables", + "oneOf": [ + { "type": "boolean" }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "additionalProperties": false + } + }, + "oneOf": [ + { + "properties": { + "when": { "enum": ["delayed"] } + }, + "required": ["when", "start_in"] + }, + { + "properties": { + "when": { + "not": { + "enum": ["delayed"] + } + } + } + } + ] + }, + "tags": { + "type": "array", + "description": "Used to select runners from the list of available runners. A runner must have all tags listed here to run the job.", + "items": { + "type": "string" + } + } + } +} diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue index 4b7917b4572..8609503e486 100644 --- a/app/assets/javascripts/environments/components/environment_delete.vue +++ b/app/assets/javascripts/environments/components/environment_delete.vue @@ -4,17 +4,15 @@ * Used in the environments table. */ -import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui'; -import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import { GlDropdownItem, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; export default { components: { - GlButton, + GlDropdownItem, }, directives: { - GlTooltip: GlTooltipDirective, GlModalDirective, }, props: { @@ -28,10 +26,8 @@ export default { isLoading: false, }; }, - computed: { - title() { - return s__('Environments|Delete environment'); - }, + i18n: { + title: s__('Environments|Delete environment'), }, mounted() { eventHub.$on('deleteEnvironment', this.onDeleteEnvironment); @@ -41,7 +37,6 @@ export default { }, methods: { onClick() { - this.$root.$emit(BV_HIDE_TOOLTIP, this.$options.deleteEnvironmentTooltipId); eventHub.$emit('requestDeleteEnvironment', this.environment); }, onDeleteEnvironment(environment) { @@ -50,20 +45,15 @@ export default { } }, }, - deleteEnvironmentTooltipId: 'delete-environment-button-tooltip', }; </script> <template> - <gl-button - v-gl-tooltip="{ id: $options.deleteEnvironmentTooltipId }" - v-gl-modal-directive="'delete-environment-modal'" + <gl-dropdown-item + v-gl-modal-directive.delete-environment-modal :loading="isLoading" - :title="title" - :aria-label="title" - class="gl-display-none gl-md-display-block" variant="danger" - category="primary" - icon="remove" @click="onClick" - /> + > + {{ $options.i18n.title }} + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue index 793f7bf0681..b8def676e7d 100644 --- a/app/assets/javascripts/environments/components/environment_external_url.vue +++ b/app/assets/javascripts/environments/components/environment_external_url.vue @@ -18,22 +18,23 @@ export default { required: true, }, }, - computed: { - title() { - return s__('Environments|Open live environment'); - }, + i18n: { + title: s__('Environments|Open live environment'), + open: s__('Environments|Open'), }, }; </script> <template> <gl-button v-gl-tooltip - :title="title" - :aria-label="title" + :title="$options.i18n.title" + :aria-label="$options.i18n.title" :href="externalUrl" class="external-url" target="_blank" icon="external-link" rel="noopener noreferrer nofollow" - /> + > + {{ $options.i18n.open }} + </gl-button> </template> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index d12863ee742..db01d455b2b 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 { GlTooltipDirective, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlDropdown, GlTooltipDirective, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __, s__, sprintf } from '~/locale'; @@ -29,6 +29,7 @@ export default { ActionsComponent, CommitComponent, ExternalUrlComponent, + GlDropdown, GlIcon, GlLink, GlSprintf, @@ -521,6 +522,10 @@ export default { return this.model.metrics_path || ''; }, + terminalPath() { + return this.model?.terminal_path ?? ''; + }, + autoStopUrl() { return this.model.cancel_auto_stop_path || ''; }, @@ -549,6 +554,15 @@ export default { tableNameSpacingClass() { return this.isFolder ? 'section-100' : this.tableData.name.spacing; }, + hasExtraActions() { + return Boolean( + this.canRetry || + this.canShowAutoStopDate || + this.monitoringUrl || + this.terminalPath || + this.canDeleteEnvironment, + ); + }, }, methods: { @@ -776,13 +790,6 @@ export default { role="gridcell" > <div class="btn-group table-action-buttons" role="group"> - <pin-component - v-if="canShowAutoStopDate" - :auto-stop-url="autoStopUrl" - data-track-action="click_button" - data-track-label="environment_pin" - /> - <external-url-component v-if="externalURL" :external-url="externalURL" @@ -790,13 +797,6 @@ export default { data-track-label="environment_url" /> - <monitoring-button-component - v-if="monitoringUrl" - :monitoring-url="monitoringUrl" - data-track-action="click_button" - data-track-label="environment_monitoring" - /> - <actions-component v-if="actions.length > 0" :actions="actions" @@ -804,35 +804,59 @@ export default { data-track-label="environment_actions" /> - <terminal-button-component - v-if="model && model.terminal_path" - :terminal-path="model.terminal_path" - data-track-action="click_button" - data-track-label="environment_terminal" - /> - - <rollback-component - v-if="canRetry" - :environment="model" - :is-last-deployment="isLastDeployment" - :retry-url="retryUrl" - data-track-action="click_button" - data-track-label="environment_rollback" - /> - <stop-component v-if="canStopEnvironment" :environment="model" + class="gl-z-index-2" data-track-action="click_button" data-track-label="environment_stop" /> - <delete-component - v-if="canDeleteEnvironment" - :environment="model" - data-track-action="click_button" - data-track-label="environment_delete" - /> + <gl-dropdown + v-if="hasExtraActions" + icon="ellipsis_v" + text-sr-only + :text="__('More actions')" + category="secondary" + no-caret + > + <rollback-component + v-if="canRetry" + :environment="model" + :is-last-deployment="isLastDeployment" + :retry-url="retryUrl" + data-track-action="click_button" + data-track-label="environment_rollback" + /> + + <pin-component + v-if="canShowAutoStopDate" + :auto-stop-url="autoStopUrl" + data-track-action="click_button" + data-track-label="environment_pin" + /> + + <monitoring-button-component + v-if="monitoringUrl" + :monitoring-url="monitoringUrl" + data-track-action="click_button" + data-track-label="environment_monitoring" + /> + + <terminal-button-component + v-if="terminalPath" + :terminal-path="terminalPath" + data-track-action="click_button" + data-track-label="environment_terminal" + /> + + <delete-component + v-if="canDeleteEnvironment" + :environment="model" + data-track-action="click_button" + data-track-label="environment_delete" + /> + </gl-dropdown> </div> </div> </div> diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index 7f70433776d..06c7f10223a 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -1,15 +1,12 @@ <script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; /** * Renders the Monitoring (Metrics) link in environments table. */ export default { components: { - GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, + GlDropdownItem, }, props: { monitoringUrl: { @@ -17,22 +14,11 @@ export default { required: true, }, }, - computed: { - title() { - return __('Monitoring'); - }, - }, + title: __('Monitoring'), }; </script> <template> - <gl-button - v-gl-tooltip - :href="monitoringUrl" - :title="title" - :aria-label="title" - class="monitoring-url gl-display-none gl-sm-display-none gl-md-display-block" - icon="chart" - rel="noopener noreferrer nofollow" - variant="default" - /> + <gl-dropdown-item :href="monitoringUrl" rel="noopener noreferrer nofollow" target="_blank"> + {{ $options.title }} + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue index 52ac7725bde..0b753d53ee3 100644 --- a/app/assets/javascripts/environments/components/environment_pin.vue +++ b/app/assets/javascripts/environments/components/environment_pin.vue @@ -3,17 +3,13 @@ * Renders a prevent auto-stop button. * Used in environments table. */ -import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; import eventHub from '../event_hub'; export default { components: { - GlIcon, - GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, + GlDropdownItem, }, props: { autoStopUrl: { @@ -26,11 +22,11 @@ export default { eventHub.$emit('cancelAutoStop', this.autoStopUrl); }, }, - title: __('Prevent environment from auto-stopping'), + title: __('Prevent auto-stopping'), }; </script> <template> - <gl-button v-gl-tooltip :title="$options.title" :aria-label="$options.title" @click="onPinClick"> - <gl-icon name="thumbtack" /> - </gl-button> + <gl-dropdown-item @click="onPinClick"> + {{ $options.title }} + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index c0b4e96cea2..00497b3c683 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -5,16 +5,15 @@ * * Makes a post request when the button is clicked. */ -import { GlTooltipDirective, GlModalDirective, GlButton } from '@gitlab/ui'; +import { GlModalDirective, GlDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; export default { components: { - GlButton, + GlDropdownItem, }, directives: { - GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, props: { @@ -65,14 +64,7 @@ export default { }; </script> <template> - <gl-button - v-gl-tooltip - v-gl-modal.confirm-rollback-modal - class="gl-display-none gl-md-display-block text-secondary" - :loading="isLoading" - :title="title" - :aria-label="title" - :icon="isLastDeployment ? 'repeat' : 'redo'" - @click="onClick" - /> + <gl-dropdown-item v-gl-modal.confirm-rollback-modal @click="onClick"> + {{ title }} + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index dceaf3cacf1..0d4a1e76eb8 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -23,16 +23,15 @@ export default { required: true, }, }, + i18n: { + title: s__('Environments|Stop environment'), + stop: s__('Environments|Stop'), + }, data() { return { isLoading: false, }; }, - computed: { - title() { - return s__('Environments|Stop environment'); - }, - }, mounted() { eventHub.$on('stopEnvironment', this.onStopEnvironment); }, @@ -58,11 +57,13 @@ export default { v-gl-tooltip="{ id: $options.stopEnvironmentTooltipId }" v-gl-modal-directive="'stop-environment-modal'" :loading="isLoading" - :title="title" - :aria-label="title" + :title="$options.i18n.title" + :aria-label="$options.i18n.title" icon="stop" - category="primary" + category="secondary" variant="danger" @click="onClick" - /> + > + {{ $options.i18n.stop }} + </gl-button> </template> diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index 4750b8ef01b..0df07f0457f 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -3,15 +3,12 @@ * Renders a terminal button to open a web terminal. * Used in environments table. */ -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; export default { components: { - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, + GlDropdownItem, }, props: { terminalPath: { @@ -25,22 +22,11 @@ export default { default: false, }, }, - computed: { - title() { - return __('Terminal'); - }, - }, + title: __('Terminal'), }; </script> <template> - <a - v-gl-tooltip - :title="title" - :aria-label="title" - :href="terminalPath" - :class="{ disabled: disabled }" - class="btn terminal-button d-none d-md-block text-secondary" - > - <gl-icon name="terminal" /> - </a> + <gl-dropdown-item :href="terminalPath" :disabled="disabled"> + {{ $options.title }} + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index f1c728b84fd..7b8b756487b 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -67,7 +67,7 @@ export default { spacing: 'section-10', }, autoStop: { - title: s__('Environments|Auto stop in'), + title: s__('Environments|Auto stop'), spacing: 'section-10', }, actions: { diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 206381e0b7e..f248e9ec079 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -8,7 +8,7 @@ Vue.use(Translate); Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), }); export default () => { diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 0a15cb56447..4adbf5362b7 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -128,6 +128,12 @@ export default { lastReleaseLink() { return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`; }, + firstCommitLink() { + return `${this.error.externalBaseUrl}/-/commit/${this.error.firstReleaseVersion}`; + }, + lastCommitLink() { + return `${this.error.externalBaseUrl}/-/commit/${this.error.lastReleaseVersion}`; + }, showStacktrace() { return Boolean(this.stacktrace?.length); }, @@ -394,7 +400,7 @@ export default { <span>{{ error.gitlabIssuePath }}</span> </gl-link> </li> - <li> + <li v-if="!error.integrated"> <strong class="bold">{{ __('Sentry event') }}:</strong> <gl-link v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)" @@ -409,15 +415,21 @@ export default { <li v-if="error.firstReleaseVersion"> <strong class="bold">{{ __('First seen') }}:</strong> <time-ago-tooltip :time="error.firstSeen" /> - <gl-link :href="firstReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ error.firstReleaseVersion }}</span> + <gl-link v-if="error.integrated" :href="firstCommitLink"> + {{ __('GitLab commit') }}: {{ error.firstReleaseVersion }} + </gl-link> + <gl-link v-else :href="firstReleaseLink" target="_blank"> + {{ __('Release') }}: {{ error.firstReleaseVersion }} </gl-link> </li> <li v-if="error.lastReleaseVersion"> <strong class="bold">{{ __('Last seen') }}:</strong> <time-ago-tooltip :time="error.lastSeen" /> - <gl-link :href="lastReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ error.lastReleaseVersion }}</span> + <gl-link v-if="error.integrated" :href="lastCommitLink"> + {{ __('GitLab commit') }}: {{ error.lastReleaseVersion }} + </gl-link> + <gl-link v-else :href="lastReleaseLink" target="_blank"> + {{ __('Release') }}: {{ error.lastReleaseVersion }} </gl-link> </li> <li> diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql index 593cbf2ae52..af386528f00 100644 --- a/app/assets/javascripts/error_tracking/queries/details.query.graphql +++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql @@ -23,6 +23,7 @@ query errorDetails($fullPath: ID!, $errorId: ID!) { gitlabCommit gitlabCommitPath gitlabIssuePath + integrated } } } diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index e12d9cc2b07..4808cd1d1c0 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -1,6 +1,14 @@ <script> -import { GlButton, GlFormGroup, GlFormCheckbox, GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; +import { + GlButton, + GlFormGroup, + GlFormCheckbox, + GlFormRadioGroup, + GlFormRadio, + GlFormInputGroup, +} from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ErrorTrackingForm from './error_tracking_form.vue'; import ProjectDropdown from './project_dropdown.vue'; @@ -12,7 +20,9 @@ export default { GlFormGroup, GlFormRadioGroup, GlFormRadio, + GlFormInputGroup, ProjectDropdown, + ClipboardButton, }, props: { initialApiHost: { @@ -46,6 +56,11 @@ export default { type: String, required: true, }, + gitlabDsn: { + type: String, + required: false, + default: null, + }, }, computed: { ...mapGetters([ @@ -63,6 +78,9 @@ export default { 'settingsLoading', 'token', ]), + showGitlabDsnSetting() { + return this.integrated && this.enabled && this.gitlabDsn; + }, }, created() { this.setInitialState({ @@ -119,6 +137,17 @@ export default { </gl-form-radio> </gl-form-radio-group> </gl-form-group> + <gl-form-group + v-if="showGitlabDsnSetting" + :label="__('Paste this DSN into your Sentry SDK')" + data-testid="gitlab-dsn-setting-form" + > + <gl-form-input-group readonly :value="gitlabDsn"> + <template #append> + <clipboard-button :text="gitlabDsn" :title="__('Copy')" /> + </template> + </gl-form-input-group> + </gl-form-group> <div v-if="!integrated" class="js-sentry-setting-form" data-testid="sentry-setting-form"> <error-tracking-form /> <div class="form-group"> diff --git a/app/assets/javascripts/error_tracking_settings/index.js b/app/assets/javascripts/error_tracking_settings/index.js index 324b3292834..69388329e1c 100644 --- a/app/assets/javascripts/error_tracking_settings/index.js +++ b/app/assets/javascripts/error_tracking_settings/index.js @@ -13,6 +13,7 @@ export default () => { token, listProjectsEndpoint, operationsSettingsEndpoint, + gitlabDsn, }, } = formContainerEl; @@ -29,6 +30,7 @@ export default () => { initialToken: token, listProjectsEndpoint, operationsSettingsEndpoint, + gitlabDsn, }, }); }, diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js index 9079c238169..624a04fd7c2 100644 --- a/app/assets/javascripts/experimentation/utils.js +++ b/app/assets/javascripts/experimentation/utils.js @@ -1,5 +1,5 @@ // This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment -import { get, pick } from 'lodash'; +import { get } from 'lodash'; import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants'; function getExperimentsData() { @@ -14,12 +14,6 @@ export function getExperimentData(experimentName) { return getExperimentsData()[experimentName]; } -export function getExperimentContexts(...experimentNames) { - return Object.values(pick(getExperimentsData(), experimentNames)).map( - convertExperimentDataToExperimentContext, - ); -} - export function getAllExperimentContexts() { return Object.values(getExperimentsData()).map(convertExperimentDataToExperimentContext); } diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue index 05d557db942..2bdc95e798c 100644 --- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue @@ -1,7 +1,7 @@ <script> import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import { sprintf, s__ } from '~/locale'; +import { sprintf, __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import FeatureFlagForm from './form.vue'; @@ -10,6 +10,7 @@ export default { GlAlert, GlLoadingIcon, GlToggle, + FeatureFlagActions: () => import('ee_component/feature_flags/components/actions.vue'), FeatureFlagForm, }, mixins: [glFeatureFlagMixin()], @@ -28,7 +29,7 @@ export default { title() { return this.iid ? `^${this.iid} ${this.name}` - : sprintf(s__('Edit %{name}'), { name: this.name }); + : sprintf(this.$options.i18n.editTitle, { name: this.name }); }, }, created() { @@ -37,6 +38,11 @@ export default { methods: { ...mapActions(['updateFeatureFlag', 'fetchFeatureFlag', 'toggleActive']), }, + i18n: { + editTitle: __('Edit %{name}'), + toggleLabel: __('Feature flag status'), + submit: __('Save changes'), + }, }; </script> <template> @@ -51,11 +57,13 @@ export default { data-track-action="click_button" data-track-label="feature_flag_toggle" class="gl-mr-4" - :label="__('Feature flag status')" + :label="$options.i18n.toggleLabel" label-position="hidden" @change="toggleActive" /> <h3 class="page-title gl-m-0">{{ title }}</h3> + + <feature-flag-actions class="gl-ml-auto" /> </div> <gl-alert v-if="error.length" variant="warning" class="gl-mb-5" :dismissible="false"> @@ -67,7 +75,7 @@ export default { :description="description" :strategies="strategies" :cancel-path="path" - :submit-text="__('Save changes')" + :submit-text="$options.i18n.submit" :active="active" @handleSubmit="(data) => updateFeatureFlag(data)" /> diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue index f7ad2c1f106..29e82289107 100644 --- a/app/assets/javascripts/feature_flags/components/form.vue +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -95,7 +95,7 @@ export default { return this.formStrategies.filter((s) => !s.shouldBeDestroyed); }, showRelatedIssues() { - return this.featureFlagIssuesEndpoint.length > 0; + return Boolean(this.featureFlagIssuesEndpoint); }, }, methods: { diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue index 858c30649bb..1a470d74b59 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue @@ -17,7 +17,7 @@ export default { }, }, i18n: { - percentageDescription: __('Enter an integer number number between 0 and 100'), + percentageDescription: __('Enter an integer number between 0 and 100'), percentageInvalid: __('Percent rollout must be an integer number between 0 and 100'), percentageLabel: __('Percentage'), stickinessDescription: __('Consistency guarantee method'), diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js index 98dee7c7e97..55dad87ea5b 100644 --- a/app/assets/javascripts/feature_flags/edit.js +++ b/app/assets/javascripts/feature_flags/edit.js @@ -15,6 +15,7 @@ export default () => { environmentsEndpoint, projectId, featureFlagIssuesEndpoint, + searchPath, } = el.dataset; return new Vue({ @@ -26,6 +27,7 @@ export default () => { environmentsEndpoint, projectId, featureFlagIssuesEndpoint, + searchPath, }, render(createElement) { return createElement(EditFeatureFlag); diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js index 545719ee681..9726b2164b7 100644 --- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -1,6 +1,6 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; -import AjaxFilter from '../droplab/plugins/ajax_filter'; +import AjaxFilter from './droplab/plugins/ajax_filter'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; import FilteredSearchTokenizer from './filtered_search_tokenizer'; diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index a7648a3c463..5adc074b3ce 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -1,7 +1,7 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; -import Ajax from '../droplab/plugins/ajax'; -import Filter from '../droplab/plugins/filter'; +import Ajax from './droplab/plugins/ajax'; +import Filter from './droplab/plugins/filter'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 47f350dc6a2..9d29782c9a7 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -1,5 +1,5 @@ -import Filter from '~/droplab/plugins/filter'; import { __ } from '~/locale'; +import Filter from './droplab/plugins/filter'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index f78644a3893..ddc3c06a9d1 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -1,7 +1,7 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; -import Ajax from '../droplab/plugins/ajax'; -import Filter from '../droplab/plugins/filter'; +import Ajax from './droplab/plugins/ajax'; +import Filter from './droplab/plugins/filter'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js index f933338514a..fb9f25a8c45 100644 --- a/app/assets/javascripts/filtered_search/dropdown_operator.js +++ b/app/assets/javascripts/filtered_search/dropdown_operator.js @@ -1,5 +1,5 @@ -import Filter from '~/droplab/plugins/filter'; import { __ } from '~/locale'; +import Filter from './droplab/plugins/filter'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/filtered_search/droplab/constants.js index 6451af49d36..6451af49d36 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/filtered_search/droplab/constants.js diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/filtered_search/droplab/drop_down.js index 05b741af191..05b741af191 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/filtered_search/droplab/drop_down.js diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/filtered_search/droplab/drop_lab_deprecated.js index 6f068aaa800..15c4a4b7c6b 100644 --- a/app/assets/javascripts/droplab/drop_lab.js +++ b/app/assets/javascripts/filtered_search/droplab/drop_lab_deprecated.js @@ -1,3 +1,13 @@ +/** + * This library is deprecated and scheduled to be removed once the + * filtered_search component is replaced with GitLab's new Pajamas + * filter vue component. + * + * The documentation has been removed from the gitlab codebase but + * can still be found in the commit history here: + * https://gitlab.com/gitlab-org/gitlab/-/blob/28f20e28/doc/development/fe_guide/droplab/droplab.md + */ + import { DATA_TRIGGER } from './constants'; import HookButton from './hook_button'; import HookInput from './hook_input'; diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/filtered_search/droplab/hook.js index 8a8dcde9f88..8a8dcde9f88 100644 --- a/app/assets/javascripts/droplab/hook.js +++ b/app/assets/javascripts/filtered_search/droplab/hook.js diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/filtered_search/droplab/hook_button.js index c51d6167fa3..c51d6167fa3 100644 --- a/app/assets/javascripts/droplab/hook_button.js +++ b/app/assets/javascripts/filtered_search/droplab/hook_button.js diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/filtered_search/droplab/hook_input.js index c523dae347f..c523dae347f 100644 --- a/app/assets/javascripts/droplab/hook_input.js +++ b/app/assets/javascripts/filtered_search/droplab/hook_input.js diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/filtered_search/droplab/keyboard.js index fe1ea2fa6b0..fe1ea2fa6b0 100644 --- a/app/assets/javascripts/droplab/keyboard.js +++ b/app/assets/javascripts/filtered_search/droplab/keyboard.js diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/filtered_search/droplab/plugins/ajax.js index 77d60454d1a..77d60454d1a 100644 --- a/app/assets/javascripts/droplab/plugins/ajax.js +++ b/app/assets/javascripts/filtered_search/droplab/plugins/ajax.js diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js index ac4d44adc17..d0f2d205bb6 100644 --- a/app/assets/javascripts/droplab/plugins/ajax_filter.js +++ b/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js @@ -1,5 +1,6 @@ /* eslint-disable */ -import AjaxCache from '../../lib/utils/ajax_cache'; + +import AjaxCache from '~/lib/utils/ajax_cache'; const AjaxFilter = { init: function (hook) { diff --git a/app/assets/javascripts/droplab/plugins/filter.js b/app/assets/javascripts/filtered_search/droplab/plugins/filter.js index 06391668928..06391668928 100644 --- a/app/assets/javascripts/droplab/plugins/filter.js +++ b/app/assets/javascripts/filtered_search/droplab/plugins/filter.js diff --git a/app/assets/javascripts/droplab/plugins/input_setter.js b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js index 148d9a35b81..148d9a35b81 100644 --- a/app/assets/javascripts/droplab/plugins/input_setter.js +++ b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/filtered_search/droplab/utils.js index d7f49bf19d8..d7f49bf19d8 100644 --- a/app/assets/javascripts/droplab/utils.js +++ b/app/assets/javascripts/filtered_search/droplab/utils.js diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index ebaa3ef98b1..e467e97dda9 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -1,6 +1,6 @@ import { last } from 'lodash'; import AvailableDropdownMappings from 'ee_else_ce/filtered_search/available_dropdown_mappings'; -import DropLab from '~/droplab/drop_lab'; +import DropLab from './droplab/drop_lab_deprecated'; import { DROPDOWN_TYPE } from './constants'; import FilteredSearchContainer from './container'; import DropdownUtils from './dropdown_utils'; diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 2f451e8353b..5dac315d345 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable vue/require-default-prop */ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers'; @@ -14,6 +14,9 @@ export default { GlButton, ProjectAvatar, }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, mixins: [trackingMixin], inject: ['vuexModule'], props: { @@ -73,9 +76,9 @@ export default { <div ref="frequentItemsItemMetadataContainer" class="frequent-items-item-metadata-container"> <div ref="frequentItemsItemTitle" + v-safe-html="highlightedItemName" :title="itemName" class="frequent-items-item-title" - v-html="highlightedItemName /* eslint-disable-line vue/no-v-html */" ></div> <div v-if="namespace" diff --git a/app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql new file mode 100644 index 00000000000..760d78be20d --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql @@ -0,0 +1,6 @@ +fragment MilestoneFragment on Milestone { + expired + id + state + title +} diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index 580c27f6c61..c6590fd8eb3 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -3,6 +3,7 @@ import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue'; import HeaderSearchScopedItems from './header_search_scoped_items.vue'; @@ -16,6 +17,7 @@ export default { GlSearchBoxByType, HeaderSearchDefaultItems, HeaderSearchScopedItems, + HeaderSearchAutocompleteItems, }, data() { return { @@ -41,7 +43,7 @@ export default { }, }, methods: { - ...mapActions(['setSearch']), + ...mapActions(['setSearch', 'fetchAutocompleteOptions']), openDropdown() { this.showDropdown = true; }, @@ -51,6 +53,13 @@ export default { submitSearch() { return visitUrl(this.searchQuery); }, + getAutocompleteOptions(searchTerm) { + if (!searchTerm) { + return; + } + + this.fetchAutocompleteOptions(); + }, }, }; </script> @@ -64,18 +73,20 @@ export default { :placeholder="$options.i18n.searchPlaceholder" @focus="openDropdown" @click="openDropdown" + @input="getAutocompleteOptions" @keydown.enter="submitSearch" @keydown.esc="closeDropdown" /> <div v-if="showSearchDropdown" data-testid="header-search-dropdown-menu" - class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-left-0 gl-z-index-1 gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0" + class="header-search-dropdown-menu gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0" > <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2"> <header-search-default-items v-if="showDefaultItems" /> <template v-else> <header-search-scoped-items /> + <header-search-autocomplete-items /> </template> </div> </div> diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue new file mode 100644 index 00000000000..9bea2b280f7 --- /dev/null +++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue @@ -0,0 +1,74 @@ +<script> +import { + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, + GlAvatar, + GlLoadingIcon, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import { mapState, mapGetters } from 'vuex'; +import highlight from '~/lib/utils/highlight'; +import { GROUPS_CATEGORY, PROJECTS_CATEGORY, LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants'; + +export default { + name: 'HeaderSearchAutocompleteItems', + components: { + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, + GlAvatar, + GlLoadingIcon, + }, + directives: { + SafeHtml, + }, + computed: { + ...mapState(['search', 'loading']), + ...mapGetters(['autocompleteGroupedSearchOptions']), + }, + methods: { + highlightedName(val) { + return highlight(val, this.search); + }, + avatarSize(data) { + if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) { + return LARGE_AVATAR_PX; + } + + return SMALL_AVATAR_PX; + }, + }, +}; +</script> + +<template> + <div> + <template v-if="!loading"> + <div v-for="option in autocompleteGroupedSearchOptions" :key="option.category"> + <gl-dropdown-divider /> + <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="(data, index) in option.data" + :id="`autocomplete-${option.category}-${index}`" + :key="index" + tabindex="-1" + :href="data.url" + > + <div class="gl-display-flex gl-align-items-center"> + <gl-avatar + v-if="data.avatar_url !== undefined" + :src="data.avatar_url" + :entity-id="data.id" + :entity-name="data.label" + :size="avatarSize(data)" + shape="square" + /> + <span v-safe-html="highlightedName(data.label)"></span> + </div> + </gl-dropdown-item> + </div> + </template> + <gl-loading-icon v-else size="lg" class="my-4" /> + </div> +</template> diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index fffed7bcbdb..2fadb1bd1ee 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -15,3 +15,11 @@ export const MSG_IN_ALL_GITLAB = __('in all GitLab'); export const MSG_IN_GROUP = __('in group'); export const MSG_IN_PROJECT = __('in project'); + +export const GROUPS_CATEGORY = 'Groups'; + +export const PROJECTS_CATEGORY = 'Projects'; + +export const LARGE_AVATAR_PX = 32; + +export const SMALL_AVATAR_PX = 16; diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js index 2d37ee137fc..d7e21f55ea5 100644 --- a/app/assets/javascripts/header_search/index.js +++ b/app/assets/javascripts/header_search/index.js @@ -12,13 +12,13 @@ export const initHeaderSearchApp = () => { return false; } - const { searchPath, issuesPath, mrPath } = el.dataset; + const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset; let { searchContext } = el.dataset; searchContext = JSON.parse(searchContext); return new Vue({ el, - store: createStore({ searchPath, issuesPath, mrPath, searchContext }), + store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }), render(createElement) { return createElement(HeaderSearchApp); }, diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js index 841aee04029..2c3b1bd4c0f 100644 --- a/app/assets/javascripts/header_search/store/actions.js +++ b/app/assets/javascripts/header_search/store/actions.js @@ -1,5 +1,19 @@ +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; import * as types from './mutation_types'; +export const fetchAutocompleteOptions = ({ commit, getters }) => { + commit(types.REQUEST_AUTOCOMPLETE); + return axios + .get(getters.autocompleteQuery) + .then(({ data }) => commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data)) + .catch(() => { + commit(types.RECEIVE_AUTOCOMPLETE_ERROR); + createFlash({ message: __('There was an error fetching search autocomplete suggestions') }); + }); +}; + export const setSearch = ({ commit }, value) => { commit(types.SET_SEARCH, value); }; diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js index d1e1fc8ad73..3f4e231ca55 100644 --- a/app/assets/javascripts/header_search/store/getters.js +++ b/app/assets/javascripts/header_search/store/getters.js @@ -23,6 +23,16 @@ export const searchQuery = (state) => { return `${state.searchPath}?${objectToQuery(query)}`; }; +export const autocompleteQuery = (state) => { + const query = { + term: state.search, + project_id: state.searchContext.project?.id, + project_ref: state.searchContext.ref, + }; + + return `${state.autocompletePath}?${objectToQuery(query)}`; +}; + export const scopedIssuesPath = (state) => { return ( state.searchContext.project_metadata?.issues_path || @@ -133,3 +143,25 @@ export const scopedSearchOptions = (state, getters) => { return options; }; + +export const autocompleteGroupedSearchOptions = (state) => { + const groupedOptions = {}; + const results = []; + + state.autocompleteOptions.forEach((option) => { + const category = groupedOptions[option.category]; + + if (category) { + category.data.push(option); + } else { + groupedOptions[option.category] = { + category: option.category, + data: [option], + }; + + results.push(groupedOptions[option.category]); + } + }); + + return results; +}; diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js index 8b74f8662a5..06cca4be8a7 100644 --- a/app/assets/javascripts/header_search/store/index.js +++ b/app/assets/javascripts/header_search/store/index.js @@ -7,11 +7,17 @@ import createState from './state'; Vue.use(Vuex); -export const getStoreConfig = ({ searchPath, issuesPath, mrPath, searchContext }) => ({ +export const getStoreConfig = ({ + searchPath, + issuesPath, + mrPath, + autocompletePath, + searchContext, +}) => ({ actions, getters, mutations, - state: createState({ searchPath, issuesPath, mrPath, searchContext }), + state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }), }); const createStore = (config) => new Vuex.Store(getStoreConfig(config)); diff --git a/app/assets/javascripts/header_search/store/mutation_types.js b/app/assets/javascripts/header_search/store/mutation_types.js index 0bc94ae055f..a2358621ce6 100644 --- a/app/assets/javascripts/header_search/store/mutation_types.js +++ b/app/assets/javascripts/header_search/store/mutation_types.js @@ -1 +1,5 @@ +export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE'; +export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS'; +export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR'; + export const SET_SEARCH = 'SET_SEARCH'; diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js index 5b1438929d4..175b5406540 100644 --- a/app/assets/javascripts/header_search/store/mutations.js +++ b/app/assets/javascripts/header_search/store/mutations.js @@ -1,6 +1,18 @@ import * as types from './mutation_types'; export default { + [types.REQUEST_AUTOCOMPLETE](state) { + state.loading = true; + state.autocompleteOptions = []; + }, + [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { + state.loading = false; + state.autocompleteOptions = data; + }, + [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { + state.loading = false; + state.autocompleteOptions = []; + }, [types.SET_SEARCH](state, value) { state.search = value; }, diff --git a/app/assets/javascripts/header_search/store/state.js b/app/assets/javascripts/header_search/store/state.js index fb2c83dbbe3..3d4073f0583 100644 --- a/app/assets/javascripts/header_search/store/state.js +++ b/app/assets/javascripts/header_search/store/state.js @@ -1,8 +1,11 @@ -const createState = ({ searchPath, issuesPath, mrPath, searchContext }) => ({ +const createState = ({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }) => ({ searchPath, issuesPath, mrPath, + autocompletePath, searchContext, search: '', + autocompleteOptions: [], + loading: false, }); export default createState; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue index 5a7d7917f8a..5272c4310d8 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue @@ -1,7 +1,11 @@ <script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapState } from 'vuex'; export default { + directives: { + SafeHtml, + }, computed: { ...mapState(['lastCommitMsg', 'committedStateSvgPath']), }, @@ -16,7 +20,7 @@ export default { <div class="gl-mr-3 gl-ml-3"> <div class="text-content text-center"> <h4>{{ __('All changes are committed') }}</h4> - <p v-html="lastCommitMsg /* eslint-disable-line vue/no-v-html */"></p> + <p v-safe-html="lastCommitMsg"></p> </div> </div> </div> diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index c142992a9d1..96cb4f3d495 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -44,18 +44,18 @@ export default { methods: { ...mapActions('pipelines', ['fetchJobLogs', 'setDetailJob']), scrollDown() { - if (this.$refs.buildTrace) { - this.$refs.buildTrace.scrollTo(0, this.$refs.buildTrace.scrollHeight); + if (this.$refs.buildJobLog) { + this.$refs.buildJobLog.scrollTo(0, this.$refs.buildJobLog.scrollHeight); } }, scrollUp() { - if (this.$refs.buildTrace) { - this.$refs.buildTrace.scrollTo(0, 0); + if (this.$refs.buildJobLog) { + this.$refs.buildJobLog.scrollTo(0, 0); } }, scrollBuildLog: throttle(function buildLogScrollDebounce() { - const { scrollTop } = this.$refs.buildTrace; - const { offsetHeight, scrollHeight } = this.$refs.buildTrace; + const { scrollTop } = this.$refs.buildJobLog; + const { offsetHeight, scrollHeight } = this.$refs.buildJobLog; if (scrollTop + offsetHeight === scrollHeight) { this.scrollPos = scrollPositions.bottom; @@ -97,7 +97,7 @@ export default { <scroll-button :disabled="isScrolledToBottom" direction="down" @click="scrollDown" /> </div> </div> - <pre ref="buildTrace" class="build-trace mb-0 h-100 mr-3" @scroll="scrollBuildLog"> + <pre ref="buildJobLog" class="build-log mb-0 h-100 mr-3" @scroll="scrollBuildLog"> <code v-show="!detailJob.isLoading" class="bash" diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue index 838c363a6a3..96f9a85c23f 100644 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -117,7 +117,7 @@ export default { class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" @click="refresh" > - <gl-icon :size="18" name="retry" use-deprecated-sizes class="m-auto" /> + <gl-icon :size="16" name="retry" class="m-auto" /> </button> <div class="position-relative w-100 gl-ml-2"> <input diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index f5e367e16f5..05e3601f381 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -1,14 +1,13 @@ -import { sprintf, n__, __ } from '../../../../locale'; +import { __ } from '../../../../locale'; import { COMMIT_TO_NEW_BRANCH } from './constants'; const BRANCH_SUFFIX_COUNT = 5; const createTranslatedTextForFiles = (files, text) => { if (!files.length) return null; - return sprintf(n__('%{text} %{files}', '%{text} %{files} files', files.length), { - files: files.reduce((acc, val) => acc.concat(val.path), []).join(', '), - text, - }); + const filesPart = files.reduce((acc, val) => acc.concat(val.path), []).join(', '); + + return `${text} ${filesPart}`; }; export const discardDraftButtonDisabled = (state) => diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js index 60561292c9d..9cf8d5a360e 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js @@ -139,6 +139,7 @@ export const receiveJobLogsSuccess = ({ commit }, data) => export const fetchJobLogs = ({ dispatch, state }) => { dispatch('requestJobLogs'); + // update trace endpoint once BE compeletes trace re-naming in #340626 return axios .get(`${state.detailJob.path}/trace`, { params: { format: 'json' } }) .then(({ data }) => dispatch('receiveJobLogsSuccess', data)) diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 0cef3b98e61..ec661fdb0d6 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -117,7 +117,7 @@ export const createCommitPayload = ({ action: commitActionForFile(f), file_path: f.path, previous_path: f.prevPath || undefined, - content: f.prevPath && !f.changed ? null : content || undefined, + content: content || undefined, encoding: isBlob ? 'base64' : 'text', last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha, }; diff --git a/app/assets/javascripts/import_entities/components/pagination_bar.vue b/app/assets/javascripts/import_entities/components/pagination_bar.vue new file mode 100644 index 00000000000..33bd3e08bb1 --- /dev/null +++ b/app/assets/javascripts/import_entities/components/pagination_bar.vue @@ -0,0 +1,90 @@ +<script> +import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; + +const DEFAULT_PAGE_SIZES = [20, 50, 100]; + +export default { + components: { + PaginationLinks, + GlDropdown, + GlDropdownItem, + GlIcon, + GlSprintf, + }, + props: { + pageInfo: { + required: true, + type: Object, + }, + pageSizes: { + required: false, + type: Array, + default: () => DEFAULT_PAGE_SIZES, + }, + itemsCount: { + required: true, + type: Number, + }, + }, + + computed: { + humanizedTotal() { + return this.pageInfo.total >= 1000 ? __('1000+') : this.pageInfo.total; + }, + + paginationInfo() { + const { page, perPage } = this.pageInfo; + const start = (page - 1) * perPage + 1; + const end = start + this.itemsCount - 1; + + return { start, end }; + }, + }, + + methods: { + setPage(page) { + this.$emit('set-page', page); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" /> + <gl-dropdown category="tertiary" class="gl-ml-auto"> + <template #button-content> + <span class="gl-font-weight-bold"> + <gl-sprintf :message="__('%{count} items per page')"> + <template #count> + {{ pageInfo.perPage }} + </template> + </gl-sprintf> + </span> + <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> + </template> + <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="$emit('set-page-size', size)"> + <gl-sprintf :message="__('%{count} items per page')"> + <template #count> + {{ size }} + </template> + </gl-sprintf> + </gl-dropdown-item> + </gl-dropdown> + <div class="gl-ml-2" data-testid="information"> + <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')"> + <template #start> + {{ paginationInfo.start }} + </template> + <template #end> + {{ paginationInfo.end }} + </template> + <template #total> + {{ humanizedTotal }} + </template> + </gl-sprintf> + </div> + </div> +</template> diff --git a/app/assets/javascripts/integrations/edit/constants.js b/app/assets/javascripts/integrations/constants.js index b74ae209eb7..8a8d38b295c 100644 --- a/app/assets/javascripts/integrations/edit/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -1,5 +1,11 @@ import { s__ } from '~/locale'; +export const TEST_INTEGRATION_EVENT = 'testIntegration'; +export const SAVE_INTEGRATION_EVENT = 'saveIntegration'; +export const GET_JIRA_ISSUE_TYPES_EVENT = 'getJiraIssueTypes'; +export const TOGGLE_INTEGRATION_EVENT = 'toggleIntegration'; +export const VALIDATE_INTEGRATION_FORM_EVENT = 'validateIntegrationForm'; + export const integrationLevels = { GROUP: 'group', INSTANCE: 'instance', diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue index f7d7f4aa010..9804a9e15f6 100644 --- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue +++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue @@ -1,6 +1,7 @@ <script> import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import { TOGGLE_INTEGRATION_EVENT } from '~/integrations/constants'; import eventHub from '../event_hub'; export default { @@ -26,7 +27,7 @@ export default { }, methods: { onChange(e) { - eventHub.$emit('toggle', e); + eventHub.$emit(TOGGLE_INTEGRATION_EVENT, e); }, }, }; diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index 1fd4083b920..f30298676df 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -9,6 +9,7 @@ import { } from '@gitlab/ui'; import { capitalize, lowerCase, isEmpty } from 'lodash'; import { mapGetters } from 'vuex'; +import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants'; import eventHub from '../event_hub'; export default { @@ -121,10 +122,10 @@ export default { if (this.isNonEmptyPassword) { this.model = null; } - eventHub.$on('validateForm', this.validateForm); + eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); }, beforeDestroy() { - eventHub.$off('validateForm', this.validateForm); + eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); }, methods: { validateForm() { diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 63f007170d0..ba1aeb28616 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -2,7 +2,11 @@ import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { integrationLevels } from '../constants'; +import { + TEST_INTEGRATION_EVENT, + SAVE_INTEGRATION_EVENT, + integrationLevels, +} from '~/integrations/constants'; import eventHub from '../event_hub'; import ActiveCheckbox from './active_checkbox.vue'; @@ -75,11 +79,11 @@ export default { ]), onSaveClick() { this.setIsSaving(true); - eventHub.$emit('saveIntegration'); + eventHub.$emit(SAVE_INTEGRATION_EVENT); }, onTestClick() { this.setIsTesting(true); - eventHub.$emit('testIntegration'); + eventHub.$emit(TEST_INTEGRATION_EVENT); }, onResetClick() { this.fetchResetIntegration(); diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 1242493fb57..0521e1eeea5 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -1,6 +1,10 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import { + VALIDATE_INTEGRATION_FORM_EVENT, + GET_JIRA_ISSUE_TYPES_EVENT, +} from '~/integrations/constants'; import eventHub from '../event_hub'; import JiraUpgradeCta from './jira_upgrade_cta.vue'; @@ -77,17 +81,17 @@ export default { }, }, created() { - eventHub.$on('validateForm', this.validateForm); + eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); }, beforeDestroy() { - eventHub.$off('validateForm', this.validateForm); + eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); }, methods: { validateForm() { this.validated = true; }, getJiraIssueTypes() { - eventHub.$emit('getJiraIssueTypes'); + eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); }, }, }; diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index 1cc5a185f03..249a3e105b1 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -9,6 +9,7 @@ import { } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; @@ -118,10 +119,10 @@ export default { }, }, created() { - eventHub.$on('validateForm', this.validateForm); + eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); }, beforeDestroy() { - eventHub.$off('validateForm', this.validateForm); + eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm); }, methods: { validateForm() { diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue index 7b3a067b186..63650400bb7 100644 --- a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue +++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue @@ -2,7 +2,7 @@ import { GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; import { mapState } from 'vuex'; import { s__ } from '~/locale'; -import { defaultIntegrationLevel, overrideDropdownDescriptions } from '../constants'; +import { defaultIntegrationLevel, overrideDropdownDescriptions } from '~/integrations/constants'; const dropdownOptions = [ { diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index 801cf3ed27e..f33364d5545 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -1,20 +1,26 @@ -import $ from 'jquery'; import { delay } from 'lodash'; import { __, s__ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; import axios from '../lib/utils/axios_utils'; import initForm from './edit'; import eventHub from './edit/event_hub'; +import { + TEST_INTEGRATION_EVENT, + SAVE_INTEGRATION_EVENT, + GET_JIRA_ISSUE_TYPES_EVENT, + TOGGLE_INTEGRATION_EVENT, + VALIDATE_INTEGRATION_FORM_EVENT, +} from './constants'; export default class IntegrationSettingsForm { constructor(formSelector) { - this.$form = $(formSelector); + this.$form = document.querySelector(formSelector); this.formActive = false; this.vue = null; // Form Metadata - this.testEndPoint = this.$form.data('testUrl'); + this.testEndPoint = this.$form.dataset.testUrl; } init() { @@ -23,22 +29,19 @@ export default class IntegrationSettingsForm { document.querySelector('.js-vue-integration-settings'), document.querySelector('.js-vue-default-integration-settings'), ); - eventHub.$on('toggle', (active) => { + eventHub.$on(TOGGLE_INTEGRATION_EVENT, (active) => { this.formActive = active; this.toggleServiceState(); }); - eventHub.$on('testIntegration', () => { + eventHub.$on(TEST_INTEGRATION_EVENT, () => { this.testIntegration(); }); - eventHub.$on('saveIntegration', () => { + eventHub.$on(SAVE_INTEGRATION_EVENT, () => { this.saveIntegration(); }); - eventHub.$on('getJiraIssueTypes', () => { - // eslint-disable-next-line no-jquery/no-serialize - this.getJiraIssueTypes(this.$form.serialize()); + eventHub.$on(GET_JIRA_ISSUE_TYPES_EVENT, () => { + this.getJiraIssueTypes(new FormData(this.$form)); }); - - eventHub.$emit('formInitialized'); } saveIntegration() { @@ -47,14 +50,14 @@ export default class IntegrationSettingsForm { // 2) If this service can be saved // If both conditions are true, we override form submission // and save the service using provided configuration. - const formValid = this.$form.get(0).checkValidity() || this.formActive === false; + const formValid = this.$form.checkValidity() || this.formActive === false; if (formValid) { delay(() => { - this.$form.trigger('submit'); + this.$form.submit(); }, 100); } else { - eventHub.$emit('validateForm'); + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); this.vue.$store.dispatch('setIsSaving', false); } } @@ -65,11 +68,10 @@ export default class IntegrationSettingsForm { // 2) If this service can be tested // If both conditions are true, we override form submission // and test the service using provided configuration. - if (this.$form.get(0).checkValidity()) { - // eslint-disable-next-line no-jquery/no-serialize - this.testSettings(this.$form.serialize()); + if (this.$form.checkValidity()) { + this.testSettings(new FormData(this.$form)); } else { - eventHub.$emit('validateForm'); + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); this.vue.$store.dispatch('setIsTesting', false); } } @@ -79,9 +81,9 @@ export default class IntegrationSettingsForm { */ toggleServiceState() { if (this.formActive) { - this.$form.removeAttr('novalidate'); - } else if (!this.$form.attr('novalidate')) { - this.$form.attr('novalidate', 'novalidate'); + this.$form.removeAttribute('novalidate'); + } else if (!this.$form.getAttribute('novalidate')) { + this.$form.setAttribute('novalidate', 'novalidate'); } } @@ -109,7 +111,7 @@ export default class IntegrationSettingsForm { }, }) => { if (error || !issuetypes?.length) { - eventHub.$emit('validateForm'); + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); throw new Error(message); } diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue index 707ac946b98..85018f133cb 100644 --- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue +++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue @@ -1,8 +1,8 @@ <script> -import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui'; +import { GlLink, GlLoadingIcon, GlPagination, GlTable, GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { DEFAULT_PER_PAGE } from '~/api'; -import createFlash from '~/flash'; import { fetchOverrides } from '~/integrations/overrides/api'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { truncateNamespace } from '~/lib/utils/text_utility'; @@ -16,6 +16,7 @@ export default { GlLoadingIcon, GlPagination, GlTable, + GlAlert, ProjectAvatar, }, props: { @@ -36,6 +37,7 @@ export default { overrides: [], page: 1, totalItems: 0, + errorMessage: null, }; }, computed: { @@ -49,6 +51,7 @@ export default { methods: { loadOverrides(page = this.page) { this.isLoading = true; + this.errorMessage = null; fetchOverrides(this.overridesPath, { page, @@ -61,11 +64,9 @@ export default { this.overrides = data; }) .catch((error) => { - createFlash({ - message: this.$options.i18n.defaultErrorMessage, - error, - captureError: true, - }); + this.errorMessage = this.$options.i18n.defaultErrorMessage; + + Sentry.captureException(error); }) .finally(() => { this.isLoading = false; @@ -85,7 +86,11 @@ export default { <template> <div> + <gl-alert v-if="errorMessage" variant="danger" :dismissible="false"> + {{ errorMessage }} + </gl-alert> <gl-table + v-else :items="overrides" :fields="$options.fields" :busy="isLoading" diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index ab42e8cdfeb..cd0b413265b 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -11,9 +11,10 @@ import { GlFormInput, GlFormCheckboxGroup, } from '@gitlab/ui'; -import { partition, isString } from 'lodash'; +import { partition, isString, unescape } from 'lodash'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { sanitize } from '~/lib/dompurify'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, sprintf } from '~/locale'; import { @@ -293,7 +294,7 @@ export default { }; }, conditionallyShowToastSuccess(response) { - const message = responseMessageFromSuccess(response); + const message = this.unescapeMsg(responseMessageFromSuccess(response)); if (message === '') { this.showToastMessageSuccess(); @@ -309,13 +310,17 @@ export default { this.closeModal(); }, showInvalidFeedbackMessage(response) { + const message = this.unescapeMsg(responseMessageFromError(response)); + this.isLoading = false; - this.invalidFeedbackMessage = - responseMessageFromError(response) || this.$options.labels.invalidFeedbackMessageDefault; + this.invalidFeedbackMessage = message || this.$options.labels.invalidFeedbackMessageDefault; }, handleMembersTokenSelectClear() { this.invalidFeedbackMessage = ''; }, + unescapeMsg(message) { + return unescape(sanitize(message, { ALLOWED_TAGS: [] })); + }, }, labels: { members: { diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js index b7bc9ea5652..52ec3be3205 100644 --- a/app/assets/javascripts/invite_members/utils/response_message_parser.js +++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js @@ -18,7 +18,10 @@ function responseMessageStringForMultiple(message) { return message.includes(':'); } function responseMessageStringFirstPart(message) { - return message.split(' and ')[0]; + const firstPart = message.split(':')[1]; + const firstMsg = firstPart.split(/ and [\w-]*$/)[0].trim(); + + return firstMsg; } export function responseMessageFromError(response) { diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue index 1c88f8dfdca..b0af3612e05 100644 --- a/app/assets/javascripts/issuable/components/csv_export_modal.vue +++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue @@ -1,9 +1,14 @@ <script> import { GlButton, GlModal, GlSprintf, GlIcon } from '@gitlab/ui'; +import { __, n__ } from '~/locale'; import { ISSUABLE_TYPE } from '../constants'; export default { - name: 'CsvExportModal', + i18n: { + exportText: __( + 'The CSV export will be created in the background. Once finished, it will be sent to %{email} in an attachment.', + ), + }, components: { GlButton, GlModal, @@ -32,53 +37,39 @@ export default { required: true, }, }, - data() { - return { - // eslint-disable-next-line @gitlab/require-i18n-strings - issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests', - }; + computed: { + isIssue() { + return this.issuableType === ISSUABLE_TYPE.issues; + }, + exportText() { + return this.isIssue ? __('Export issues') : __('Export merge requests'); + }, + issuableCountText() { + return this.isIssue + ? n__('1 issue selected', '%d issues selected', this.issuableCount) + : n__('1 merge request selected', '%d merge requests selected', this.issuableCount); + }, }, - issueableType: ISSUABLE_TYPE, }; </script> <template> - <gl-modal :modal-id="modalId" body-class="gl-p-0!" data-qa-selector="export_issuable_modal"> - <template #modal-title> - <gl-sprintf :message="__('Export %{name}')"> - <template #name>{{ issuableName }}</template> - </gl-sprintf> - </template> + <gl-modal + :modal-id="modalId" + body-class="gl-p-0!" + :title="exportText" + data-qa-selector="export_issuable_modal" + > <div - v-if="issuableCount > -1" class="gl-justify-content-start gl-align-items-center gl-p-4 gl-border-b-solid gl-border-1 gl-border-gray-50" > <gl-icon name="check" class="gl-color-green-400" /> - <strong class="gl-m-3"> - <gl-sprintf - v-if="issuableType === $options.issueableType.issues" - :message="n__('1 issue selected', '%d issues selected', issuableCount)" - > - <template #issuableCount>{{ issuableCount }}</template> - </gl-sprintf> - <gl-sprintf - v-else - :message="n__('1 merge request selected', '%d merge requests selected', issuableCount)" - > - <template #issuableCount>{{ issuableCount }}</template> - </gl-sprintf> - </strong> + <strong class="gl-m-3">{{ issuableCountText }}</strong> </div> <div class="modal-text gl-px-4 gl-py-5"> - <gl-sprintf - :message=" - __( - `The CSV export will be created in the background. Once finished, it will be sent to %{strongStart}${email}%{strongEnd} in an attachment.`, - ) - " - > - <template #strong="{ content }"> - <strong>{{ content }}</strong> + <gl-sprintf :message="$options.i18n.exportText"> + <template #email> + <strong>{{ email }}</strong> </template> </gl-sprintf> </div> @@ -92,9 +83,7 @@ export default { data-track-action="click_button" :data-track-label="`export_${issuableType}_csv`" > - <gl-sprintf :message="__('Export %{name}')"> - <template #name>{{ issuableName }}</template> - </gl-sprintf> + {{ exportText }} </gl-button> </template> </gl-modal> diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue index 4fdd094072c..269f720bac9 100644 --- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue +++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue @@ -15,6 +15,8 @@ import CsvImportModal from './csv_import_modal.vue'; export default { i18n: { exportAsCsvButtonText: __('Export as CSV'), + importCsvText: __('Import CSV'), + importFromJiraText: __('Import from Jira'), importIssuesText: __('Import issues'), }, name: 'CsvImportExportButtons', @@ -101,13 +103,16 @@ export default { :text-sr-only="!showLabel" :icon="importButtonIcon" > - <gl-dropdown-item v-gl-modal="importModalId">{{ __('Import CSV') }}</gl-dropdown-item> + <gl-dropdown-item v-gl-modal="importModalId"> + {{ $options.i18n.importCsvText }} + </gl-dropdown-item> <gl-dropdown-item v-if="canEdit" :href="projectImportJiraPath" data-qa-selector="import_from_jira_link" - >{{ __('Import from Jira') }}</gl-dropdown-item > + {{ $options.i18n.importFromJiraText }} + </gl-dropdown-item> </gl-dropdown> </gl-button-group> <csv-export-modal diff --git a/app/assets/javascripts/issuable/components/csv_import_modal.vue b/app/assets/javascripts/issuable/components/csv_import_modal.vue index c85efd60b8b..b72abe14ee1 100644 --- a/app/assets/javascripts/issuable/components/csv_import_modal.vue +++ b/app/assets/javascripts/issuable/components/csv_import_modal.vue @@ -1,23 +1,28 @@ <script> -import { GlModal, GlSprintf, GlFormGroup, GlButton } from '@gitlab/ui'; +import { GlModal, GlFormGroup } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; -import { ISSUABLE_TYPE } from '../constants'; +import { __, sprintf } from '~/locale'; export default { - name: 'CsvImportModal', + i18n: { + maximumFileSizeText: __('The maximum file size allowed is %{size}.'), + importIssuesText: __('Import issues'), + uploadCsvFileText: __('Upload CSV file'), + mainText: __( + "Your issues will be imported in the background. Once finished, you'll get a confirmation email.", + ), + helpText: __( + 'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.', + ), + }, + actionPrimary: { + text: __('Import issues'), + }, components: { GlModal, - GlSprintf, GlFormGroup, - GlButton, }, inject: { - issuableType: { - default: '', - }, - exportCsvPath: { - default: '', - }, importCsvIssuesPath: { default: '', }, @@ -31,11 +36,10 @@ export default { required: true, }, }, - data() { - return { - // eslint-disable-next-line @gitlab/require-i18n-strings - issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests', - }; + computed: { + maxFileSizeText() { + return sprintf(this.$options.i18n.maximumFileSizeText, { size: this.maxAttachmentSize }); + }, }, methods: { submitForm() { @@ -47,34 +51,22 @@ export default { </script> <template> - <gl-modal :modal-id="modalId" :title="__('Import issues')"> + <gl-modal + :modal-id="modalId" + :title="$options.i18n.importIssuesText" + :action-primary="$options.actionPrimary" + @primary="submitForm" + > <form ref="form" :action="importCsvIssuesPath" enctype="multipart/form-data" method="post"> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - <p> - {{ - __( - "Your issues will be imported in the background. Once finished, you'll get a confirmation email.", - ) - }} - </p> - <gl-form-group :label="__('Upload CSV file')" label-for="file"> + <p>{{ $options.i18n.mainText }}</p> + <gl-form-group :label="$options.i18n.uploadCsvFileText" label-for="file"> <input id="file" type="file" name="file" accept=".csv,text/csv" /> </gl-form-group> <p class="text-secondary"> - {{ - __( - 'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.', - ) - }} - <gl-sprintf :message="__('The maximum file size allowed is %{size}.')" - ><template #size>{{ maxAttachmentSize }}</template></gl-sprintf - > + {{ $options.i18n.helpText }} + {{ maxFileSizeText }} </p> </form> - <template #modal-footer> - <gl-button category="primary" variant="confirm" @click="submitForm">{{ - __('Import issues') - }}</gl-button> - </template> </gl-modal> </template> diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 5dc49d3ae15..bafc26befda 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -50,20 +50,16 @@ export default class IssuableForm { this.renderWipExplanation = this.renderWipExplanation.bind(this); this.resetAutosave = this.resetAutosave.bind(this); this.handleSubmit = this.handleSubmit.bind(this); - /* eslint-disable @gitlab/require-i18n-strings */ // prettier-ignore this.draftRegex = new RegExp( '^\\s*(' + // Line start, then any amount of leading whitespace - 'draft\\s-\\s' + // Draft_-_ where "_" are *exactly* one whitespace - '|\\[draft\\]\\s*' + // [Draft] and any following whitespace + '\\[draft\\]\\s*' + // [Draft] and any following whitespace '|draft:\\s*' + // Draft: and any following whitespace - '|draft\\s+' + // Draft_ where "_" is at least one whitespace '|\\(draft\\)\\s*' + // (Draft) and any following whitespace ')+' + // At least one repeated match of the preceding parenthetical '\\s*', // Any amount of trailing whitespace 'i', // Match any case(s) ); - /* eslint-enable @gitlab/require-i18n-strings */ this.gfmAutoComplete = new GfmAutoComplete( gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources, diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index df9d5c86a4b..ab04c6a38a5 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -1,5 +1,5 @@ <script> -import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui'; +import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isScopedLabel } from '~/lib/utils/common_utils'; @@ -15,6 +15,7 @@ export default { GlIcon, GlLabel, GlFormCheckbox, + GlSprintf, IssuableAssignees, }, directives: { @@ -82,9 +83,7 @@ export default { return this.issuable.assignees?.nodes || this.issuable.assignees || []; }, createdAt() { - return sprintf(__('created %{timeAgo}'), { - timeAgo: getTimeago().format(this.issuable.createdAt), - }); + return getTimeago().format(this.issuable.createdAt); }, updatedAt() { return sprintf(__('updated %{timeAgo}'), { @@ -164,132 +163,132 @@ export default { <template> <li :id="`issuable_${issuableId}`" - class="issue gl-px-5!" + class="issue gl-display-flex! gl-px-5!" :class="{ closed: issuable.closedAt, today: createdInPastDay }" :data-labels="labelIdsString" > - <div class="issuable-info-container"> - <div v-if="showCheckbox" class="issue-check"> - <gl-form-checkbox - class="gl-mr-0" - :checked="checked" - :data-id="issuableId" - @input="$emit('checked-input', $event)" + <gl-form-checkbox + v-if="showCheckbox" + class="issue-check gl-mr-0" + :checked="checked" + :data-id="issuableId" + @input="$emit('checked-input', $event)" + > + <span class="gl-sr-only">{{ issuable.title }}</span> + </gl-form-checkbox> + <div class="issuable-main-info"> + <div data-testid="issuable-title" class="issue-title title"> + <gl-icon + v-if="issuable.confidential" + v-gl-tooltip + name="eye-slash" + :title="__('Confidential')" + :aria-label="__('Confidential')" + /> + <gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps"> + {{ issuable.title }} + <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> + </gl-link> + <span + v-if="taskStatus" + class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3" + data-testid="task-status" > - <span class="gl-sr-only">{{ issuable.title }}</span> - </gl-form-checkbox> + {{ taskStatus }} + </span> </div> - <div class="issuable-main-info"> - <div data-testid="issuable-title" class="issue-title title"> - <span class="issue-title-text" dir="auto"> - <gl-icon - v-if="issuable.confidential" - v-gl-tooltip - name="eye-slash" - :title="__('Confidential')" - :aria-label="__('Confidential')" - /> - <gl-link :href="webUrl" v-bind="issuableTitleProps"> - {{ issuable.title - }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" - /></gl-link> - </span> - <span - v-if="taskStatus" - class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3" - data-testid="task-status" - > - {{ taskStatus }} - </span> - </div> - <div class="issuable-info"> - <slot v-if="hasSlotContents('reference')" name="reference"></slot> - <span v-else data-testid="issuable-reference" class="issuable-reference"> - {{ reference }} - </span> - <span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3"> - <span aria-hidden="true">·</span> - <span - v-gl-tooltip:tooltipcontainer.bottom - data-testid="issuable-created-at" - :title="tooltipTitle(issuable.createdAt)" - >{{ createdAt }}</span - > - {{ __('by') }} - <slot v-if="hasSlotContents('author')" name="author"></slot> - <gl-link - v-else - :data-user-id="authorId" - :data-username="author.username" - :data-name="author.name" - :data-avatar-url="author.avatarUrl" - :href="author.webUrl" - data-testid="issuable-author" - class="author-link js-user-link" - > - <span class="author">{{ author.name }}</span> - </gl-link> + <div class="issuable-info"> + <slot v-if="hasSlotContents('reference')" name="reference"></slot> + <span v-else data-testid="issuable-reference" class="issuable-reference"> + {{ reference }} + </span> + <span class="gl-display-none gl-sm-display-inline-block"> + <span aria-hidden="true">·</span> + <span class="issuable-authored gl-mr-3"> + <gl-sprintf :message="__('created %{timeAgo} by %{author}')"> + <template #timeAgo> + <span + v-gl-tooltip.bottom + :title="tooltipTitle(issuable.createdAt)" + data-testid="issuable-created-at" + > + {{ createdAt }} + </span> + </template> + <template #author> + <slot v-if="hasSlotContents('author')" name="author"></slot> + <gl-link + v-else + :data-user-id="authorId" + :data-username="author.username" + :data-name="author.name" + :data-avatar-url="author.avatarUrl" + :href="author.webUrl" + data-testid="issuable-author" + class="author-link js-user-link" + > + <span class="author">{{ author.name }}</span> + </gl-link> + </template> + </gl-sprintf> </span> <slot name="timeframe"></slot> - - <span v-if="labels.length" role="group" :aria-label="__('Labels')"> - <gl-label - v-for="(label, index) in labels" - :key="index" - :background-color="label.color" - :title="labelTitle(label)" - :description="label.description" - :scoped="scopedLabel(label)" - :target="labelTarget(label)" - :class="{ 'gl-ml-2': index }" - size="sm" - /> - </span> - </div> + </span> + + <span v-if="labels.length" role="group" :aria-label="__('Labels')"> + <gl-label + v-for="(label, index) in labels" + :key="index" + :background-color="label.color" + :title="labelTitle(label)" + :description="label.description" + :scoped="scopedLabel(label)" + :target="labelTarget(label)" + :class="{ 'gl-ml-2': index }" + size="sm" + /> + </span> </div> - <div class="issuable-meta"> - <ul v-if="showIssuableMeta" class="controls"> - <li v-if="hasSlotContents('status')" class="issuable-status"> - <slot name="status"></slot> - </li> - <li v-if="assignees.length" class="gl-display-flex"> - <issuable-assignees - :assignees="assignees" - :icon-size="16" - :max-visible="4" - img-css-classes="gl-mr-2!" - class="gl-align-items-center gl-display-flex gl-ml-3" - /> - </li> - <slot name="statistics"></slot> - <li - v-if="showDiscussions" - data-testid="issuable-discussions" - class="issuable-comments gl-display-none gl-sm-display-block" - > - <gl-link - v-gl-tooltip:tooltipcontainer.top - :title="__('Comments')" - :href="issuableNotesLink" - :class="{ 'no-comments': !notesCount }" - class="gl-reset-color!" - > - <gl-icon name="comments" /> - {{ notesCount }} - </gl-link> - </li> - </ul> - <div - data-testid="issuable-updated-at" - class="float-right issuable-updated-at gl-display-none gl-sm-display-inline-block" + </div> + <div class="issuable-meta"> + <ul v-if="showIssuableMeta" class="controls"> + <li v-if="hasSlotContents('status')" class="issuable-status"> + <slot name="status"></slot> + </li> + <li v-if="assignees.length"> + <issuable-assignees + :assignees="assignees" + :icon-size="16" + :max-visible="4" + img-css-classes="gl-mr-2!" + class="gl-align-items-center gl-display-flex gl-ml-3" + /> + </li> + <slot name="statistics"></slot> + <li + v-if="showDiscussions" + data-testid="issuable-discussions" + class="issuable-comments gl-display-none gl-sm-display-block" > - <span - v-gl-tooltip:tooltipcontainer.bottom - :title="tooltipTitle(issuable.updatedAt)" - class="issuable-updated-at" - >{{ updatedAt }}</span + <gl-link + v-gl-tooltip.top + :title="__('Comments')" + :href="issuableNotesLink" + :class="{ 'no-comments': !notesCount }" + class="gl-reset-color!" > - </div> + <gl-icon name="comments" /> + {{ notesCount }} + </gl-link> + </li> + </ul> + <div + v-gl-tooltip.bottom + class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block" + :title="tooltipTitle(issuable.updatedAt)" + data-testid="issuable-updated-at" + > + {{ updatedAt }} </div> </div> </li> diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue index 87066a0a0b6..c1082987146 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -284,72 +284,70 @@ export default { <slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot> </template> </issuable-bulk-edit-sidebar> - <div class="issuables-holder"> - <ul v-if="issuablesLoading" class="content-list"> - <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!"> - <gl-skeleton-loading /> - </li> - </ul> - <template v-else> - <component - :is="issuablesWrapper" - v-if="issuables.length > 0" - class="content-list issuable-list issues-list" - :class="{ 'manual-ordering': isManualOrdering }" - v-bind="$options.vueDraggableAttributes" - @update="handleVueDraggableUpdate" + <ul v-if="issuablesLoading" class="content-list"> + <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!"> + <gl-skeleton-loading /> + </li> + </ul> + <template v-else> + <component + :is="issuablesWrapper" + v-if="issuables.length > 0" + class="content-list issuable-list issues-list" + :class="{ 'manual-ordering': isManualOrdering }" + v-bind="$options.vueDraggableAttributes" + @update="handleVueDraggableUpdate" + > + <issuable-item + v-for="issuable in issuables" + :key="issuableId(issuable)" + :class="{ 'gl-cursor-grab': isManualOrdering }" + :issuable-symbol="issuableSymbol" + :issuable="issuable" + :enable-label-permalinks="enableLabelPermalinks" + :label-filter-param="labelFilterParam" + :show-checkbox="showBulkEditSidebar" + :checked="issuableChecked(issuable)" + @checked-input="handleIssuableCheckedInput(issuable, $event)" > - <issuable-item - v-for="issuable in issuables" - :key="issuableId(issuable)" - :class="{ 'gl-cursor-grab': isManualOrdering }" - :issuable-symbol="issuableSymbol" - :issuable="issuable" - :enable-label-permalinks="enableLabelPermalinks" - :label-filter-param="labelFilterParam" - :show-checkbox="showBulkEditSidebar" - :checked="issuableChecked(issuable)" - @checked-input="handleIssuableCheckedInput(issuable, $event)" - > - <template #reference> - <slot name="reference" :issuable="issuable"></slot> - </template> - <template #author> - <slot name="author" :author="issuable.author"></slot> - </template> - <template #timeframe> - <slot name="timeframe" :issuable="issuable"></slot> - </template> - <template #status> - <slot name="status" :issuable="issuable"></slot> - </template> - <template #statistics> - <slot name="statistics" :issuable="issuable"></slot> - </template> - </issuable-item> - </component> - <slot v-else name="empty-state"></slot> - </template> + <template #reference> + <slot name="reference" :issuable="issuable"></slot> + </template> + <template #author> + <slot name="author" :author="issuable.author"></slot> + </template> + <template #timeframe> + <slot name="timeframe" :issuable="issuable"></slot> + </template> + <template #status> + <slot name="status" :issuable="issuable"></slot> + </template> + <template #statistics> + <slot name="statistics" :issuable="issuable"></slot> + </template> + </issuable-item> + </component> + <slot v-else name="empty-state"></slot> + </template> - <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3"> - <gl-keyset-pagination - :has-next-page="hasNextPage" - :has-previous-page="hasPreviousPage" - @next="$emit('next-page')" - @prev="$emit('previous-page')" - /> - </div> - <gl-pagination - v-else-if="showPaginationControls" - :per-page="defaultPageSize" - :total-items="totalItems" - :value="currentPage" - :prev-page="previousPage" - :next-page="nextPage" - align="center" - class="gl-pagination gl-mt-3" - @input="$emit('page-change', $event)" + <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3"> + <gl-keyset-pagination + :has-next-page="hasNextPage" + :has-previous-page="hasPreviousPage" + @next="$emit('next-page')" + @prev="$emit('previous-page')" /> </div> + <gl-pagination + v-else-if="showPaginationControls" + :per-page="defaultPageSize" + :total-items="totalItems" + :value="currentPage" + :prev-page="previousPage" + :next-page="nextPage" + align="center" + class="gl-pagination gl-mt-3" + @input="$emit('page-change', $event)" + /> </div> </template> diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issuable_suggestions/components/app.vue index d0642b64e7e..48a5e220abf 100644 --- a/app/assets/javascripts/issuable_suggestions/components/app.vue +++ b/app/assets/javascripts/issuable_suggestions/components/app.vue @@ -74,7 +74,7 @@ export default { :title="$options.helpText" :aria-label="$options.helpText" name="question-o" - class="text-secondary suggestion-help-hover" + class="text-secondary gl-cursor-help" /> </div> <div class="col-sm-10"> diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue index dea7608685a..a01f4f747b9 100644 --- a/app/assets/javascripts/issuable_suggestions/components/item.vue +++ b/app/assets/javascripts/issuable_suggestions/components/item.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ import { GlLink, GlTooltip, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { __ } from '~/locale'; @@ -26,12 +25,6 @@ export default { }, }, computed: { - isOpen() { - return this.suggestion.state === 'opened'; - }, - isClosed() { - return this.suggestion.state === 'closed'; - }, counts() { return [ { @@ -48,7 +41,13 @@ export default { }, ].filter(({ count }) => count); }, - stateIcon() { + isClosed() { + return this.suggestion.state === 'closed'; + }, + stateIconClass() { + return this.isClosed ? 'gl-text-blue-500' : 'gl-text-green-500'; + }, + stateIconName() { return this.isClosed ? 'issue-close' : 'issue-open-m'; }, stateTitle() { @@ -72,7 +71,7 @@ export default { v-gl-tooltip.bottom :title="__('Confidential')" name="eye-slash" - class="suggestion-help-hover mr-1 suggestion-confidential" + class="gl-cursor-help gl-mr-2 gl-text-orange-500" /> <gl-link :href="suggestion.webUrl" @@ -83,15 +82,7 @@ export default { </gl-link> </div> <div class="text-secondary suggestion-footer"> - <gl-icon - ref="state" - :name="stateIcon" - :class="{ - 'suggestion-state-open': isOpen, - 'suggestion-state-closed': isClosed, - }" - class="suggestion-help-hover" - /> + <gl-icon ref="state" :name="stateIconName" :class="stateIconClass" class="gl-cursor-help" /> <gl-tooltip :target="() => $refs.state" placement="bottom"> <span class="d-block"> <span class="bold"> {{ stateTitle }} </span> {{ timeFormatted(closedOrCreatedDate) }} @@ -102,9 +93,9 @@ export default { <timeago-tooltip :time="suggestion.createdAt" tooltip-placement="bottom" - class="suggestion-help-hover" + class="gl-cursor-help" /> - by + {{ __('by') }} <gl-link :href="suggestion.author.webUrl"> <user-avatar-image :img-src="suggestion.author.avatarUrl" @@ -122,7 +113,7 @@ export default { <timeago-tooltip :time="suggestion.updatedAt" tooltip-placement="bottom" - class="suggestion-help-hover" + class="gl-cursor-help" /> </template> <span class="suggestion-counts"> @@ -131,7 +122,7 @@ export default { :key="id" v-gl-tooltip.bottom :title="tooltipTitle" - class="suggestion-help-hover gl-ml-3 text-tertiary" + class="gl-cursor-help gl-ml-3 text-tertiary" > <gl-icon :name="icon" /> {{ count }} </span> diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issuable_suggestions/index.js index 8f7f317d6b4..22a99a17741 100644 --- a/app/assets/javascripts/issuable_suggestions/index.js +++ b/app/assets/javascripts/issuable_suggestions/index.js @@ -10,7 +10,12 @@ export default function initIssuableSuggestions() { const issueTitle = document.getElementById('issue_title'); const { projectPath } = el.dataset; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), }); return new Vue({ diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue index f3c2a31bd5b..4b99888ae73 100644 --- a/app/assets/javascripts/issue_show/components/locked_warning.vue +++ b/app/assets/javascripts/issue_show/components/locked_warning.vue @@ -1,30 +1,33 @@ <script> -import { __, sprintf } from '~/locale'; +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const alertMessage = __( + 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.', +); export default { + alertMessage, + components: { + GlSprintf, + GlLink, + }, computed: { currentPath() { return window.location.pathname; }, - alertMessage() { - return sprintf( - __( - 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.', - ), - { - linkStart: `<a href="${this.currentPath}" target="_blank" rel="nofollow">`, - linkEnd: `</a>`, - }, - false, - ); - }, }, }; </script> <template> - <div - class="alert alert-danger" - v-html="alertMessage /* eslint-disable-line vue/no-v-html */" - ></div> + <div class="alert alert-danger"> + <gl-sprintf :message="$options.alertMessage"> + <template #link="{ content }"> + <gl-link :href="currentPath" target="_blank" rel="nofollow"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> </template> 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 a687a58a6ad..4a2f7861492 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 @@ -85,7 +85,7 @@ export default { <span> <span v-if="issue.milestone" - class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3" + class="issuable-milestone gl-mr-3" data-testid="issuable-milestone" > <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate"> @@ -96,7 +96,7 @@ export default { <span v-if="issue.dueDate" v-gl-tooltip - class="issuable-due-date gl-display-none gl-sm-display-inline-block! gl-mr-3" + class="issuable-due-date gl-mr-3" :class="{ 'gl-text-red-500': showDueDateInRed }" :title="__('Due date')" data-testid="issuable-due-date" @@ -107,21 +107,14 @@ export default { <span v-if="timeEstimate" v-gl-tooltip - class="gl-display-none gl-sm-display-inline-block! gl-mr-3" + class="gl-mr-3" :title="__('Estimate')" data-testid="time-estimate" > <gl-icon name="timer" /> {{ timeEstimate }} </span> - <weight-count - class="issuable-weight gl-display-none gl-sm-display-inline-block gl-mr-3" - :weight="issue.weight" - /> - <issue-health-status - v-if="showHealthStatus" - class="gl-display-none gl-sm-display-inline-block" - :health-status="healthStatus" - /> + <weight-count class="issuable-weight gl-mr-3" :weight="issue.weight" /> + <issue-health-status v-if="showHealthStatus" :health-status="healthStatus" /> </span> </template> 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 8e37339fca6..7b51f6ee46a 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -68,13 +68,6 @@ import { TOKEN_TITLE_TYPE, TOKEN_TITLE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; -import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; -import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'; -import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'; -import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; -import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; -import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; import eventHub from '../eventhub'; import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; import searchIterationsQuery from '../queries/search_iterations.query.graphql'; @@ -82,6 +75,21 @@ import searchLabelsQuery from '../queries/search_labels.query.graphql'; import searchMilestonesQuery from '../queries/search_milestones.query.graphql'; import searchUsersQuery from '../queries/search_users.query.graphql'; import IssueCardTimeInfo from './issue_card_time_info.vue'; +import NewIssueDropdown from './new_issue_dropdown.vue'; + +const AuthorToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'); +const EmojiToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); +const EpicToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'); +const IterationToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'); +const LabelToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); +const MilestoneToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); +const WeightToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'); export default { i18n, @@ -96,6 +104,7 @@ export default { IssuableByEmail, IssuableList, IssueCardTimeInfo, + NewIssueDropdown, BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'), }, directives: { @@ -120,12 +129,15 @@ export default { fullPath: { default: '', }, - groupEpicsPath: { + groupPath: { default: '', }, hasAnyIssues: { default: false, }, + hasAnyProjects: { + default: false, + }, hasBlockedIssuesFeature: { default: false, }, @@ -253,6 +265,9 @@ export default { showCsvButtons() { return this.isProject && this.isSignedIn; }, + showNewIssueDropdown() { + return !this.isProject && this.hasAnyProjects; + }, apiFilterParams() { return convertToApiParams(this.filterTokens); }, @@ -363,16 +378,18 @@ export default { }); } - if (this.groupEpicsPath) { + if (this.groupPath) { tokens.push({ type: TOKEN_TYPE_EPIC, title: TOKEN_TITLE_EPIC, icon: 'epic', token: EpicToken, unique: true, + symbol: '&', idProperty: 'id', useIdValue: true, - fetchEpics: this.fetchEpics, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-epic_id`, + fullPath: this.groupPath, }); } @@ -442,16 +459,6 @@ export default { fetchEmojis(search) { return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search); }, - async fetchEpics({ search }) { - const epics = await this.fetchWithCache(this.groupEpicsPath, 'epics'); - if (!search) { - return epics.slice(0, MAX_LIST_SIZE); - } - const number = Number(search); - return Number.isNaN(number) - ? fuzzaldrinPlus.filter(epics, search, { key: 'title' }) - : epics.filter((epic) => epic.id === number); - }, fetchLabels(search) { return this.$apollo .query({ @@ -662,6 +669,7 @@ export default { <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> {{ $options.i18n.newIssueLabel }} </gl-button> + <new-issue-dropdown v-if="showNewIssueDropdown" /> </template> <template #timeframe="{ issuable = {} }"> @@ -765,6 +773,7 @@ export default { :export-csv-path="exportCsvPathWithQuery" :issuable-count="currentTabCount" /> + <new-issue-dropdown v-if="showNewIssueDropdown" /> </template> </gl-empty-state> <hr /> diff --git a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue new file mode 100644 index 00000000000..037fd9be542 --- /dev/null +++ b/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue @@ -0,0 +1,124 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import createFlash from '~/flash'; +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'; + +export default { + i18n: { + defaultDropdownText: __('Select project to create issue'), + noMatchesFound: __('No matches found'), + toggleButtonLabel: __('Toggle project select'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLoadingIcon, + GlSearchBoxByType, + }, + inject: ['fullPath'], + data() { + return { + projects: [], + search: '', + selectedProject: {}, + shouldSkipQuery: true, + }; + }, + apollo: { + projects: { + query: searchProjectsQuery, + variables() { + return { + fullPath: this.fullPath, + search: this.search, + }; + }, + update: ({ group }) => group.projects.nodes ?? [], + error(error) { + createFlash({ + message: __('An error occurred while loading projects.'), + captureError: true, + error, + }); + }, + skip() { + return this.shouldSkipQuery; + }, + debounce: DEBOUNCE_DELAY, + }, + }, + computed: { + dropdownHref() { + return this.hasSelectedProject + ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, 'issues/new') + : undefined; + }, + dropdownText() { + return this.hasSelectedProject + ? sprintf(__('New issue in %{project}'), { project: this.selectedProject.name }) + : this.$options.i18n.defaultDropdownText; + }, + hasSelectedProject() { + return this.selectedProject.id; + }, + showNoSearchResultsText() { + return !this.projects.length && this.search; + }, + }, + methods: { + handleDropdownClick() { + if (!this.dropdownHref) { + this.$refs.dropdown.show(); + } + }, + handleDropdownShown() { + if (this.shouldSkipQuery) { + this.shouldSkipQuery = false; + } + this.$refs.search.focusInput(); + }, + selectProject(project) { + this.selectedProject = project; + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="dropdown" + right + split + :split-href="dropdownHref" + :text="dropdownText" + :toggle-text="$options.i18n.toggleButtonLabel" + variant="confirm" + @click="handleDropdownClick" + @shown="handleDropdownShown" + > + <gl-search-box-by-type ref="search" v-model.trim="search" /> + <gl-loading-icon v-if="$apollo.queries.projects.loading" /> + <template v-else> + <gl-dropdown-item + v-for="project of projects" + :key="project.id" + @click="selectProject(project)" + > + {{ project.nameWithNamespace }} + </gl-dropdown-item> + <gl-dropdown-text v-if="showNoSearchResultsText"> + {{ $options.i18n.noMatchesFound }} + </gl-dropdown-text> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index e89e3e8e681..47af20f5271 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -119,8 +119,9 @@ export function mountIssuesListApp() { emptyStateSvgPath, exportCsvPath, fullPath, - groupEpicsPath, + groupPath, hasAnyIssues, + hasAnyProjects, hasBlockedIssuesFeature, hasIssuableHealthStatusFeature, hasIssueWeightsFeature, @@ -151,8 +152,9 @@ export function mountIssuesListApp() { canBulkUpdate: parseBoolean(canBulkUpdate), emptyStateSvgPath, fullPath, - groupEpicsPath, + groupPath, hasAnyIssues: parseBoolean(hasAnyIssues), + hasAnyProjects: parseBoolean(hasAnyProjects), hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), diff --git a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql new file mode 100644 index 00000000000..df1f330139a --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql @@ -0,0 +1,12 @@ +query searchProjects($fullPath: ID!, $search: String) { + group(fullPath: $fullPath) { + projects(search: $search, includeSubgroups: true) { + nodes { + id + name + nameWithNamespace + webUrl + } + } + } +} diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 059772e8cb9..fe4158a1bd1 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -80,13 +80,13 @@ export default { 'isLoading', 'job', 'isSidebarOpen', - 'trace', - 'isTraceComplete', - 'traceSize', - 'isTraceSizeVisible', + 'jobLog', + 'isJobLogComplete', + 'jobLogSize', + 'isJobLogSizeVisible', 'isScrollBottomDisabled', 'isScrollTopDisabled', - 'isScrolledToBottomBeforeReceivingTrace', + 'isScrolledToBottomBeforeReceivingJobLog', 'hasError', 'selectedStage', ]), @@ -97,7 +97,7 @@ export default { 'shouldRenderTriggeredLabel', 'hasEnvironment', 'shouldRenderSharedRunnerLimitWarning', - 'hasTrace', + 'hasJobLog', 'emptyStateIllustration', 'isScrollingDown', 'emptyStateAction', @@ -155,7 +155,7 @@ export default { this.updateSidebar(); }, beforeDestroy() { - this.stopPollingTrace(); + this.stopPollingJobLog(); this.stopPolling(); window.removeEventListener('resize', this.onResize); window.removeEventListener('scroll', this.updateScroll); @@ -168,7 +168,7 @@ export default { 'toggleSidebar', 'scrollBottom', 'scrollTop', - 'stopPollingTrace', + 'stopPollingJobLog', 'stopPolling', 'toggleScrollButtons', 'toggleScrollAnimation', @@ -270,7 +270,7 @@ export default { <div v-if="job.archived" class="gl-mt-3 gl-py-2 gl-px-3 gl-align-items-center gl-z-index-1 gl-m-auto archived-job" - :class="{ 'sticky-top gl-border-bottom-0': hasTrace }" + :class="{ 'sticky-top gl-border-bottom-0': hasJobLog }" data-testid="archived-job" > <gl-icon name="lock" class="gl-vertical-align-bottom" /> @@ -278,8 +278,8 @@ export default { </div> <!-- job log --> <div - v-if="hasTrace" - class="build-trace-container gl-relative" + v-if="hasJobLog" + class="build-log-container gl-relative" :class="{ 'gl-mt-3': !job.archived }" > <log-top-bar @@ -289,22 +289,22 @@ export default { 'has-archived-block': job.archived, }" :erase-path="job.erase_path" - :size="traceSize" + :size="jobLogSize" :raw-path="job.raw_path" :is-scroll-bottom-disabled="isScrollBottomDisabled" :is-scroll-top-disabled="isScrollTopDisabled" - :is-trace-size-visible="isTraceSizeVisible" + :is-job-log-size-visible="isJobLogSizeVisible" :is-scrolling-down="isScrollingDown" @scrollJobLogTop="scrollTop" @scrollJobLogBottom="scrollBottom" /> - <log :trace="trace" :is-complete="isTraceComplete" /> + <log :job-log="jobLog" :is-complete="isJobLogComplete" /> </div> <!-- EO job log --> <!-- empty state --> <empty-state - v-if="!hasTrace" + v-if="!hasJobLog" :illustration-path="emptyStateIllustration.image" :illustration-size-class="emptyStateIllustration.size" :title="emptyStateTitle" diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index 957e8243f33..6105299e15c 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -44,7 +44,7 @@ export default { type: Boolean, required: true, }, - isTraceSizeVisible: { + isJobLogSizeVisible: { type: Boolean, required: true, }, @@ -73,7 +73,7 @@ export default { class="truncated-info gl-display-none gl-sm-display-block gl-float-left" data-testid="log-truncated-info" > - <template v-if="isTraceSizeVisible"> + <template v-if="isJobLogSizeVisible"> {{ jobLogSize }} <gl-link v-if="rawPath" diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue index c0d5fac0e8d..757b2e458e9 100644 --- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue +++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue @@ -17,7 +17,7 @@ export default { type: Object, required: true, }, - traceEndpoint: { + jobLogEndpoint: { type: String, required: true, }, @@ -42,7 +42,7 @@ export default { <log-line-header :line="section.line" :duration="badgeDuration" - :path="traceEndpoint" + :path="jobLogEndpoint" :is-closed="section.isClosed" @toggleLine="handleOnClickCollapsibleLine(section)" /> @@ -53,10 +53,10 @@ export default { v-if="line.isHeader" :key="line.line.offset" :section="line" - :trace-endpoint="traceEndpoint" + :job-log-endpoint="jobLogEndpoint" @onClickCollapsibleLine="handleOnClickCollapsibleLine" /> - <log-line v-else :key="line.offset" :line="line" :path="traceEndpoint" /> + <log-line v-else :key="line.offset" :line="line" :path="jobLogEndpoint" /> </template> </template> <template v-else> @@ -64,7 +64,7 @@ export default { v-for="line in section.lines" :key="line.offset" :line="line" - :path="traceEndpoint" + :path="jobLogEndpoint" /> </template> </template> diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index 0134e5dafe8..ef95d79b8ab 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -10,10 +10,10 @@ export default { }, computed: { ...mapState([ - 'traceEndpoint', - 'trace', - 'isTraceComplete', - 'isScrolledToBottomBeforeReceivingTrace', + 'jobLogEndpoint', + 'jobLog', + 'isJobLogComplete', + 'isScrolledToBottomBeforeReceivingJobLog', ]), }, updated() { @@ -39,7 +39,7 @@ export default { * In order to scroll the page down after `v-html` has finished, we need to use setTimeout */ handleScrollDown() { - if (this.isScrolledToBottomBeforeReceivingTrace) { + if (this.isScrolledToBottomBeforeReceivingJobLog) { setTimeout(() => { this.scrollBottom(); }, 0); @@ -50,18 +50,18 @@ export default { </script> <template> <code class="job-log d-block" data-qa-selector="job_log_content"> - <template v-for="(section, index) in trace"> + <template v-for="(section, index) in jobLog"> <collapsible-log-section v-if="section.isHeader" :key="`collapsible-${index}`" :section="section" - :trace-endpoint="traceEndpoint" + :job-log-endpoint="jobLogEndpoint" @onClickCollapsibleLine="handleOnClickCollapsibleLine" /> - <log-line v-else :key="section.offset" :line="section" :path="traceEndpoint" /> + <log-line v-else :key="section.offset" :line="section" :path="jobLogEndpoint" /> </template> - <div v-if="!isTraceComplete" class="js-log-animation loader-animation pt-3 pl-3"> + <div v-if="!isJobLogComplete" class="js-log-animation loader-animation pt-3 pl-3"> <div class="dot"></div> <div class="dot"></div> <div class="dot"></div> diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue index 6b3a4424a5b..51251c0cacc 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -18,6 +18,7 @@ import cancelJobMutation from '../graphql/mutations/job_cancel.mutation.graphql' import playJobMutation from '../graphql/mutations/job_play.mutation.graphql'; import retryJobMutation from '../graphql/mutations/job_retry.mutation.graphql'; import unscheduleJobMutation from '../graphql/mutations/job_unschedule.mutation.graphql'; +import { reportMessageToSentry } from '../../../utils'; export default { ACTIONS_DOWNLOAD_ARTIFACTS, @@ -34,6 +35,7 @@ export default { jobPlay: 'jobPlay', jobUnschedule: 'jobUnschedule', playJobModalId: 'play-job-modal', + name: 'JobActionsCell', components: { GlButton, GlButtonGroup, @@ -99,15 +101,17 @@ export default { variables: { id: this.job.id }, }); if (errors.length > 0) { - this.reportFailure(); + reportMessageToSentry(this.$options.name, errors.join(', '), {}); + this.showToastMessage(); } else { eventHub.$emit('jobActionPerformed'); } - } catch { - this.reportFailure(); + } catch (failure) { + reportMessageToSentry(this.$options.name, failure, {}); + this.showToastMessage(); } }, - reportFailure() { + showToastMessage() { const toastProps = { text: this.$options.GENERIC_ERROR, variant: 'danger', @@ -136,7 +140,13 @@ export default { <template> <gl-button-group> <template v-if="canReadJob"> - <gl-button v-if="isActive" icon="cancel" :title="$options.CANCEL" @click="cancelJob()" /> + <gl-button + v-if="isActive" + data-testid="cancel-button" + icon="cancel" + :title="$options.CANCEL" + @click="cancelJob()" + /> <template v-else-if="isScheduled"> <gl-button icon="planning" disabled data-testid="countdown"> <gl-countdown :end-date-string="scheduledAt" /> diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 53e3dbbad0d..927ba7c7e1e 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -18,16 +18,16 @@ import * as types from './mutation_types'; export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { dispatch('setJobEndpoint', endpoint); - dispatch('setTraceOptions', { + dispatch('setJobLogOptions', { logState, pagePath, }); - return Promise.all([dispatch('fetchJob'), dispatch('fetchTrace')]); + return Promise.all([dispatch('fetchJob'), dispatch('fetchJobLog')]); }; export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint); -export const setTraceOptions = ({ commit }, options) => commit(types.SET_TRACE_OPTIONS, options); +export const setJobLogOptions = ({ commit }, options) => commit(types.SET_JOB_LOG_OPTIONS, options); export const hideSidebar = ({ commit }) => commit(types.HIDE_SIDEBAR); export const showSidebar = ({ commit }) => commit(types.SHOW_SIDEBAR); @@ -107,7 +107,7 @@ export const receiveJobError = ({ commit }) => { }; /** - * Job's Trace + * Job Log */ export const scrollTop = ({ dispatch }) => { scrollUp(); @@ -156,59 +156,62 @@ export const toggleScrollAnimation = ({ commit }, toggle) => * Responsible to handle automatic scroll */ export const toggleScrollisInBottom = ({ commit }, toggle) => { - commit(types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE, toggle); + commit(types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG, toggle); }; -export const requestTrace = ({ commit }) => commit(types.REQUEST_TRACE); +export const requestJobLog = ({ commit }) => commit(types.REQUEST_JOB_LOG); -export const fetchTrace = ({ dispatch, state }) => +export const fetchJobLog = ({ dispatch, state }) => + // update trace endpoint once BE compeletes trace re-naming in #340626 axios - .get(`${state.traceEndpoint}/trace.json`, { - params: { state: state.traceState }, + .get(`${state.jobLogEndpoint}/trace.json`, { + params: { state: state.jobLogState }, }) .then(({ data }) => { dispatch('toggleScrollisInBottom', isScrolledToBottom()); - dispatch('receiveTraceSuccess', data); + dispatch('receiveJobLogSuccess', data); if (data.complete) { - dispatch('stopPollingTrace'); - } else if (!state.traceTimeout) { - dispatch('startPollingTrace'); + dispatch('stopPollingJobLog'); + } else if (!state.jobLogTimeout) { + dispatch('startPollingJobLog'); } }) .catch((e) => { if (e.response.status === httpStatusCodes.FORBIDDEN) { - dispatch('receiveTraceUnauthorizedError'); + dispatch('receiveJobLogUnauthorizedError'); } else { reportToSentry('job_actions', e); - dispatch('receiveTraceError'); + dispatch('receiveJobLogError'); } }); -export const startPollingTrace = ({ dispatch, commit }) => { - const traceTimeout = setTimeout(() => { - commit(types.SET_TRACE_TIMEOUT, 0); - dispatch('fetchTrace'); +export const startPollingJobLog = ({ dispatch, commit }) => { + const jobLogTimeout = setTimeout(() => { + commit(types.SET_JOB_LOG_TIMEOUT, 0); + dispatch('fetchJobLog'); }, 4000); - commit(types.SET_TRACE_TIMEOUT, traceTimeout); + commit(types.SET_JOB_LOG_TIMEOUT, jobLogTimeout); }; -export const stopPollingTrace = ({ state, commit }) => { - clearTimeout(state.traceTimeout); - commit(types.SET_TRACE_TIMEOUT, 0); - commit(types.STOP_POLLING_TRACE); +export const stopPollingJobLog = ({ state, commit }) => { + clearTimeout(state.jobLogTimeout); + commit(types.SET_JOB_LOG_TIMEOUT, 0); + commit(types.STOP_POLLING_JOB_LOG); }; -export const receiveTraceSuccess = ({ commit }, log) => commit(types.RECEIVE_TRACE_SUCCESS, log); -export const receiveTraceError = ({ dispatch }) => { - dispatch('stopPollingTrace'); +export const receiveJobLogSuccess = ({ commit }, log) => commit(types.RECEIVE_JOB_LOG_SUCCESS, log); + +export const receiveJobLogError = ({ dispatch }) => { + dispatch('stopPollingJobLog'); createFlash({ message: __('An error occurred while fetching the job log.'), }); }; -export const receiveTraceUnauthorizedError = ({ dispatch }) => { - dispatch('stopPollingTrace'); + +export const receiveJobLogUnauthorizedError = ({ dispatch }) => { + dispatch('stopPollingJobLog'); createFlash({ message: __('The current user is not authorized to access the job log.'), }); @@ -248,6 +251,7 @@ export const fetchJobsForStage = ({ dispatch }, stage = {}) => { }; export const receiveJobsForStageSuccess = ({ commit }, data) => commit(types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, data); + export const receiveJobsForStageError = ({ commit }) => { commit(types.RECEIVE_JOBS_FOR_STAGE_ERROR); createFlash({ diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 6cb96bee07d..9d255822250 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -21,11 +21,12 @@ export const shouldRenderTriggeredLabel = (state) => isString(state.job.started) export const hasEnvironment = (state) => !isEmpty(state.job.deployment_status); /** - * Checks if it the job has trace. + * Checks if it the job has a log. * Used to check if it should render the job log or the empty state * @returns {Boolean} */ -export const hasTrace = (state) => +export const hasJobLog = (state) => + // update has_trace once BE compeletes trace re-naming in #340626 state.job.has_trace || (!isEmpty(state.job.status) && state.job.status.group === 'running'); export const emptyStateIllustration = (state) => state?.job?.status?.illustration || {}; @@ -43,7 +44,7 @@ export const shouldRenderSharedRunnerLimitWarning = (state) => !isEmpty(state.job.runners.quota) && state.job.runners.quota.used >= state.job.runners.quota.limit; -export const isScrollingDown = (state) => isScrolledToBottom() && !state.isTraceComplete; +export const isScrollingDown = (state) => isScrolledToBottom() && !state.isJobLogComplete; export const hasRunnersForProject = (state) => state?.job?.runners?.available && !state?.job?.runners?.online; diff --git a/app/assets/javascripts/jobs/store/mutation_types.js b/app/assets/javascripts/jobs/store/mutation_types.js index 6c4f1b5a191..4915a826b84 100644 --- a/app/assets/javascripts/jobs/store/mutation_types.js +++ b/app/assets/javascripts/jobs/store/mutation_types.js @@ -1,5 +1,5 @@ export const SET_JOB_ENDPOINT = 'SET_JOB_ENDPOINT'; -export const SET_TRACE_OPTIONS = 'SET_TRACE_OPTIONS'; +export const SET_JOB_LOG_OPTIONS = 'SET_JOB_LOG_OPTIONS'; export const HIDE_SIDEBAR = 'HIDE_SIDEBAR'; export const SHOW_SIDEBAR = 'SHOW_SIDEBAR'; @@ -12,17 +12,17 @@ export const ENABLE_SCROLL_BOTTOM = 'ENABLE_SCROLL_BOTTOM'; export const ENABLE_SCROLL_TOP = 'ENABLE_SCROLL_TOP'; export const TOGGLE_SCROLL_ANIMATION = 'TOGGLE_SCROLL_ANIMATION'; -export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE = 'TOGGLE_IS_SCROLL_IN_BOTTOM'; +export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG = 'TOGGLE_IS_SCROLL_IN_BOTTOM'; export const REQUEST_JOB = 'REQUEST_JOB'; export const RECEIVE_JOB_SUCCESS = 'RECEIVE_JOB_SUCCESS'; export const RECEIVE_JOB_ERROR = 'RECEIVE_JOB_ERROR'; -export const REQUEST_TRACE = 'REQUEST_TRACE'; -export const SET_TRACE_TIMEOUT = 'SET_TRACE_TIMEOUT'; -export const STOP_POLLING_TRACE = 'STOP_POLLING_TRACE'; -export const RECEIVE_TRACE_SUCCESS = 'RECEIVE_TRACE_SUCCESS'; -export const RECEIVE_TRACE_ERROR = 'RECEIVE_TRACE_ERROR'; +export const REQUEST_JOB_LOG = 'REQUEST_JOB_LOG'; +export const SET_JOB_LOG_TIMEOUT = 'SET_JOB_LOG_TIMEOUT'; +export const STOP_POLLING_JOB_LOG = 'STOP_POLLING_JOB_LOG'; +export const RECEIVE_JOB_LOG_SUCCESS = 'RECEIVE_JOB_LOG_SUCCESS'; +export const RECEIVE_JOB_LOG_ERROR = 'RECEIVE_JOB_LOG_ERROR'; export const TOGGLE_COLLAPSIBLE_LINE = 'TOGGLE_COLLAPSIBLE_LINE'; export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js index 4045d8a0c16..eda2ee0349a 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -1,16 +1,16 @@ import Vue from 'vue'; import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../constants'; import * as types from './mutation_types'; -import { logLinesParser, logLinesParserLegacy, updateIncrementalTrace } from './utils'; +import { logLinesParser, logLinesParserLegacy, updateIncrementalJobLog } from './utils'; export default { [types.SET_JOB_ENDPOINT](state, endpoint) { state.jobEndpoint = endpoint; }, - [types.SET_TRACE_OPTIONS](state, options = {}) { - state.traceEndpoint = options.pagePath; - state.traceState = options.logState; + [types.SET_JOB_LOG_OPTIONS](state, options = {}) { + state.jobLogEndpoint = options.pagePath; + state.jobLogState = options.logState; }, [types.HIDE_SIDEBAR](state) { @@ -20,11 +20,11 @@ export default { state.isSidebarOpen = true; }, - [types.RECEIVE_TRACE_SUCCESS](state, log = {}) { + [types.RECEIVE_JOB_LOG_SUCCESS](state, log = {}) { const infinitelyCollapsibleSectionsFlag = gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF]; if (log.state) { - state.traceState = log.state; + state.jobLogState = log.state; } if (log.append) { @@ -32,52 +32,52 @@ export default { if (log.lines) { const parsedResult = logLinesParser( log.lines, - state.auxiliaryPartialTraceHelpers, - state.trace, + state.auxiliaryPartialJobLogHelpers, + state.jobLog, ); - state.trace = parsedResult.parsedLines; - state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers; + state.jobLog = parsedResult.parsedLines; + state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers; } } else { - state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace; + state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog; } - state.traceSize += log.size; + state.jobLogSize += log.size; } else { - // When the job still does not have a trace - // the trace response will not have a defined + // When the job still does not have a log + // the job log response will not have a defined // html or size. We keep the old value otherwise these // will be set to `null` if (infinitelyCollapsibleSectionsFlag) { const parsedResult = logLinesParser(log.lines); - state.trace = parsedResult.parsedLines; - state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers; + state.jobLog = parsedResult.parsedLines; + state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers; } else { - state.trace = log.lines ? logLinesParserLegacy(log.lines) : state.trace; + state.jobLog = log.lines ? logLinesParserLegacy(log.lines) : state.jobLog; } - state.traceSize = log.size || state.traceSize; + state.jobLogSize = log.size || state.jobLogSize; } - if (state.traceSize < log.total) { - state.isTraceSizeVisible = true; + if (state.jobLogSize < log.total) { + state.isJobLogSizeVisible = true; } else { - state.isTraceSizeVisible = false; + state.isJobLogSizeVisible = false; } - state.isTraceComplete = log.complete || state.isTraceComplete; + state.isJobLogComplete = log.complete || state.isJobLogComplete; }, - [types.SET_TRACE_TIMEOUT](state, id) { - state.traceTimeout = id; + [types.SET_JOB_LOG_TIMEOUT](state, id) { + state.jobLogTimeout = id; }, /** * Will remove loading animation */ - [types.STOP_POLLING_TRACE](state) { - state.isTraceComplete = true; + [types.STOP_POLLING_JOB_LOG](state) { + state.isJobLogComplete = true; }, /** @@ -137,8 +137,8 @@ export default { state.isScrollingDown = toggle; }, - [types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE](state, toggle) { - state.isScrolledToBottomBeforeReceivingTrace = toggle; + [types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG](state, toggle) { + state.isScrolledToBottomBeforeReceivingJobLog = toggle; }, [types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) { diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js index 718324c8bad..a1ba64aa71e 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/jobs/store/state.js @@ -1,6 +1,6 @@ export default () => ({ jobEndpoint: null, - traceEndpoint: null, + jobLogEndpoint: null, // sidebar isSidebarOpen: true, @@ -14,16 +14,16 @@ export default () => ({ isScrollTopDisabled: true, // Used to check if we should keep the automatic scroll - isScrolledToBottomBeforeReceivingTrace: true, + isScrolledToBottomBeforeReceivingJobLog: true, - trace: [], - isTraceComplete: false, - traceSize: 0, - isTraceSizeVisible: false, - traceTimeout: 0, + jobLog: [], + isJobLogComplete: false, + jobLogSize: 0, + isJobLogSizeVisible: false, + jobLogTimeout: 0, - // used as a query parameter to fetch the trace - traceState: null, + // used as a query parameter to fetch the job log + jobLogState: null, // sidebar dropdown & list of jobs isLoadingJobs: false, @@ -32,5 +32,5 @@ export default () => ({ jobs: [], // to parse partial logs - auxiliaryPartialTraceHelpers: {}, + auxiliaryPartialJobLogHelpers: {}, }); diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index b64734e29f6..8bca448ee11 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -131,17 +131,17 @@ export const logLinesParserLegacy = (lines = [], accumulator = []) => [...accumulator], ); -export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLines = []) => { - let currentLineCount = previousTraceState?.prevLineCount ?? 0; - let currentHeader = previousTraceState?.currentHeader; - let isPreviousLineHeader = previousTraceState?.isPreviousLineHeader ?? false; +export const logLinesParser = (lines = [], previousJobLogState = {}, prevParsedLines = []) => { + let currentLineCount = previousJobLogState?.prevLineCount ?? 0; + let currentHeader = previousJobLogState?.currentHeader; + let isPreviousLineHeader = previousJobLogState?.isPreviousLineHeader ?? false; const parsedLines = prevParsedLines.length > 0 ? prevParsedLines : []; - const sectionsQueue = previousTraceState?.sectionsQueue ?? []; + const sectionsQueue = previousJobLogState?.sectionsQueue ?? []; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; // First run we can use the current index, later runs we have to retrieve the last number of lines - currentLineCount = previousTraceState?.prevLineCount ? currentLineCount + 1 : i + 1; + currentLineCount = previousJobLogState?.prevLineCount ? currentLineCount + 1 : i + 1; if (line.section_header && !isPreviousLineHeader) { // If there's no previous line header that means we're at the root of the log @@ -198,7 +198,7 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi return { parsedLines, - auxiliaryPartialTraceHelpers: { + auxiliaryPartialJobLogHelpers: { isPreviousLineHeader, currentHeader, sectionsQueue, @@ -241,7 +241,7 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => { }; /** - * When the trace is not complete, backend may send the last received line + * When the job log is not complete, backend may send the last received line * in the new response. * * We need to check if that is the case by looking for the offset property @@ -250,7 +250,7 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => { * @param array oldLog * @param array newLog */ -export const updateIncrementalTrace = (newLog = [], oldParsed = []) => { +export const updateIncrementalJobLog = (newLog = [], oldParsed = []) => { const parsedLog = findOffsetAndRemove(newLog, oldParsed); return logLinesParserLegacy(newLog, parsedLog); diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js index bb27658369f..a4e695518f1 100644 --- a/app/assets/javascripts/jobs/utils.js +++ b/app/assets/javascripts/jobs/utils.js @@ -19,3 +19,12 @@ export const reportToSentry = (component, failureType) => { Sentry.captureException(failureType); }); }; + +export const reportMessageToSentry = (component, message, context) => { + Sentry.withScope((scope) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + scope.setContext('Vue data', context); + scope.setTag('component', component); + Sentry.captureMessage(message); + }); +}; diff --git a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js new file mode 100644 index 00000000000..ad92bd4de42 --- /dev/null +++ b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js @@ -0,0 +1,36 @@ +import { Observable } from 'apollo-link'; +import { onError } from 'apollo-link-error'; +import { isNavigatingAway } from '~/lib/utils/is_navigating_away'; + +/** + * Returns an ApolloLink (or null if not enabled) which supresses network + * errors when the browser is navigating away. + * + * @returns {ApolloLink|null} + */ +export const getSuppressNetworkErrorsDuringNavigationLink = () => { + if (!gon.features?.suppressApolloErrorsDuringNavigation) { + return null; + } + + return onError(({ networkError }) => { + if (networkError && isNavigatingAway()) { + // Return an observable that will never notify any subscribers with any + // values, errors, or completions. This ensures that requests aborted due + // to navigating away do not trigger any failure behaviour. + // + // See '../utils/suppress_ajax_errors_during_navigation.js' for an axios + // interceptor that performs a similar role. + return new Observable(() => {}); + } + + // We aren't suppressing anything here, so simply do nothing. + // The onError helper will forward all values/errors/completions from the + // underlying request observable to the next link if you return a falsey + // value. + // + // Note that this return statement is technically redundant, but is kept + // for explicitness. + return undefined; + }); +}; diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index b96a55fe116..39bf804b54e 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -11,6 +11,7 @@ import csrf from '~/lib/utils/csrf'; import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; import { getInstrumentationLink } from './apollo/instrumentation_link'; +import { getSuppressNetworkErrorsDuringNavigationLink } from './apollo/suppress_network_errors_during_navigation_link'; export const fetchPolicies = { CACHE_FIRST: 'cache-first', @@ -143,6 +144,7 @@ export default (resolvers = {}, config = {}) => { new ActionCableLink(), ApolloLink.from( [ + getSuppressNetworkErrorsDuringNavigationLink(), getInstrumentationLink(), requestCounterLink, performanceBarLink, diff --git a/app/assets/javascripts/lib/logger/hello.js b/app/assets/javascripts/lib/logger/hello.js index 18fa35ab55b..ccfdfe91e60 100644 --- a/app/assets/javascripts/lib/logger/hello.js +++ b/app/assets/javascripts/lib/logger/hello.js @@ -1,15 +1,36 @@ +import { s__, sprintf } from '~/locale'; + const HANDSHAKE = String.fromCodePoint(0x1f91d); const MAG = String.fromCodePoint(0x1f50e); +const ROCKET = String.fromCodePoint(0x1f680); export const logHello = () => { // eslint-disable-next-line no-console console.log( - `%cWelcome to GitLab!%c + `%c${s__('HelloMessage|Welcome to GitLab!')}%c -Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute! +${s__( + 'HelloMessage|Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute!', +)} -${HANDSHAKE} Contribute to GitLab: https://about.gitlab.com/community/contribute/ -${MAG} Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new`, +${sprintf(s__('HelloMessage|%{handshake_emoji} Contribute to GitLab: %{contribute_link}'), { + handshake_emoji: `${HANDSHAKE}`, + contribute_link: 'https://about.gitlab.com/community/contribute/', +})} +${sprintf(s__('HelloMessage|%{magnifier_emoji} Create a new GitLab issue: %{new_issue_link}'), { + magnifier_emoji: `${MAG}`, + new_issue_link: 'https://gitlab.com/gitlab-org/gitlab/-/issues/new', +})} +${ + window.gon?.dot_com + ? `${sprintf( + s__( + 'HelloMessage|%{rocket_emoji} We like your curiosity! Help us improve GitLab by joining the team: %{jobs_page_link}', + ), + { rocket_emoji: `${ROCKET}`, jobs_page_link: 'https://about.gitlab.com/jobs/' }, + )}` + : '' +}`, `padding-top: 0.5em; font-size: 2em;`, 'padding-bottom: 0.5em;', ); diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js index 0a26f78e253..de6d85b8a18 100644 --- a/app/assets/javascripts/lib/utils/axios_utils.js +++ b/app/assets/javascripts/lib/utils/axios_utils.js @@ -2,6 +2,7 @@ import axios from 'axios'; import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor'; import setupAxiosStartupCalls from './axios_startup_calls'; import csrf from './csrf'; +import { isNavigatingAway } from './is_navigating_away'; import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation'; axios.defaults.headers.common[csrf.headerKey] = csrf.token; @@ -30,16 +31,11 @@ axios.interceptors.response.use( }, ); -let isUserNavigating = false; -window.addEventListener('beforeunload', () => { - isUserNavigating = true; -}); - // Ignore AJAX errors caused by requests // being cancelled due to browser navigation axios.interceptors.response.use( (response) => response, - (err) => suppressAjaxErrorsDuringNavigation(err, isUserNavigating), + (err) => suppressAjaxErrorsDuringNavigation(err, isNavigatingAway()), ); registerCaptchaModalInterceptor(axios); diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js index da2c10076b1..66d52051905 100644 --- a/app/assets/javascripts/lib/utils/color_utils.js +++ b/app/assets/javascripts/lib/utils/color_utils.js @@ -1,3 +1,22 @@ +const colorValidatorEl = document.createElement('div'); + +/** + * Validates whether the specified color expression + * is supported by the browser’s DOM API and has a valid form. + * + * This utility assigns the color expression to a detached DOM + * element’s color property. If the color expression is valid, + * the DOM API will accept the value. + * + * @param {String} color color expression rgba, hex, hsla, etc. + */ +export const isValidColorExpression = (colorExpression) => { + colorValidatorEl.style.color = ''; + colorValidatorEl.style.color = colorExpression; + + return colorValidatorEl.style.color.length > 0; +}; + /** * Convert hex color to rgb array * diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index fd9629499b0..813fd3dbb1e 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -6,6 +6,7 @@ import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/util import $ from 'jquery'; import Cookies from 'js-cookie'; import { isFunction, defer } from 'lodash'; +import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import { convertToCamelCase, convertToSnakeCase } from './text_utility'; import { isObject } from './type_utility'; import { getLocationHash } from './url_utility'; @@ -685,7 +686,7 @@ export const searchBy = (query = '', searchSpace = {}) => { * @param {Object} label * @returns Boolean */ -export const isScopedLabel = ({ title = '' } = {}) => title.indexOf('::') !== -1; +export const isScopedLabel = ({ title = '' } = {}) => title.includes(SCOPED_LABEL_DELIMITER); /** * Returns the base value of the scoped label @@ -696,7 +697,8 @@ export const isScopedLabel = ({ title = '' } = {}) => title.indexOf('::') !== -1 * @param {Object} label * @returns String */ -export const scopedLabelKey = ({ title = '' }) => isScopedLabel({ title }) && title.split('::')[0]; +export const scopedLabelKey = ({ title = '' }) => + isScopedLabel({ title }) && title.split(SCOPED_LABEL_DELIMITER)[0]; // Methods to set and get Cookie export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 }); diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index e41de72ded4..0e5a23a5cbb 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -20,3 +20,7 @@ export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide'; export const DEFAULT_TH_CLASSES = 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; + +// We set the drawer's z-index to 252 to clear flash messages that might +// be displayed in the page and that have a z-index of 251. +export const DRAWER_Z_INDEX = 252; diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js index 0a35efb0ac8..3c446c21865 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -1,6 +1,8 @@ import dateFormat from 'dateformat'; -import { isString, mapValues, reduce, isDate } from 'lodash'; -import { s__, n__, __ } from '../../../locale'; +import { isString, mapValues, reduce, isDate, unescape } from 'lodash'; +import { roundToNearestHalf } from '~/lib/utils/common_utils'; +import { sanitize } from '~/lib/dompurify'; +import { s__, n__, __, sprintf } from '../../../locale'; /** * Returns i18n month names array. @@ -361,3 +363,26 @@ export const dateToTimeInputValue = (date) => { hour12: false, }); }; + +export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, months }) => { + if (months) { + return sprintf(s__('ValueStreamAnalytics|%{value}M'), { + value: roundToNearestHalf(months), + }); + } else if (weeks) { + return sprintf(s__('ValueStreamAnalytics|%{value}w'), { + value: roundToNearestHalf(weeks), + }); + } else if (days) { + return sprintf(s__('ValueStreamAnalytics|%{value}d'), { + value: roundToNearestHalf(days), + }); + } else if (hours) { + return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours }); + } else if (minutes) { + return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes }); + } else if (seconds) { + return unescape(sanitize(s__('ValueStreamAnalytics|<1m'), { ALLOWED_TAGS: [] })); + } + return '-'; +}; diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js index a2b161d1446..840cc4600fe 100644 --- a/app/assets/javascripts/lib/utils/datetime_range.js +++ b/app/assets/javascripts/lib/utils/datetime_range.js @@ -26,7 +26,17 @@ const isValidDateString = (dateString) => { return false; } - return !Number.isNaN(Date.parse(dateformat(dateString, 'isoUtcDateTime'))); + let isoFormatted; + try { + isoFormatted = dateformat(dateString, 'isoUtcDateTime'); + } catch (e) { + if (e instanceof TypeError) { + // not a valid date string + return false; + } + throw e; + } + return !Number.isNaN(Date.parse(isoFormatted)); }; const handleRangeDirection = ({ direction = DEFAULT_DIRECTION, anchorDate, minDate, maxDate }) => { diff --git a/app/assets/javascripts/lib/utils/is_navigating_away.js b/app/assets/javascripts/lib/utils/is_navigating_away.js new file mode 100644 index 00000000000..7df00b45379 --- /dev/null +++ b/app/assets/javascripts/lib/utils/is_navigating_away.js @@ -0,0 +1,23 @@ +let navigating = false; + +window.addEventListener('beforeunload', () => { + navigating = true; +}); + +/** + * To only be used for testing purposes. Allows the navigating state to be set + * to a given value. + * + * @param {boolean} value The value to set the navigating flag to. + */ +export const setNavigatingForTestsOnly = (value) => { + navigating = value; +}; + +/** + * Returns a boolean indicating whether the browser is in the process of + * navigating away from the current page. + * + * @returns {boolean} + */ +export const isNavigatingAway = () => navigating; diff --git a/app/assets/javascripts/lib/utils/regexp.js b/app/assets/javascripts/lib/utils/regexp.js index 25b60dcd14a..f212bf80bd7 100644 --- a/app/assets/javascripts/lib/utils/regexp.js +++ b/app/assets/javascripts/lib/utils/regexp.js @@ -1,6 +1,5 @@ /** * Regexp utility for the convenience of working with regular expressions. - * */ // Inspired by https://github.com/mishoo/UglifyJS/blob/2bc1d02363db3798d5df41fb5059a19edca9b7eb/lib/parse-js.js#L203 @@ -8,4 +7,9 @@ const unicodeLetters = '\\u0041-\\u005A\\u0061-\\u007A\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u0527\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0620-\\u064A\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u08A0\\u08A2-\\u08AC\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0977\\u0979-\\u097F\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C33\\u0C35-\\u0C39\\u0C3D\\u0C58\\u0C59\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDE\\u0CE0\\u0CE1\\u0CF1\\u0CF2\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D60\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F4\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16EE-\\u16F0\\u1700-\\u170C\\u170E-\\u1711\\u1720-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1877\\u1880-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191C\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19C1-\\u19C7\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4B\\u1B83-\\u1BA0\\u1BAE\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1CE9-\\u1CEC\\u1CEE-\\u1CF1\\u1CF5\\u1CF6\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2160-\\u2188\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005-\\u3007\\u3021-\\u3029\\u3031-\\u3035\\u3038-\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31BA\\u31F0-\\u31FF\\u3400-\\u4DB5\\u4E00-\\u9FCC\\uA000-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A\\uA62B\\uA640-\\uA66E\\uA67F-\\uA697\\uA6A0-\\uA6EF\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA78E\\uA790-\\uA793\\uA7A0-\\uA7AA\\uA7F8-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA80-\\uAAAF\\uAAB1\\uAAB5\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uABC0-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC'; -export default { unicodeLetters }; +/** + * A regex that matches all single quotes in a string + */ +export const allSingleQuotes = /'/g; + +export default { unicodeLetters, allSingleQuotes }; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 5ee00464a8b..419afa0a0a9 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -4,6 +4,7 @@ import { TRUNCATE_WIDTH_DEFAULT_WIDTH, TRUNCATE_WIDTH_DEFAULT_FONT_SIZE, } from '~/lib/utils/constants'; +import { allSingleQuotes } from '~/lib/utils/regexp'; /** * Adds a , to a string composed by numbers, at every 3 chars. @@ -479,3 +480,17 @@ export const markdownConfig = { ALLOWED_ATTR: ['class', 'style', 'href', 'src'], ALLOW_DATA_ATTR: false, }; + +/** + * Escapes a string into a shell string, for example + * when you want to give a user the command to checkout + * a branch. + * + * It replaces all single-quotes with an escaped "'\''" + * that is interpreted by shell as a single-quote. It also + * encapsulates the string in single-quotes. + * + * If the branch is `fix-'bug-behavior'`, that should be + * escaped to `'fix-'\''bug-behavior'\'''`. + */ +export const escapeShellString = (str) => `'${str.replace(allSingleQuotes, () => "'\\''")}'`; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index bca0e45d98d..1c22d21a313 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,3 +1,5 @@ +export const DASH_SCOPE = '-'; + const PATH_SEPARATOR = '/'; const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`); const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`); @@ -588,3 +590,30 @@ export function isSameOriginUrl(url) { return false; } } + +/** + * Returns a URL to WebIDE considering the current user's position in + * repository's tree. If not MR `iid` has been passed, the URL is fetched + * from the global `gl.webIDEPath`. + * + * @param sourceProjectFullPath Source project's full path. Used in MRs + * @param targetProjectFullPath Target project's full path. Used in MRs + * @param iid MR iid + * @returns {string} + */ + +export function constructWebIDEPath({ + sourceProjectFullPath, + targetProjectFullPath = '', + iid, +} = {}) { + if (!iid || !sourceProjectFullPath) { + return window.gl?.webIDEPath; + } + return mergeUrlParams( + { + target_project: sourceProjectFullPath !== targetProjectFullPath ? targetProjectFullPath : '', + }, + webIDEUrl(`/${sourceProjectFullPath}/merge_requests/${iid}`), + ); +} diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index 3db9fa01629..2a60825a427 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -214,7 +214,7 @@ export default { <template #items> <pre ref="logTrace" - class="build-trace" + class="build-log" ><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation"> <div class="dot"></div> <div class="dot"></div> diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js index 83080589362..ee17e8ecef2 100644 --- a/app/assets/javascripts/logs/stores/state.js +++ b/app/assets/javascripts/logs/stores/state.js @@ -31,7 +31,7 @@ export default () => ({ }, /** - * Logs including trace + * Jobs with logs */ logs: { lines: [], diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index b96a2607552..e422d9b1a32 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -1,5 +1,4 @@ /* global $ */ -/* eslint-disable import/order */ import jQuery from 'jquery'; import Cookies from 'js-cookie'; @@ -15,6 +14,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { initRails } from '~/lib/utils/rails_ujs'; import * as popovers from '~/popovers'; import * as tooltips from '~/tooltips'; +import { initHeaderSearchApp } from '~/header_search'; import initAlertHandler from './alert_handler'; import { removeFlashClickListener } from './flash'; import initTodoToggle from './header'; @@ -36,7 +36,6 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; import { initTopNav } from './nav'; -import { initHeaderSearchApp } from '~/header_search'; import 'ee_else_ce/main_ee'; import 'jh_else_ce/main_jh'; diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue index 665e8ee69f7..69137ce615b 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue @@ -42,7 +42,7 @@ export default { required: false, default: false, }, - oncallSchedules: { + userDeletionObstacles: { type: Object, required: false, default: () => ({}), @@ -61,7 +61,7 @@ export default { memberPath: this.memberPath.replace(':id', this.memberId), memberType: this.memberType, message: this.message, - oncallSchedules: this.oncallSchedules, + userDeletionObstacles: this.userDeletionObstacles, }; }, }, diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue index 0c20f935d50..44d658c90a0 100644 --- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -1,5 +1,6 @@ <script> import { s__, sprintf } from '~/locale'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import ActionButtonGroup from './action_button_group.vue'; import LeaveButton from './leave_button.vue'; import RemoveMemberButton from './remove_member_button.vue'; @@ -49,9 +50,11 @@ export default { }, ); }, - oncallScheduleUserData() { - const { user: { name, oncallSchedules: schedules } = {} } = this.member; - return { name, schedules }; + userDeletionObstaclesUserData() { + return { + name: this.member.user?.name, + obstacles: parseUserDeletionObstacles(this.member.user), + }; }, }, }; @@ -65,7 +68,7 @@ export default { v-else :member-id="member.id" :member-type="member.type" - :oncall-schedules="oncallScheduleUserData" + :user-deletion-obstacles="userDeletionObstaclesUserData" :message="message" :title="s__('Member|Remove member')" /> diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue index 44178981136..e39669e17dd 100644 --- a/app/assets/javascripts/members/components/modals/leave_modal.vue +++ b/app/assets/javascripts/members/components/modals/leave_modal.vue @@ -3,7 +3,8 @@ import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { LEAVE_MODAL_ID } from '../../constants'; export default { @@ -20,7 +21,7 @@ export default { csrf, modalId: LEAVE_MODAL_ID, modalContent: s__('Members|Are you sure you want to leave "%{source}"?'), - components: { GlModal, GlForm, GlSprintf, OncallSchedulesList }, + components: { GlModal, GlForm, GlSprintf, UserDeletionObstaclesList }, directives: { GlTooltip: GlTooltipDirective, }, @@ -43,11 +44,11 @@ export default { modalTitle() { return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName }); }, - schedules() { - return this.member.user?.oncallSchedules; + obstacles() { + return parseUserDeletionObstacles(this.member.user); }, - isPartOfOnCallSchedules() { - return this.schedules?.length; + hasObstaclesToUserDeletion() { + return this.obstacles?.length; }, }, methods: { @@ -74,9 +75,9 @@ export default { </gl-sprintf> </p> - <oncall-schedules-list - v-if="isPartOfOnCallSchedules" - :schedules="schedules" + <user-deletion-obstacles-list + v-if="hasObstaclesToUserDeletion" + :obstacles="obstacles" :is-current-user="true" /> diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue index 00b6ebf9a73..b82fb0030ff 100644 --- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue @@ -3,7 +3,7 @@ import { GlFormCheckbox, GlModal } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { s__, __ } from '~/locale'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; export default { actionCancel: { @@ -13,7 +13,7 @@ export default { components: { GlFormCheckbox, GlModal, - OncallSchedulesList, + UserDeletionObstaclesList, }, inject: ['namespace'], computed: { @@ -33,8 +33,8 @@ export default { message(state) { return state[this.namespace].removeMemberModalData.message; }, - oncallSchedules(state) { - return state[this.namespace].removeMemberModalData.oncallSchedules ?? {}; + userDeletionObstacles(state) { + return state[this.namespace].removeMemberModalData.userDeletionObstacles ?? {}; }, removeMemberModalVisible(state) { return state[this.namespace].removeMemberModalVisible; @@ -60,11 +60,11 @@ export default { }, }; }, - showUnassignIssuablesCheckbox() { + hasWorkspaceAccess() { return !this.isAccessRequest && !this.isInvite; }, - isPartOfOncallSchedules() { - return !this.isAccessRequest && this.oncallSchedules.schedules?.length; + hasObstaclesToUserDeletion() { + return this.hasWorkspaceAccess && this.userDeletionObstacles.obstacles?.length; }, }, methods: { @@ -95,10 +95,10 @@ export default { <form ref="form" :action="memberPath" method="post"> <p>{{ message }}</p> - <oncall-schedules-list - v-if="isPartOfOncallSchedules" - :schedules="oncallSchedules.schedules" - :user-name="oncallSchedules.name" + <user-deletion-obstacles-list + v-if="hasObstaclesToUserDeletion" + :obstacles="userDeletionObstacles.obstacles" + :user-name="userDeletionObstacles.name" /> <input ref="method" type="hidden" name="_method" value="delete" /> @@ -106,7 +106,7 @@ export default { <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships"> {{ __('Also remove direct user membership from subgroups and projects') }} </gl-form-checkbox> - <gl-form-checkbox v-if="showUnassignIssuablesCheckbox" name="unassign_issuables"> + <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables"> {{ __('Also unassign this user from related issues and merge requests') }} </gl-form-checkbox> </form> diff --git a/app/assets/javascripts/members/components/table/expires_at.vue b/app/assets/javascripts/members/components/table/expires_at.vue deleted file mode 100644 index c91de061b50..00000000000 --- a/app/assets/javascripts/members/components/table/expires_at.vue +++ /dev/null @@ -1,66 +0,0 @@ -<script> -import { GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import { - approximateDuration, - differenceInSeconds, - formatDate, - getDayDifference, -} from '~/lib/utils/datetime_utility'; -import { DAYS_TO_EXPIRE_SOON } from '../../constants'; - -export default { - name: 'ExpiresAt', - components: { GlSprintf }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - date: { - type: String, - required: false, - default: null, - }, - }, - computed: { - noExpirationSet() { - return this.date === null; - }, - parsed() { - return new Date(this.date); - }, - differenceInSeconds() { - return differenceInSeconds(new Date(), this.parsed); - }, - isExpired() { - return this.differenceInSeconds <= 0; - }, - inWords() { - return approximateDuration(this.differenceInSeconds); - }, - formatted() { - return formatDate(this.parsed); - }, - expiresSoon() { - return getDayDifference(new Date(), this.parsed) < DAYS_TO_EXPIRE_SOON; - }, - cssClass() { - return { - 'gl-text-red-500': this.isExpired, - 'gl-text-orange-500': this.expiresSoon, - }; - }, - }, -}; -</script> - -<template> - <span v-if="noExpirationSet">{{ s__('Members|No expiration set') }}</span> - <span v-else v-gl-tooltip.hover :title="formatted" :class="cssClass"> - <template v-if="isExpired">{{ s__('Members|Expired') }}</template> - <gl-sprintf v-else :message="s__('Members|in %{time}')"> - <template #time> - {{ inWords }} - </template> - </gl-sprintf> - </span> -</template> diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index debc3fc31f6..202f3aa89e1 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -5,12 +5,17 @@ import MembersTableCell from 'ee_else_ce/members/components/table/members_table_ import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import initUserPopovers from '~/user_popovers'; -import { FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME } from '../../constants'; +import { + FIELDS, + ACTIVE_TAB_QUERY_PARAM_NAME, + MEMBER_STATE_AWAITING, + USER_STATE_BLOCKED_PENDING_APPROVAL, + BADGE_LABELS_PENDING_OWNER_APPROVAL, +} from '../../constants'; import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; import RemoveMemberModal from '../modals/remove_member_modal.vue'; import CreatedAt from './created_at.vue'; import ExpirationDatepicker from './expiration_datepicker.vue'; -import ExpiresAt from './expires_at.vue'; import MemberActionButtons from './member_action_buttons.vue'; import MemberAvatar from './member_avatar.vue'; import MemberSource from './member_source.vue'; @@ -24,7 +29,6 @@ export default { GlPagination, MemberAvatar, CreatedAt, - ExpiresAt, MembersTableCell, MemberSource, MemberActionButtons, @@ -131,6 +135,74 @@ export default { window.location.href, ); }, + /** + * Returns whether it's a new or existing user + * + * If memberInviteMetadata doesn't exist, it means we're adding an existing user + * to the Group/Project, so `isNewUser` should be false. + * If memberInviteMetadata exists but `userState` has content, + * the user has registered but is awaiting root approval + * + * @param {object} memberInviteMetadata - MemberEntity.invite + * @see {@link ~/app/serializers/member_entity.rb} + * @returns {boolean} + */ + isNewUser(memberInviteMetadata) { + return memberInviteMetadata && !memberInviteMetadata.userState; + }, + /** + * Returns whether the user is awaiting root approval + * + * This checks User.state exposed via MemberEntity + * + * @param {object} memberInviteMetadata - MemberEntity.invite + * @see {@link ~/app/serializers/member_entity.rb} + * @returns {boolean} + */ + isUserPendingRootApproval(memberInviteMetadata) { + return memberInviteMetadata?.userState === USER_STATE_BLOCKED_PENDING_APPROVAL; + }, + /** + * Returns whether the member is awaiting owner approval + * + * This checks Member.state exposed via MemberEntity + * + * @param {Number} memberState - Member.state exposed via MemberEntity.state + * @see {@link ~/ee/app/models/ee/member.rb} + * @see {@link ~/app/serializers/member_entity.rb} + * @returns {boolean} + */ + isMemberPendingOwnerApproval(memberState) { + return memberState === MEMBER_STATE_AWAITING; + }, + isUserAwaiting(memberInviteMetadata, memberState) { + return ( + this.isUserPendingRootApproval(memberInviteMetadata) || + this.isMemberPendingOwnerApproval(memberState) + ); + }, + shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState) { + return ( + this.isUserAwaiting(memberInviteMetadata, memberState) && + !this.isNewUser(memberInviteMetadata) + ); + }, + /** + * Returns the string to be used in the invite badge + * + * @param {object} memberInviteMetadata - MemberEntity.invite + * @see {@link ~/app/serializers/member_entity.rb} + * @param {Number} memberState - Member.state exposed via MemberEntity.state + * @see {@link ~/ee/app/models/ee/member.rb} + * @returns {string} + */ + inviteBadge(memberInviteMetadata, memberState) { + if (this.shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState)) { + return BADGE_LABELS_PENDING_OWNER_APPROVAL; + } + + return ''; + }, }, }; </script> @@ -174,18 +246,17 @@ export default { <created-at :date="createdAt" :created-by="createdBy" /> </template> - <template #cell(invited)="{ item: { createdAt, createdBy } }"> + <template #cell(invited)="{ item: { createdAt, createdBy, invite, state } }"> <created-at :date="createdAt" :created-by="createdBy" /> + <gl-badge v-if="inviteBadge(invite, state)" data-testid="invited-badge">{{ + inviteBadge(invite, state) + }}</gl-badge> </template> <template #cell(requested)="{ item: { createdAt } }"> <created-at :date="createdAt" /> </template> - <template #cell(expires)="{ item: { expiresAt } }"> - <expires-at :date="expiresAt" /> - </template> - <template #cell(maxRole)="{ item: member }"> <members-table-cell #default="{ permissions }" :member="member"> <role-dropdown v-if="permissions.canUpdate" :permissions="permissions" :member="member" /> diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 6f465245d20..f5ca881ab0d 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -38,12 +38,6 @@ export const FIELDS = [ tdClass: 'col-meta', }, { - key: 'expires', - label: __('Access expires'), - thClass: 'col-meta', - tdClass: 'col-meta', - }, - { key: 'maxRole', label: __('Max role'), thClass: 'col-max-role', @@ -95,6 +89,22 @@ export const TAB_QUERY_PARAM_VALUES = { accessRequest: 'access_requests', }; +/** + * This user state value comes from the User model + * see the state machine in app/models/user.rb + */ +export const USER_STATE_BLOCKED_PENDING_APPROVAL = 'blocked_pending_approval'; + +/** + * This and following member state constants' values + * come from ee/app/models/ee/member.rb + */ +export const MEMBER_STATE_CREATED = 0; +export const MEMBER_STATE_AWAITING = 1; +export const MEMBER_STATE_ACTIVE = 2; + +export const BADGE_LABELS_PENDING_OWNER_APPROVAL = __('Pending owner approval'); + export const DAYS_TO_EXPIRE_SOON = 7; export const LEAVE_MODAL_ID = 'member-leave-modal'; diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue index a856d38c089..87eeb272659 100644 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue @@ -35,7 +35,11 @@ export default { <td :class="lineCssClass(line)" class="diff-line-num header"></td> <td :class="lineCssClass(line)" class="line_content header"> <strong>{{ line.richText }}</strong> - <button type="button" @click="handleSelected({ file, line })"> + <button + type="button" + class="gl-border-1 gl-border-solid" + @click="handleSelected({ file, line })" + > {{ line.buttonTitle }} </button> </td> diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue index 2c89b614c87..2c59e7bfa2f 100644 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue @@ -35,7 +35,11 @@ export default { <td class="diff-line-num header" :class="lineCssClass(line)"></td> <td class="line_content header" :class="lineCssClass(line)"> <strong>{{ line.richText }}</strong> - <button type="button" @click="handleSelected({ file, line })"> + <button + type="button" + class="gl-border-1 gl-border-solid" + @click="handleSelected({ file, line })" + > {{ line.buttonTitle }} </button> </td> diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index ed32f26583e..244cf1e150a 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import createFlash from '~/flash'; +import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; import eventHub from '~/vue_merge_request_widget/event_hub'; import axios from './lib/utils/axios_utils'; @@ -136,10 +137,9 @@ MergeRequest.hideCloseButton = function () { MergeRequest.toggleDraftStatus = function (title, isReady) { if (isReady) { - createFlash({ - message: __('Marked as ready. Merging is now allowed.'), - type: 'notice', - }); + toast(__('Marked as ready. Merging is now allowed.')); + } else { + toast(__('Marked as draft. Can only be merged when marked as ready.')); } const titleEl = document.querySelector('.merge-request .detail-page-description .title'); diff --git a/app/assets/javascripts/mr_popover/index.js b/app/assets/javascripts/mr_popover/index.js index 714cf67e0bd..6e46c5d3c1f 100644 --- a/app/assets/javascripts/mr_popover/index.js +++ b/app/assets/javascripts/mr_popover/index.js @@ -48,7 +48,12 @@ export default (elements) => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), }); const listenerAddedAttr = 'data-mr-listener-added'; diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js deleted file mode 100644 index af7a600d1ad..00000000000 --- a/app/assets/javascripts/namespace_select.js +++ /dev/null @@ -1,58 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import Api from './api'; -import { mergeUrlParams } from './lib/utils/url_utility'; -import { __ } from './locale'; - -export default class NamespaceSelect { - constructor(opts) { - const isFilter = parseBoolean(opts.dropdown.dataset.isFilter); - const fieldName = opts.dropdown.dataset.fieldName || 'namespace_id'; - - initDeprecatedJQueryDropdown($(opts.dropdown), { - filterable: true, - selectable: true, - filterRemote: true, - search: { - fields: ['path'], - }, - fieldName, - toggleLabel(selected) { - if (selected.id == null) { - return selected.text; - } - return `${selected.kind}: ${selected.full_path}`; - }, - data(term, dataCallback) { - return Api.namespaces(term, (namespaces) => { - if (isFilter) { - const anyNamespace = { - text: __('Any namespace'), - id: null, - }; - namespaces.unshift(anyNamespace); - namespaces.splice(1, 0, { type: 'divider' }); - } - return dataCallback(namespaces); - }); - }, - text(namespace) { - if (namespace.id == null) { - return namespace.text; - } - return `${namespace.kind}: ${namespace.full_path}`; - }, - renderRow: this.renderRow, - clicked(options) { - if (!isFilter) { - const { e } = options; - e.preventDefault(); - } - }, - url(namespace) { - return mergeUrlParams({ [fieldName]: namespace.id }, window.location.href); - }, - }); - } -} diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 1384c9c40b3..073b27605bb 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -1,6 +1,7 @@ <script> import katex from 'katex'; import marked from 'marked'; +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { sanitize } from '~/lib/dompurify'; import { hasContent, markdownConfig } from '~/lib/utils/text_utility'; import Prompt from './prompt.vue'; @@ -138,6 +139,9 @@ export default { components: { prompt: Prompt, }, + directives: { + SafeHtml, + }, inject: ['relativeRawPath'], props: { cell: { @@ -150,16 +154,17 @@ export default { renderer.attachments = this.cell.attachments; renderer.relativeRawPath = this.relativeRawPath; - return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), markdownConfig); + return marked(this.cell.source.join('').replace(/\\/g, '\\\\')); }, }, + markdownConfig, }; </script> <template> <div class="cell text-cell"> <prompt /> - <div class="markdown" v-html="markdown /* eslint-disable-line vue/no-v-html */"></div> + <div v-safe-html:[$options.markdownConfig]="markdown" class="markdown"></div> </div> </template> diff --git a/app/assets/javascripts/notes/components/comment_type_dropdown.vue b/app/assets/javascripts/notes/components/comment_type_dropdown.vue index 663a912999d..30ea5d3532e 100644 --- a/app/assets/javascripts/notes/components/comment_type_dropdown.vue +++ b/app/assets/javascripts/notes/components/comment_type_dropdown.vue @@ -96,7 +96,11 @@ export default { data-track-action="click_button" @click="$emit('click')" > - <gl-dropdown-item is-check-item :is-checked="isNoteTypeComment" @click="setNoteTypeToComment"> + <gl-dropdown-item + is-check-item + :is-checked="isNoteTypeComment" + @click.stop.prevent="setNoteTypeToComment" + > <strong>{{ $options.i18n.submitButton.comment }}</strong> <p class="gl-m-0">{{ commentDescription }}</p> </gl-dropdown-item> @@ -105,7 +109,7 @@ export default { is-check-item :is-checked="isNoteTypeDiscussion" data-qa-selector="discussion_menu_item" - @click="setNoteTypeToDiscussion" + @click.stop.prevent="setNoteTypeToDiscussion" > <strong>{{ $options.i18n.submitButton.startThread }}</strong> <p class="gl-m-0">{{ startDiscussionDescription }}</p> diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 0892276ff3b..6fcfa66ea49 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -47,6 +47,11 @@ export default { required: false, default: '', }, + isOverviewTab: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapGetters(['userCanReply']), @@ -127,6 +132,7 @@ export default { :show-reply-button="userCanReply" :discussion-root="true" :discussion-resolve-path="discussion.resolve_path" + :is-overview-tab="isOverviewTab" @handleDeleteNote="$emit('deleteNote')" @startReplying="$emit('startReplying')" > @@ -176,6 +182,7 @@ export default { :line="diffLine" :discussion-root="index === 0" :discussion-resolve-path="discussion.resolve_path" + :is-overview-tab="isOverviewTab" @handleDeleteNote="$emit('deleteNote')" > <template #avatar-badge> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 44d0c741d5a..e2a2edd7344 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -257,7 +257,7 @@ export default { <user-access-role-badge v-if="isAuthor" v-gl-tooltip - class="gl-mx-3 d-none d-md-inline-block" + class="gl-mr-3 d-none d-md-inline-block" :title="displayAuthorBadgeText" > {{ __('Author') }} @@ -265,7 +265,7 @@ export default { <user-access-role-badge v-if="accessLevel" v-gl-tooltip - class="gl-mx-3" + class="gl-mr-3" :title="displayMemberBadgeText" > {{ accessLevel }} @@ -273,7 +273,7 @@ export default { <user-access-role-badge v-else-if="isContributor" v-gl-tooltip - class="gl-mx-3" + class="gl-mr-3" :title="displayContributorBadgeText" > {{ __('Contributor') }} diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 93f71276120..1ce1696e332 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -51,7 +51,7 @@ export default { }, }, computed: { - ...mapGetters(['getDiscussion', 'suggestionsCount']), + ...mapGetters(['getDiscussion', 'suggestionsCount', 'getSuggestionsFilePaths']), ...mapGetters('diffs', ['suggestionCommitMessage']), discussion() { if (!this.note.isDraft) return {}; @@ -74,9 +74,10 @@ export default { // Please see this issue comment for why these // are hard-coded to 1: // https://gitlab.com/gitlab-org/gitlab/-/issues/291027#note_468308022 - const suggestionsCount = 1; - const filesCount = 1; - const filePaths = this.file ? [this.file.file_path] : []; + const suggestionsCount = this.batchSuggestionsInfo.length || 1; + const batchFilePaths = this.getSuggestionsFilePaths(); + const filePaths = batchFilePaths.length ? batchFilePaths : [this.file.file_path]; + const filesCount = filePaths.length; const suggestion = this.suggestionCommitMessage({ file_paths: filePaths.join(', '), suggestions_count: suggestionsCount, @@ -131,8 +132,8 @@ export default { message, }).then(callback); }, - applySuggestionBatch({ flashContainer }) { - return this.submitSuggestionBatch({ flashContainer }); + applySuggestionBatch({ message, flashContainer }) { + return this.submitSuggestionBatch({ message, flashContainer }); }, addSuggestionToBatch(suggestionId) { const { discussion_id: discussionId, id: noteId } = this.note; diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index a4f06a8d9f5..b05643e5e13 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -348,6 +348,7 @@ export default { id="note_note" ref="textarea" v-model="updatedNoteBody" + :disabled="isSubmitting" :data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete" name="note[note]" class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 4e686ce8719..0925195d4bb 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -1,10 +1,16 @@ <script> -import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + GlIcon, + GlLoadingIcon, + GlTooltipDirective, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; import { mapActions } from 'vuex'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserNameWithStatus from '../../sidebar/components/assignees/user_name_with_status.vue'; export default { + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, components: { timeAgoTooltip, GitlabTeamMemberBadge: () => @@ -14,6 +20,7 @@ export default { UserNameWithStatus, }, directives: { + SafeHtml, GlTooltip: GlTooltipDirective, }, props: { @@ -165,10 +172,10 @@ export default { <span v-if="authorStatus" ref="authorStatus" + v-safe-html:[$options.safeHtmlConfig]="authorStatus" v-on=" authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {} " - v-html="authorStatus /* eslint-disable-line vue/no-v-html */" ></span> <span class="text-nowrap author-username"> <a diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index b99579fb9a7..77f796fe8b0 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -66,6 +66,11 @@ export default { required: false, default: '', }, + isOverviewTab: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -263,6 +268,7 @@ export default { :is-expanded="isExpanded" :line="line" :should-group-replies="shouldGroupReplies" + :is-overview-tab="isOverviewTab" @startReplying="showReplyForm" @deleteNote="deleteNoteHandler" > diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 3c6ed0a8aac..e35d8d94289 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -84,6 +84,11 @@ export default { required: false, default: '', }, + isOverviewTab: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -186,6 +191,14 @@ export default { return fileResolvedFromAvailableSource || null; }, + avatarSize() { + // Use a different size if shown on a Merge Request Diff + if (this.line && !this.isOverviewTab) { + return 24; + } + + return 40; + }, }, created() { const line = this.note.position?.line_range?.start || this.line; @@ -391,7 +404,7 @@ export default { :link-href="author.path" :img-src="author.avatar_url" :img-alt="author.name" - :img-size="40" + :img-size="avatarSize" lazy > <template #avatar-badge> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 29c60b96d8a..58570e76795 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -317,6 +317,7 @@ export default { :key="discussion.id" :discussion="discussion" :render-diff-file="true" + is-overview-tab :help-page-path="helpPagePath" /> </template> diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 656591e0c32..7eb10f647a0 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -631,7 +631,7 @@ export const submitSuggestion = ( }); }; -export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContainer }) => { +export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, flashContainer }) => { const suggestionIds = state.batchSuggestionsInfo.map(({ suggestionId }) => suggestionId); const resolveAllDiscussions = () => @@ -644,7 +644,7 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai commit(types.SET_RESOLVING_DISCUSSION, true); dispatch('stopPolling'); - return Api.applySuggestionBatch(suggestionIds) + return Api.applySuggestionBatch(suggestionIds, message) .then(() => Promise.all(resolveAllDiscussions())) .then(() => commit(types.CLEAR_SUGGESTION_BATCH)) .catch((err) => { diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 956221d69ae..a710ac0ccf5 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -283,3 +283,14 @@ export const suggestionsCount = (state, getters) => export const hasDrafts = (state, getters, rootState, rootGetters) => Boolean(rootGetters['batchComments/hasDrafts']); + +export const getSuggestionsFilePaths = (state) => () => + state.batchSuggestionsInfo.reduce((acc, suggestion) => { + const discussion = state.discussions.find((d) => d.id === suggestion.discussionId); + + if (acc.indexOf(discussion?.diff_file?.file_path) === -1) { + acc.push(discussion.diff_file.file_path); + } + + return acc; + }, []); diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js index 4f875977d78..f5891c9acb5 100644 --- a/app/assets/javascripts/notifications/constants.js +++ b/app/assets/javascripts/notifications/constants.js @@ -31,7 +31,7 @@ export const i18n = { title: __('Custom notification events'), bodyTitle: __('Notification events'), bodyMessage: __( - 'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart} notification emails%{notificationLinkEnd}.', + 'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart}notification emails%{notificationLinkEnd}.', ), }, eventNames: { diff --git a/app/assets/javascripts/packages/details/components/additional_metadata.vue b/app/assets/javascripts/packages/details/components/additional_metadata.vue deleted file mode 100644 index 4e99099b0a1..00000000000 --- a/app/assets/javascripts/packages/details/components/additional_metadata.vue +++ /dev/null @@ -1,94 +0,0 @@ -<script> -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; -import { PackageType } from '../../shared/constants'; - -export default { - i18n: { - sourceText: s__('PackageRegistry|Source project located at %{link}'), - licenseText: s__('PackageRegistry|License information located at %{link}'), - recipeText: s__('PackageRegistry|Recipe: %{recipe}'), - appGroup: s__('PackageRegistry|App group: %{group}'), - appName: s__('PackageRegistry|App name: %{name}'), - }, - components: { - DetailsRow, - GlLink, - GlSprintf, - }, - props: { - packageEntity: { - type: Object, - required: true, - }, - }, - computed: { - showMetadata() { - const visibilityConditions = { - [PackageType.NUGET]: this.packageEntity.nuget_metadatum, - [PackageType.CONAN]: this.packageEntity.conan_metadatum, - [PackageType.MAVEN]: this.packageEntity.maven_metadatum, - }; - return visibilityConditions[this.packageEntity.package_type]; - }, - }, -}; -</script> - -<template> - <div v-if="showMetadata"> - <h3 class="gl-font-lg" data-testid="title">{{ __('Additional Metadata') }}</h3> - - <div class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base" data-testid="main"> - <template v-if="packageEntity.nuget_metadatum"> - <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source"> - <gl-sprintf :message="$options.i18n.sourceText"> - <template #link> - <gl-link :href="packageEntity.nuget_metadatum.project_url" target="_blank">{{ - packageEntity.nuget_metadatum.project_url - }}</gl-link> - </template> - </gl-sprintf> - </details-row> - <details-row icon="license" padding="gl-p-4" data-testid="nuget-license"> - <gl-sprintf :message="$options.i18n.licenseText"> - <template #link> - <gl-link :href="packageEntity.nuget_metadatum.license_url" target="_blank">{{ - packageEntity.nuget_metadatum.license_url - }}</gl-link> - </template> - </gl-sprintf> - </details-row> - </template> - - <details-row - v-else-if="packageEntity.conan_metadatum" - icon="information-o" - padding="gl-p-4" - data-testid="conan-recipe" - > - <gl-sprintf :message="$options.i18n.recipeText"> - <template #recipe>{{ packageEntity.name }}</template> - </gl-sprintf> - </details-row> - - <template v-else-if="packageEntity.maven_metadatum"> - <details-row icon="information-o" padding="gl-p-4" dashed data-testid="maven-app"> - <gl-sprintf :message="$options.i18n.appName"> - <template #name> - <strong>{{ packageEntity.maven_metadatum.app_name }}</strong> - </template> - </gl-sprintf> - </details-row> - <details-row icon="information-o" padding="gl-p-4" data-testid="maven-group"> - <gl-sprintf :message="$options.i18n.appGroup"> - <template #group> - <strong>{{ packageEntity.maven_metadatum.app_group }}</strong> - </template> - </gl-sprintf> - </details-row> - </template> - </div> - </div> -</template> diff --git a/app/assets/javascripts/packages/details/components/composer_installation.vue b/app/assets/javascripts/packages/details/components/composer_installation.vue deleted file mode 100644 index bf1e5083e12..00000000000 --- a/app/assets/javascripts/packages/details/components/composer_installation.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { mapGetters, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; -import { TrackingActions, TrackingLabels } from '../constants'; - -export default { - name: 'ComposerInstallation', - components: { - InstallationTitle, - CodeInstruction, - GlLink, - GlSprintf, - }, - computed: { - ...mapState(['composerHelpPath']), - ...mapGetters(['composerRegistryInclude', 'composerPackageInclude', 'groupExists']), - }, - i18n: { - registryInclude: s__('PackageRegistry|Add composer registry'), - copyRegistryInclude: s__('PackageRegistry|Copy registry include'), - packageInclude: s__('PackageRegistry|Install package version'), - copyPackageInclude: s__('PackageRegistry|Copy require package include'), - infoLine: s__( - 'PackageRegistry|For more information on Composer packages in GitLab, %{linkStart}see the documentation.%{linkEnd}', - ), - }, - trackingActions: { ...TrackingActions }, - TrackingLabels, - installOptions: [{ value: 'composer', label: s__('PackageRegistry|Show Composer commands') }], -}; -</script> - -<template> - <div v-if="groupExists" data-testid="root-node"> - <installation-title package-type="composer" :options="$options.installOptions" /> - - <code-instruction - :label="$options.i18n.registryInclude" - :instruction="composerRegistryInclude" - :copy-text="$options.i18n.copyRegistryInclude" - :tracking-action="$options.trackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - data-testid="registry-include" - /> - - <code-instruction - :label="$options.i18n.packageInclude" - :instruction="composerPackageInclude" - :copy-text="$options.i18n.copyPackageInclude" - :tracking-action="$options.trackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - data-testid="package-include" - /> - <span data-testid="help-text"> - <gl-sprintf :message="$options.i18n.infoLine"> - <template #link="{ content }"> - <gl-link :href="composerHelpPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - </div> -</template> diff --git a/app/assets/javascripts/packages/details/components/conan_installation.vue b/app/assets/javascripts/packages/details/components/conan_installation.vue deleted file mode 100644 index 1d855f6cf3e..00000000000 --- a/app/assets/javascripts/packages/details/components/conan_installation.vue +++ /dev/null @@ -1,59 +0,0 @@ -<script> -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { mapGetters, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; -import { TrackingActions, TrackingLabels } from '../constants'; - -export default { - name: 'ConanInstallation', - components: { - InstallationTitle, - CodeInstruction, - GlLink, - GlSprintf, - }, - computed: { - ...mapState(['conanHelpPath']), - ...mapGetters(['conanInstallationCommand', 'conanSetupCommand']), - }, - i18n: { - helpText: s__( - 'PackageRegistry|For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}.', - ), - }, - trackingActions: { ...TrackingActions }, - TrackingLabels, - installOptions: [{ value: 'conan', label: s__('PackageRegistry|Show Conan commands') }], -}; -</script> - -<template> - <div> - <installation-title package-type="conan" :options="$options.installOptions" /> - - <code-instruction - :label="s__('PackageRegistry|Conan Command')" - :instruction="conanInstallationCommand" - :copy-text="s__('PackageRegistry|Copy Conan Command')" - :tracking-action="$options.trackingActions.COPY_CONAN_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - - <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> - - <code-instruction - :label="s__('PackageRegistry|Add Conan Remote')" - :instruction="conanSetupCommand" - :copy-text="s__('PackageRegistry|Copy Conan Setup Command')" - :tracking-action="$options.trackingActions.COPY_CONAN_SETUP_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - <gl-sprintf :message="$options.i18n.helpText"> - <template #link="{ content }"> - <gl-link :href="conanHelpPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </div> -</template> diff --git a/app/assets/javascripts/packages/details/components/dependency_row.vue b/app/assets/javascripts/packages/details/components/dependency_row.vue deleted file mode 100644 index 1a2202b23c8..00000000000 --- a/app/assets/javascripts/packages/details/components/dependency_row.vue +++ /dev/null @@ -1,35 +0,0 @@ -<script> -export default { - name: 'DependencyRow', - props: { - dependency: { - type: Object, - required: true, - }, - }, - computed: { - showVersion() { - return Boolean(this.dependency.version_pattern); - }, - }, -}; -</script> - -<template> - <div class="gl-responsive-table-row"> - <div class="table-section section-50"> - <strong class="gl-text-body">{{ dependency.name }}</strong> - <span v-if="dependency.target_framework" data-testid="target-framework" - >({{ dependency.target_framework }})</span - > - </div> - - <div - v-if="showVersion" - class="table-section section-50 gl-display-flex gl-md-justify-content-end" - data-testid="version-pattern" - > - <span class="gl-text-body">{{ dependency.version_pattern }}</span> - </div> - </div> -</template> diff --git a/app/assets/javascripts/packages/details/components/installation_commands.vue b/app/assets/javascripts/packages/details/components/installation_commands.vue deleted file mode 100644 index ed55d7fe782..00000000000 --- a/app/assets/javascripts/packages/details/components/installation_commands.vue +++ /dev/null @@ -1,55 +0,0 @@ -<script> -import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue'; -import { PackageType, TERRAFORM_PACKAGE_TYPE } from '../../shared/constants'; -import ComposerInstallation from './composer_installation.vue'; -import ConanInstallation from './conan_installation.vue'; -import MavenInstallation from './maven_installation.vue'; -import NpmInstallation from './npm_installation.vue'; -import NugetInstallation from './nuget_installation.vue'; -import PypiInstallation from './pypi_installation.vue'; - -export default { - name: 'InstallationCommands', - components: { - [PackageType.CONAN]: ConanInstallation, - [PackageType.MAVEN]: MavenInstallation, - [PackageType.NPM]: NpmInstallation, - [PackageType.NUGET]: NugetInstallation, - [PackageType.PYPI]: PypiInstallation, - [PackageType.COMPOSER]: ComposerInstallation, - [TERRAFORM_PACKAGE_TYPE]: TerraformInstallation, - }, - props: { - packageEntity: { - type: Object, - required: true, - }, - npmPath: { - type: String, - required: false, - default: '', - }, - npmHelpPath: { - type: String, - required: false, - default: '', - }, - }, - computed: { - installationComponent() { - return this.$options.components[this.packageEntity.package_type]; - }, - }, -}; -</script> - -<template> - <div v-if="installationComponent"> - <component - :is="installationComponent" - :name="packageEntity.name" - :registry-url="npmPath" - :help-url="npmHelpPath" - /> - </div> -</template> diff --git a/app/assets/javascripts/packages/details/components/installation_title.vue b/app/assets/javascripts/packages/details/components/installation_title.vue deleted file mode 100644 index 43133bf7825..00000000000 --- a/app/assets/javascripts/packages/details/components/installation_title.vue +++ /dev/null @@ -1,38 +0,0 @@ -<script> -import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue'; - -export default { - name: 'InstallationTitle', - components: { - PersistedDropdownSelection, - }, - props: { - packageType: { - type: String, - required: true, - }, - options: { - type: Array, - required: true, - }, - }, - computed: { - storageKey() { - return `package_${this.packageType}_installation_instructions`; - }, - }, -}; -</script> - -<template> - <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> - <h3 class="gl-font-lg">{{ __('Installation') }}</h3> - <div> - <persisted-dropdown-selection - :storage-key="storageKey" - :options="options" - @change="$emit('change', $event)" - /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/packages/details/components/maven_installation.vue b/app/assets/javascripts/packages/details/components/maven_installation.vue deleted file mode 100644 index 6974de99344..00000000000 --- a/app/assets/javascripts/packages/details/components/maven_installation.vue +++ /dev/null @@ -1,153 +0,0 @@ -<script> -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { mapGetters, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; - -import { TrackingActions, TrackingLabels } from '../constants'; - -export default { - name: 'MavenInstallation', - components: { - InstallationTitle, - CodeInstruction, - GlLink, - GlSprintf, - }, - data() { - return { - instructionType: 'maven', - }; - }, - computed: { - ...mapState(['mavenHelpPath']), - ...mapGetters([ - 'mavenInstallationXml', - 'mavenInstallationCommand', - 'mavenSetupXml', - 'gradleGroovyInstalCommand', - 'gradleGroovyAddSourceCommand', - 'gradleKotlinInstalCommand', - 'gradleKotlinAddSourceCommand', - ]), - showMaven() { - return this.instructionType === 'maven'; - }, - showGroovy() { - return this.instructionType === 'groovy'; - }, - }, - i18n: { - xmlText: s__( - `PackageRegistry|Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block.`, - ), - setupText: s__( - `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file.`, - ), - helpText: s__( - 'PackageRegistry|For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}.', - ), - }, - trackingActions: { ...TrackingActions }, - TrackingLabels, - installOptions: [ - { value: 'maven', label: s__('PackageRegistry|Maven XML') }, - { value: 'groovy', label: s__('PackageRegistry|Gradle Groovy DSL') }, - { value: 'kotlin', label: s__('PackageRegistry|Gradle Kotlin DSL') }, - ], -}; -</script> - -<template> - <div> - <installation-title - package-type="maven" - :options="$options.installOptions" - @change="instructionType = $event" - /> - - <template v-if="showMaven"> - <p> - <gl-sprintf :message="$options.i18n.xmlText"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - - <code-instruction - :instruction="mavenInstallationXml" - :copy-text="s__('PackageRegistry|Copy Maven XML')" - :tracking-action="$options.trackingActions.COPY_MAVEN_XML" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - multiline - /> - - <code-instruction - :label="s__('PackageRegistry|Maven Command')" - :instruction="mavenInstallationCommand" - :copy-text="s__('PackageRegistry|Copy Maven command')" - :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - - <h3 class="gl-font-lg">{{ s__('PackageRegistry|Registry setup') }}</h3> - <p> - <gl-sprintf :message="$options.i18n.setupText"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - <code-instruction - :instruction="mavenSetupXml" - :copy-text="s__('PackageRegistry|Copy Maven registry XML')" - :tracking-action="$options.trackingActions.COPY_MAVEN_SETUP" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - multiline - /> - <gl-sprintf :message="$options.i18n.helpText"> - <template #link="{ content }"> - <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </template> - <template v-else-if="showGroovy"> - <code-instruction - class="gl-mb-5" - :label="s__('PackageRegistry|Gradle Groovy DSL install command')" - :instruction="gradleGroovyInstalCommand" - :copy-text="s__('PackageRegistry|Copy Gradle Groovy DSL install command')" - :tracking-action="$options.trackingActions.COPY_GRADLE_INSTALL_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - <code-instruction - :label="s__('PackageRegistry|Add Gradle Groovy DSL repository command')" - :instruction="gradleGroovyAddSourceCommand" - :copy-text="s__('PackageRegistry|Copy add Gradle Groovy DSL repository command')" - :tracking-action="$options.trackingActions.COPY_GRADLE_ADD_TO_SOURCE_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - multiline - /> - </template> - <template v-else> - <code-instruction - class="gl-mb-5" - :label="s__('PackageRegistry|Gradle Kotlin DSL install command')" - :instruction="gradleKotlinInstalCommand" - :copy-text="s__('PackageRegistry|Copy Gradle Kotlin DSL install command')" - :tracking-action="$options.trackingActions.COPY_KOTLIN_INSTALL_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - <code-instruction - :label="s__('PackageRegistry|Add Gradle Kotlin DSL repository command')" - :instruction="gradleKotlinAddSourceCommand" - :copy-text="s__('PackageRegistry|Copy add Gradle Kotlin DSL repository command')" - :tracking-action="$options.trackingActions.COPY_KOTLIN_ADD_TO_SOURCE_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - multiline - /> - </template> - </div> -</template> diff --git a/app/assets/javascripts/packages/details/components/npm_installation.vue b/app/assets/javascripts/packages/details/components/npm_installation.vue deleted file mode 100644 index 6b0fcf5e4fe..00000000000 --- a/app/assets/javascripts/packages/details/components/npm_installation.vue +++ /dev/null @@ -1,103 +0,0 @@ -<script> -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { mapGetters, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; -import { NpmManager, TrackingActions, TrackingLabels } from '../constants'; - -export default { - name: 'NpmInstallation', - components: { - InstallationTitle, - CodeInstruction, - GlLink, - GlSprintf, - }, - data() { - return { - instructionType: 'npm', - }; - }, - computed: { - ...mapState(['npmHelpPath']), - ...mapGetters(['npmInstallationCommand', 'npmSetupCommand']), - npmCommand() { - return this.npmInstallationCommand(NpmManager.NPM); - }, - npmSetup() { - return this.npmSetupCommand(NpmManager.NPM); - }, - yarnCommand() { - return this.npmInstallationCommand(NpmManager.YARN); - }, - yarnSetupCommand() { - return this.npmSetupCommand(NpmManager.YARN); - }, - showNpm() { - return this.instructionType === 'npm'; - }, - }, - i18n: { - helpText: s__( - 'PackageRegistry|You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more.', - ), - }, - trackingActions: { ...TrackingActions }, - TrackingLabels, - installOptions: [ - { value: 'npm', label: s__('PackageRegistry|Show NPM commands') }, - { value: 'yarn', label: s__('PackageRegistry|Show Yarn commands') }, - ], -}; -</script> - -<template> - <div> - <installation-title - package-type="npm" - :options="$options.installOptions" - @change="instructionType = $event" - /> - - <code-instruction - v-if="showNpm" - :instruction="npmCommand" - :copy-text="s__('PackageRegistry|Copy npm command')" - :tracking-action="$options.trackingActions.COPY_NPM_INSTALL_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - - <code-instruction - v-else - :instruction="yarnCommand" - :copy-text="s__('PackageRegistry|Copy yarn command')" - :tracking-action="$options.trackingActions.COPY_YARN_INSTALL_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - - <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> - - <code-instruction - v-if="showNpm" - :instruction="npmSetup" - :copy-text="s__('PackageRegistry|Copy npm setup command')" - :tracking-action="$options.trackingActions.COPY_NPM_SETUP_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - - <code-instruction - v-else - :instruction="yarnSetupCommand" - :copy-text="s__('PackageRegistry|Copy yarn setup command')" - :tracking-action="$options.trackingActions.COPY_YARN_SETUP_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - - <gl-sprintf :message="$options.i18n.helpText"> - <template #link="{ content }"> - <gl-link :href="npmHelpPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </div> -</template> diff --git a/app/assets/javascripts/packages/details/components/nuget_installation.vue b/app/assets/javascripts/packages/details/components/nuget_installation.vue deleted file mode 100644 index d5e64722f24..00000000000 --- a/app/assets/javascripts/packages/details/components/nuget_installation.vue +++ /dev/null @@ -1,58 +0,0 @@ -<script> -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { mapGetters, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; -import { TrackingActions, TrackingLabels } from '../constants'; - -export default { - name: 'NugetInstallation', - components: { - InstallationTitle, - CodeInstruction, - GlLink, - GlSprintf, - }, - computed: { - ...mapState(['nugetHelpPath']), - ...mapGetters(['nugetInstallationCommand', 'nugetSetupCommand']), - }, - i18n: { - helpText: s__( - 'PackageRegistry|For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}.', - ), - }, - trackingActions: { ...TrackingActions }, - TrackingLabels, - installOptions: [{ value: 'nuget', label: s__('PackageRegistry|Show Nuget commands') }], -}; -</script> - -<template> - <div> - <installation-title package-type="nuget" :options="$options.installOptions" /> - - <code-instruction - :label="s__('PackageRegistry|NuGet Command')" - :instruction="nugetInstallationCommand" - :copy-text="s__('PackageRegistry|Copy NuGet Command')" - :tracking-action="$options.trackingActions.COPY_NUGET_INSTALL_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> - - <code-instruction - :label="s__('PackageRegistry|Add NuGet Source')" - :instruction="nugetSetupCommand" - :copy-text="s__('PackageRegistry|Copy NuGet Setup Command')" - :tracking-action="$options.trackingActions.COPY_NUGET_SETUP_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - <gl-sprintf :message="$options.i18n.helpText"> - <template #link="{ content }"> - <gl-link :href="nugetHelpPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </div> -</template> diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue deleted file mode 100644 index d02a7b3ec27..00000000000 --- a/app/assets/javascripts/packages/details/components/package_title.vue +++ /dev/null @@ -1,113 +0,0 @@ -<script> -/* eslint-disable vue/v-slot-style */ -import { GlIcon, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; -import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import { mapState, mapGetters } from 'vuex'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { __ } from '~/locale'; -import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; -import TitleArea from '~/vue_shared/components/registry/title_area.vue'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import PackageTags from '../../shared/components/package_tags.vue'; - -export default { - name: 'PackageTitle', - components: { - TitleArea, - GlIcon, - GlSprintf, - PackageTags, - MetadataItem, - GlBadge, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [timeagoMixin], - i18n: { - packageInfo: __('v%{version} published %{timeAgo}'), - }, - data() { - return { - isDesktop: true, - }; - }, - computed: { - ...mapState(['packageEntity', 'packageFiles']), - ...mapGetters(['packageTypeDisplay', 'packagePipeline', 'packageIcon']), - hasTagsToDisplay() { - return Boolean(this.packageEntity.tags && this.packageEntity.tags.length); - }, - totalSize() { - return numberToHumanSize(this.packageFiles.reduce((acc, p) => acc + p.size, 0)); - }, - }, - mounted() { - this.isDesktop = GlBreakpointInstance.isDesktop(); - }, - methods: { - dynamicSlotName(index) { - return `metadata-tag${index}`; - }, - }, -}; -</script> - -<template> - <title-area :title="packageEntity.name" :avatar="packageIcon" data-qa-selector="package_title"> - <template #sub-header> - <gl-icon name="eye" class="gl-mr-3" /> - <gl-sprintf :message="$options.i18n.packageInfo"> - <template #version> - {{ packageEntity.version }} - </template> - - <template #timeAgo> - <span v-gl-tooltip :title="tooltipTitle(packageEntity.created_at)"> - {{ timeFormatted(packageEntity.created_at) }} - </span> - </template> - </gl-sprintf> - </template> - - <template v-if="packageTypeDisplay" #metadata-type> - <metadata-item data-testid="package-type" icon="package" :text="packageTypeDisplay" /> - </template> - - <template #metadata-size> - <metadata-item data-testid="package-size" icon="disk" :text="totalSize" /> - </template> - - <template v-if="packagePipeline" #metadata-pipeline> - <metadata-item - data-testid="pipeline-project" - icon="review-list" - :text="packagePipeline.project.name" - :link="packagePipeline.project.web_url" - /> - </template> - - <template v-if="packagePipeline" #metadata-ref> - <metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" /> - </template> - - <template v-if="isDesktop && hasTagsToDisplay" #metadata-tags> - <package-tags :tag-display-limit="2" :tags="packageEntity.tags" hide-label /> - </template> - - <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap --> - <template - v-for="(tag, index) in packageEntity.tags" - v-else-if="hasTagsToDisplay" - v-slot:[dynamicSlotName(index)] - > - <gl-badge :key="index" class="gl-my-1" data-testid="tag-badge" variant="info" size="sm"> - {{ tag.name }} - </gl-badge> - </template> - - <template #right-actions> - <slot name="delete-button"></slot> - </template> - </title-area> -</template> diff --git a/app/assets/javascripts/packages/details/components/pypi_installation.vue b/app/assets/javascripts/packages/details/components/pypi_installation.vue deleted file mode 100644 index fe4709d5feb..00000000000 --- a/app/assets/javascripts/packages/details/components/pypi_installation.vue +++ /dev/null @@ -1,71 +0,0 @@ -<script> -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { mapGetters, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; -import { TrackingActions, TrackingLabels } from '../constants'; - -export default { - name: 'PyPiInstallation', - components: { - InstallationTitle, - CodeInstruction, - GlLink, - GlSprintf, - }, - computed: { - ...mapState(['pypiHelpPath']), - ...mapGetters(['pypiPipCommand', 'pypiSetupCommand']), - }, - i18n: { - setupText: s__( - `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file.`, - ), - helpText: s__( - 'PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}.', - ), - }, - trackingActions: { ...TrackingActions }, - TrackingLabels, - installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }], -}; -</script> - -<template> - <div> - <installation-title package-type="pypi" :options="$options.installOptions" /> - - <code-instruction - :label="s__('PackageRegistry|Pip Command')" - :instruction="pypiPipCommand" - :copy-text="s__('PackageRegistry|Copy Pip command')" - data-testid="pip-command" - :tracking-action="$options.trackingActions.COPY_PIP_INSTALL_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - - <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> - <p> - <gl-sprintf :message="$options.i18n.setupText"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - - <code-instruction - :instruction="pypiSetupCommand" - :copy-text="s__('PackageRegistry|Copy .pypirc content')" - data-testid="pypi-setup-content" - multiline - :tracking-action="$options.trackingActions.COPY_PYPI_SETUP_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - <gl-sprintf :message="$options.i18n.helpText"> - <template #link="{ content }"> - <gl-link :href="pypiHelpPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </div> -</template> diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js deleted file mode 100644 index cd34b1ad45a..00000000000 --- a/app/assets/javascripts/packages/details/constants.js +++ /dev/null @@ -1,55 +0,0 @@ -import { s__ } from '~/locale'; - -export const TrackingLabels = { - CODE_INSTRUCTION: 'code_instruction', - CONAN_INSTALLATION: 'conan_installation', - MAVEN_INSTALLATION: 'maven_installation', - NPM_INSTALLATION: 'npm_installation', - NUGET_INSTALLATION: 'nuget_installation', - PYPI_INSTALLATION: 'pypi_installation', - COMPOSER_INSTALLATION: 'composer_installation', -}; - -export const TrackingActions = { - INSTALLATION: 'installation', - REGISTRY_SETUP: 'registry_setup', - - COPY_CONAN_COMMAND: 'copy_conan_command', - COPY_CONAN_SETUP_COMMAND: 'copy_conan_setup_command', - - COPY_MAVEN_XML: 'copy_maven_xml', - COPY_MAVEN_COMMAND: 'copy_maven_command', - COPY_MAVEN_SETUP: 'copy_maven_setup_xml', - - COPY_NPM_INSTALL_COMMAND: 'copy_npm_install_command', - COPY_NPM_SETUP_COMMAND: 'copy_npm_setup_command', - - COPY_YARN_INSTALL_COMMAND: 'copy_yarn_install_command', - COPY_YARN_SETUP_COMMAND: 'copy_yarn_setup_command', - - COPY_NUGET_INSTALL_COMMAND: 'copy_nuget_install_command', - COPY_NUGET_SETUP_COMMAND: 'copy_nuget_setup_command', - - COPY_PIP_INSTALL_COMMAND: 'copy_pip_install_command', - COPY_PYPI_SETUP_COMMAND: 'copy_pypi_setup_command', - - COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND: 'copy_composer_registry_include_command', - COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND: 'copy_composer_package_include_command', - - COPY_GRADLE_INSTALL_COMMAND: 'copy_gradle_install_command', - COPY_GRADLE_ADD_TO_SOURCE_COMMAND: 'copy_gradle_add_to_source_command', - - COPY_KOTLIN_INSTALL_COMMAND: 'copy_kotlin_install_command', - COPY_KOTLIN_ADD_TO_SOURCE_COMMAND: 'copy_kotlin_add_to_source_command', -}; - -export const NpmManager = { - NPM: 'npm', - YARN: 'yarn', -}; - -export const FETCH_PACKAGE_VERSIONS_ERROR = s__( - 'PackageRegistry|Unable to fetch package version information.', -); - -export const HISTORY_PIPELINES_LIMIT = 5; diff --git a/app/assets/javascripts/packages/details/index.js b/app/assets/javascripts/packages/details/index.js deleted file mode 100644 index 5b9d58a3860..00000000000 --- a/app/assets/javascripts/packages/details/index.js +++ /dev/null @@ -1,32 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import PackagesApp from './components/app.vue'; -import createStore from './store'; - -Vue.use(Translate); - -export default () => { - const el = document.querySelector('#js-vue-packages-detail'); - const { package: packageJson, canDelete: canDeleteStr, ...rest } = el.dataset; - const packageEntity = JSON.parse(packageJson); - const canDelete = canDeleteStr === 'true'; - - const store = createStore({ - packageEntity, - packageFiles: packageEntity.package_files, - canDelete, - ...rest, - }); - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - PackagesApp, - }, - store, - render(createElement) { - return createElement('packages-app'); - }, - }); -}; diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js deleted file mode 100644 index ae273e26d6a..00000000000 --- a/app/assets/javascripts/packages/details/store/getters.js +++ /dev/null @@ -1,140 +0,0 @@ -import { PackageType } from '../../shared/constants'; -import { getPackageTypeLabel } from '../../shared/utils'; -import { NpmManager } from '../constants'; - -export const packagePipeline = ({ packageEntity }) => { - return packageEntity?.pipeline || null; -}; - -export const packageTypeDisplay = ({ packageEntity }) => { - return getPackageTypeLabel(packageEntity.package_type); -}; - -export const packageIcon = ({ packageEntity }) => { - if (packageEntity.package_type === PackageType.NUGET) { - return packageEntity.nuget_metadatum?.icon_url || null; - } - - return null; -}; - -export const conanInstallationCommand = ({ packageEntity }) => { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `conan install ${packageEntity.name} --remote=gitlab`; -}; - -export const conanSetupCommand = ({ conanPath }) => - // eslint-disable-next-line @gitlab/require-i18n-strings - `conan remote add gitlab ${conanPath}`; - -export const mavenInstallationXml = ({ packageEntity = {} }) => { - const { - app_group: appGroup = '', - app_name: appName = '', - app_version: appVersion = '', - } = packageEntity.maven_metadatum; - - return `<dependency> - <groupId>${appGroup}</groupId> - <artifactId>${appName}</artifactId> - <version>${appVersion}</version> -</dependency>`; -}; - -export const mavenInstallationCommand = ({ packageEntity = {} }) => { - const { - app_group: group = '', - app_name: name = '', - app_version: version = '', - } = packageEntity.maven_metadatum; - - return `mvn dependency:get -Dartifact=${group}:${name}:${version}`; -}; - -export const mavenSetupXml = ({ mavenPath }) => `<repositories> - <repository> - <id>gitlab-maven</id> - <url>${mavenPath}</url> - </repository> -</repositories> - -<distributionManagement> - <repository> - <id>gitlab-maven</id> - <url>${mavenPath}</url> - </repository> - - <snapshotRepository> - <id>gitlab-maven</id> - <url>${mavenPath}</url> - </snapshotRepository> -</distributionManagement>`; - -export const npmInstallationCommand = ({ packageEntity }) => (type = NpmManager.NPM) => { - // eslint-disable-next-line @gitlab/require-i18n-strings - const instruction = type === NpmManager.NPM ? 'npm i' : 'yarn add'; - - return `${instruction} ${packageEntity.name}`; -}; - -export const npmSetupCommand = ({ packageEntity, npmPath }) => (type = NpmManager.NPM) => { - const scope = packageEntity.name.substring(0, packageEntity.name.indexOf('/')); - - if (type === NpmManager.NPM) { - return `echo ${scope}:registry=${npmPath}/ >> .npmrc`; - } - - return `echo \\"${scope}:registry\\" \\"${npmPath}/\\" >> .yarnrc`; -}; - -export const nugetInstallationCommand = ({ packageEntity }) => - `nuget install ${packageEntity.name} -Source "GitLab"`; - -export const nugetSetupCommand = ({ nugetPath }) => - `nuget source Add -Name "GitLab" -Source "${nugetPath}" -UserName <your_username> -Password <your_token>`; - -export const pypiPipCommand = ({ pypiPath, packageEntity }) => - // eslint-disable-next-line @gitlab/require-i18n-strings - `pip install ${packageEntity.name} --extra-index-url ${pypiPath}`; - -export const pypiSetupCommand = ({ pypiSetupPath }) => `[gitlab] -repository = ${pypiSetupPath} -username = __token__ -password = <your personal access token>`; - -export const composerRegistryInclude = ({ composerPath, composerConfigRepositoryName }) => - // eslint-disable-next-line @gitlab/require-i18n-strings - `composer config repositories.${composerConfigRepositoryName} '{"type": "composer", "url": "${composerPath}"}'`; - -export const composerPackageInclude = ({ packageEntity }) => - // eslint-disable-next-line @gitlab/require-i18n-strings - `composer req ${[packageEntity.name]}:${packageEntity.version}`; - -export const gradleGroovyInstalCommand = ({ packageEntity }) => { - const { - app_group: group = '', - app_name: name = '', - app_version: version = '', - } = packageEntity.maven_metadatum; - // eslint-disable-next-line @gitlab/require-i18n-strings - return `implementation '${group}:${name}:${version}'`; -}; - -export const gradleGroovyAddSourceCommand = ({ mavenPath }) => - // eslint-disable-next-line @gitlab/require-i18n-strings - `maven { - url '${mavenPath}' -}`; - -export const gradleKotlinInstalCommand = ({ packageEntity }) => { - const { - app_group: group = '', - app_name: name = '', - app_version: version = '', - } = packageEntity.maven_metadatum; - return `implementation("${group}:${name}:${version}")`; -}; - -export const gradleKotlinAddSourceCommand = ({ mavenPath }) => `maven("${mavenPath}")`; - -export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0; diff --git a/app/assets/javascripts/packages/details/utils.js b/app/assets/javascripts/packages/details/utils.js deleted file mode 100644 index 27cc95566d3..00000000000 --- a/app/assets/javascripts/packages/details/utils.js +++ /dev/null @@ -1,10 +0,0 @@ -import { TrackingActions } from './constants'; - -export const trackInstallationTabChange = { - methods: { - trackInstallationTabChange(tabIndex) { - const action = tabIndex === 0 ? TrackingActions.INSTALLATION : TrackingActions.REGISTRY_SETUP; - this.track(action, { label: this.trackingLabel }); - }, - }, -}; diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js index f15c31b85c1..c284b8358b4 100644 --- a/app/assets/javascripts/packages/shared/constants.js +++ b/app/assets/javascripts/packages/shared/constants.js @@ -1,5 +1,4 @@ -/* eslint-disable @gitlab/require-string-literal-i18n-helpers */ -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; export const PackageType = { CONAN: 'conan', @@ -38,7 +37,7 @@ export const DELETE_PACKAGE_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting the package.', ); export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( - __('PackageRegistry|Something went wrong while deleting the package file.'), + 'PackageRegistry|Something went wrong while deleting the package file.', ); export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( 'PackageRegistry|Package file deleted successfully', diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue new file mode 100644 index 00000000000..73fb3656af1 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -0,0 +1,105 @@ +<script> +import { GlAlert, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { + DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, + DEPENDENCY_PROXY_DOCS_PATH, +} from '~/packages_and_registries/settings/group/constants'; + +import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; + +export default { + components: { + GlFormGroup, + GlAlert, + GlFormInputGroup, + GlSprintf, + ClipboardButton, + TitleArea, + GlSkeletonLoader, + }, + inject: ['groupPath', 'dependencyProxyAvailable'], + i18n: { + proxyNotAvailableText: __('Dependency Proxy feature is limited to public groups for now.'), + proxyDisabledText: __('Dependency Proxy disabled. To enable it, contact the group owner.'), + proxyImagePrefix: __('Dependency Proxy image prefix'), + copyImagePrefixText: __('Copy prefix'), + blobCountAndSize: __('Contains %{count} blobs of images (%{size})'), + }, + data() { + return { + group: {}, + }; + }, + apollo: { + group: { + query: getDependencyProxyDetailsQuery, + skip() { + return !this.dependencyProxyAvailable; + }, + variables() { + return { fullPath: this.groupPath }; + }, + }, + }, + computed: { + infoMessages() { + return [ + { + text: DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, + link: DEPENDENCY_PROXY_DOCS_PATH, + }, + ]; + }, + dependencyProxyEnabled() { + return this.group?.dependencyProxySetting?.enabled; + }, + }, +}; +</script> + +<template> + <div> + <title-area :title="__('Dependency Proxy')" :info-messages="infoMessages" /> + <gl-alert + v-if="!dependencyProxyAvailable" + :dismissible="false" + data-testid="proxy-not-available" + > + {{ $options.i18n.proxyNotAvailableText }} + </gl-alert> + + <gl-skeleton-loader v-else-if="$apollo.queries.group.loading" /> + + <div v-else-if="dependencyProxyEnabled" data-testid="main-area"> + <gl-form-group :label="$options.i18n.proxyImagePrefix"> + <gl-form-input-group + readonly + :value="group.dependencyProxyImagePrefix" + class="gl-layout-w-limited" + data-testid="proxy-url" + > + <template #append> + <clipboard-button + :text="group.dependencyProxyImagePrefix" + :title="$options.i18n.copyImagePrefixText" + /> + </template> + </gl-form-input-group> + <template #description> + <span data-qa-selector="dependency_proxy_count" data-testid="proxy-count"> + <gl-sprintf :message="$options.i18n.blobCountAndSize"> + <template #count>{{ group.dependencyProxyBlobCount }}</template> + <template #size>{{ group.dependencyProxyTotalSize }}</template> + </gl-sprintf> + </span> + </template> + </gl-form-group> + </div> + <gl-alert v-else :dismissible="false" data-testid="proxy-disabled"> + {{ $options.i18n.proxyDisabledText }} + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js new file mode 100644 index 00000000000..16152eb81f6 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), +}); diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql new file mode 100644 index 00000000000..9058d349bf3 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql @@ -0,0 +1,10 @@ +query getDependencyProxyDetails($fullPath: ID!) { + group(fullPath: $fullPath) { + dependencyProxyBlobCount + dependencyProxyTotalSize + dependencyProxyImagePrefix + dependencyProxySetting { + enabled + } + } +} diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js new file mode 100644 index 00000000000..dc73470e07d --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import app from '~/packages_and_registries/dependency_proxy/app.vue'; +import { apolloProvider } from '~/packages_and_registries/dependency_proxy/graphql'; +import Translate from '~/vue_shared/translate'; + +Vue.use(Translate); + +export const initDependencyProxyApp = () => { + const el = document.getElementById('js-dependency-proxy'); + if (!el) { + return null; + } + const { dependencyProxyAvailable, ...dataset } = el.dataset; + return new Vue({ + el, + apolloProvider, + provide: { + dependencyProxyAvailable: parseBoolean(dependencyProxyAvailable), + ...dataset, + }, + render(createElement) { + return createElement(app); + }, + }); +}; diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue index 59da32e6666..6016757c1b9 100644 --- a/app/assets/javascripts/packages/details/components/app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue @@ -1,6 +1,5 @@ <script> import { - GlBadge, GlButton, GlModal, GlModalDirective, @@ -14,36 +13,30 @@ import { mapActions, mapState } from 'vuex'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; +import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue'; +import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue'; import Tracking from '~/tracking'; -import PackageListRow from '../../shared/components/package_list_row.vue'; -import PackagesListLoader from '../../shared/components/packages_list_loader.vue'; -import { PackageType, TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '../../shared/constants'; -import { packageTypeToTrackCategory } from '../../shared/utils'; -import AdditionalMetadata from './additional_metadata.vue'; -import DependencyRow from './dependency_row.vue'; -import InstallationCommands from './installation_commands.vue'; +import PackageListRow from '~/packages/shared/components/package_list_row.vue'; +import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import { TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; +import { packageTypeToTrackCategory } from '~/packages/shared/utils'; import PackageFiles from './package_files.vue'; import PackageHistory from './package_history.vue'; export default { name: 'PackagesApp', components: { - GlBadge, GlButton, GlEmptyState, GlModal, GlTab, GlTabs, GlSprintf, - PackageTitle: () => import('./package_title.vue'), - TerraformTitle: () => - import('~/packages_and_registries/infrastructure_registry/components/details_title.vue'), + TerraformTitle, PackagesListLoader, PackageListRow, - DependencyRow, PackageHistory, - AdditionalMetadata, - InstallationCommands, + TerraformInstallation, PackageFiles, }, directives: { @@ -51,12 +44,6 @@ export default { GlModal: GlModalDirective, }, mixins: [Tracking.mixin()], - inject: { - titleComponent: { - default: 'PackageTitle', - from: 'titleComponent', - }, - }, trackingActions: { ...TrackingActions }, data() { return { @@ -87,15 +74,6 @@ export default { hasVersions() { return this.packageEntity.versions?.length > 0; }, - packageDependencies() { - return this.packageEntity.dependency_links || []; - }, - showDependencies() { - return this.packageEntity.package_type === PackageType.NUGET; - }, - showFiles() { - return this.packageEntity?.package_type !== PackageType.COMPOSER; - }, }, methods: { ...mapActions(['deletePackage', 'fetchPackageVersions', 'deletePackageFile']), @@ -167,7 +145,7 @@ export default { /> <div v-else class="packages-app"> - <component :is="titleComponent"> + <terraform-title> <template #delete-button> <gl-button v-if="canDelete" @@ -180,24 +158,16 @@ export default { {{ __('Delete') }} </gl-button> </template> - </component> + </terraform-title> <gl-tabs> <gl-tab :title="__('Detail')"> <div data-qa-selector="package_information_content"> <package-history :package-entity="packageEntity" :project-name="projectName" /> - - <installation-commands - :package-entity="packageEntity" - :npm-path="npmPath" - :npm-help-path="npmHelpPath" - /> - - <additional-metadata :package-entity="packageEntity" /> + <terraform-installation /> </div> <package-files - v-if="showFiles" :package-files="packageFiles" :can-delete="canDelete" @download-file="track($options.trackingActions.PULL_PACKAGE)" @@ -205,27 +175,6 @@ export default { /> </gl-tab> - <gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab"> - <template #title> - <span>{{ __('Dependencies') }}</span> - <gl-badge size="sm" data-testid="dependencies-badge">{{ - packageDependencies.length - }}</gl-badge> - </template> - - <template v-if="packageDependencies.length > 0"> - <dependency-row - v-for="(dep, index) in packageDependencies" - :key="index" - :dependency="dep" - /> - </template> - - <p v-else class="gl-mt-3" data-testid="no-dependencies-message"> - {{ s__('PackageRegistry|This NuGet package has no dependencies.') }} - </p> - </gl-tab> - <gl-tab :title="__('Other versions')" title-item-class="js-versions-tab" @@ -254,7 +203,6 @@ export default { <gl-modal ref="deleteModal" - class="js-delete-modal" modal-id="delete-modal" :action-primary="$options.modal.packageDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue index 3e551706ed0..3e551706ed0 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue diff --git a/app/assets/javascripts/packages/details/components/file_sha.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/file_sha.vue index a25839be7e1..a25839be7e1 100644 --- a/app/assets/javascripts/packages/details/components/file_sha.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/file_sha.vue diff --git a/app/assets/javascripts/packages/details/components/package_files.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue index 0563b612d04..ab4cfccd023 100644 --- a/app/assets/javascripts/packages/details/components/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue @@ -3,10 +3,10 @@ import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@ import { last } from 'lodash'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; -import FileSha from '~/packages/details/components/file_sha.vue'; import Tracking from '~/tracking'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import FileSha from './file_sha.vue'; export default { name: 'PackageFiles', diff --git a/app/assets/javascripts/packages/details/components/package_history.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue index 27d2f208a42..e5be98b87f7 100644 --- a/app/assets/javascripts/packages/details/components/package_history.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue @@ -1,10 +1,9 @@ <script> -/* eslint-disable @gitlab/require-string-literal-i18n-helpers */ import { GlLink, GlSprintf } from '@gitlab/ui'; import { first } from 'lodash'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, n__ } from '~/locale'; -import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants'; +import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants'; import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -20,8 +19,6 @@ export default { combinedUpdateText: s__( 'PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}', ), - archivedPipelineMessageSingular: s__('PackageRegistry|Package has %{number} archived update'), - archivedPipelineMessagePlural: s__('PackageRegistry|Package has %{number} archived updates'), }, components: { GlLink, @@ -57,14 +54,14 @@ export default { showPipelinesInfo() { return Boolean(this.firstPipeline?.id); }, - archiviedLines() { + archivedLines() { return Math.max(this.pipelines.length - HISTORY_PIPELINES_LIMIT - 1, 0); }, archivedPipelineMessage() { return n__( - this.$options.i18n.archivedPipelineMessageSingular, - this.$options.i18n.archivedPipelineMessagePlural, - this.archiviedLines, + 'PackageRegistry|Package has %{updatesCount} archived update', + 'PackageRegistry|Package has %{updatesCount} archived updates', + this.archivedLines, ); }, }, @@ -133,10 +130,10 @@ export default { </gl-sprintf> </history-item> - <history-item v-if="archiviedLines" icon="history" data-testid="archived"> + <history-item v-if="archivedLines" icon="history" data-testid="archived"> <gl-sprintf :message="archivedPipelineMessage"> - <template #number> - <strong>{{ archiviedLines }}</strong> + <template #updatesCount> + <strong>{{ archivedLines }}</strong> </template> </gl-sprintf> </history-item> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue index c62bf7fb722..c62bf7fb722 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js new file mode 100644 index 00000000000..c0c67faffba --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js @@ -0,0 +1,5 @@ +import { s__ } from '~/locale'; + +export const FETCH_PACKAGE_VERSIONS_ERROR = s__( + 'PackageRegistry|Unable to fetch package version information.', +); diff --git a/app/assets/javascripts/packages/details/store/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js index a03fa8d9d63..a03fa8d9d63 100644 --- a/app/assets/javascripts/packages/details/store/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js new file mode 100644 index 00000000000..6a17e7aa6d6 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js @@ -0,0 +1,3 @@ +export const packagePipeline = ({ packageEntity }) => { + return packageEntity?.pipeline || null; +}; diff --git a/app/assets/javascripts/packages/details/store/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js index 15e17bcfaac..15e17bcfaac 100644 --- a/app/assets/javascripts/packages/details/store/index.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js diff --git a/app/assets/javascripts/packages/details/store/mutation_types.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutation_types.js index 590f2d9f970..590f2d9f970 100644 --- a/app/assets/javascripts/packages/details/store/mutation_types.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutation_types.js diff --git a/app/assets/javascripts/packages/details/store/mutations.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutations.js index 762fd5a4040..762fd5a4040 100644 --- a/app/assets/javascripts/packages/details/store/mutations.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutations.js diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js index 98942b1e578..32fbc9382fd 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; -import PackagesApp from '~/packages/details/components/app.vue'; -import createStore from '~/packages/details/store'; +import PackagesApp from '~/packages_and_registries/infrastructure_registry/details/components/app.vue'; +import createStore from '~/packages_and_registries/infrastructure_registry/details/store'; import Translate from '~/vue_shared/translate'; Vue.use(Translate); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue index f0da7db6c91..1360b03856f 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue @@ -24,7 +24,13 @@ export default { <template> <div> - <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source"> + <details-row + v-if="packageEntity.metadata.projectUrl" + icon="project" + padding="gl-p-4" + dashed + data-testid="nuget-source" + > <gl-sprintf :message="$options.i18n.sourceText"> <template #link> <gl-link :href="packageEntity.metadata.projectUrl" target="_blank">{{ @@ -33,7 +39,12 @@ export default { </template> </gl-sprintf> </details-row> - <details-row icon="license" padding="gl-p-4" data-testid="nuget-license"> + <details-row + v-if="packageEntity.metadata.licenseUrl" + icon="license" + padding="gl-p-4" + data-testid="nuget-license" + > <gl-sprintf :message="$options.i18n.licenseText"> <template #link> <gl-link :href="packageEntity.metadata.licenseUrl" target="_blank">{{ 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 47081e23318..2448324549e 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 @@ -1,5 +1,5 @@ <script> -import { GlLink, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlSprintf, GlFormRadioGroup } from '@gitlab/ui'; import { s__ } from '~/locale'; import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; @@ -11,6 +11,8 @@ import { TRACKING_LABEL_CODE_INSTRUCTION, NPM_PACKAGE_MANAGER, YARN_PACKAGE_MANAGER, + PROJECT_PACKAGE_ENDPOINT_TYPE, + INSTANCE_PACKAGE_ENDPOINT_TYPE, } from '~/packages_and_registries/package_registry/constants'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; @@ -21,8 +23,9 @@ export default { CodeInstruction, GlLink, GlSprintf, + GlFormRadioGroup, }, - inject: ['npmHelpPath', 'npmPath'], + inject: ['npmHelpPath', 'npmPath', 'npmProjectPath'], props: { packageEntity: { type: Object, @@ -32,6 +35,7 @@ export default { data() { return { instructionType: NPM_PACKAGE_MANAGER, + packageEndpointType: INSTANCE_PACKAGE_ENDPOINT_TYPE, }; }, computed: { @@ -39,13 +43,13 @@ export default { return this.npmInstallationCommand(NPM_PACKAGE_MANAGER); }, npmSetup() { - return this.npmSetupCommand(NPM_PACKAGE_MANAGER); + return this.npmSetupCommand(NPM_PACKAGE_MANAGER, this.packageEndpointType); }, yarnCommand() { return this.npmInstallationCommand(YARN_PACKAGE_MANAGER); }, yarnSetupCommand() { - return this.npmSetupCommand(YARN_PACKAGE_MANAGER); + return this.npmSetupCommand(YARN_PACKAGE_MANAGER, this.packageEndpointType); }, showNpm() { return this.instructionType === NPM_PACKAGE_MANAGER; @@ -58,14 +62,16 @@ export default { return `${instruction} ${this.packageEntity.name}`; }, - npmSetupCommand(type) { + 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; if (type === NPM_PACKAGE_MANAGER) { - return `echo ${scope}:registry=${this.npmPath}/ >> .npmrc`; + return `echo ${scope}:registry=${npmPathForEndpoint}/ >> .npmrc`; } - return `echo \\"${scope}:registry\\" \\"${this.npmPath}/\\" >> .yarnrc`; + return `echo \\"${scope}:registry\\" \\"${npmPathForEndpoint}/\\" >> .yarnrc`; }, }, packageManagers: { @@ -87,6 +93,10 @@ export default { { value: NPM_PACKAGE_MANAGER, label: s__('PackageRegistry|Show NPM commands') }, { value: YARN_PACKAGE_MANAGER, label: s__('PackageRegistry|Show Yarn commands') }, ], + packageEndpointTypeOptions: [ + { value: INSTANCE_PACKAGE_ENDPOINT_TYPE, text: s__('PackageRegistry|Instance-level') }, + { value: PROJECT_PACKAGE_ENDPOINT_TYPE, text: s__('PackageRegistry|Project-level') }, + ], }; </script> @@ -116,6 +126,12 @@ export default { <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + <gl-form-radio-group + :options="$options.packageEndpointTypeOptions" + :checked="packageEndpointType" + @change="packageEndpointType = $event" + /> + <code-instruction v-if="showNpm" :instruction="npmSetup" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue index 408bd2e3dfe..af6bd7079ba 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue @@ -1,11 +1,10 @@ <script> -/* eslint-disable @gitlab/require-string-literal-i18n-helpers */ import { GlLink, GlSprintf } from '@gitlab/ui'; import { first } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, n__ } from '~/locale'; -import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants'; +import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants'; import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -21,8 +20,6 @@ export default { combinedUpdateText: s__( 'PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}', ), - archivedPipelineMessageSingular: s__('PackageRegistry|Package has %{number} archived update'), - archivedPipelineMessagePlural: s__('PackageRegistry|Package has %{number} archived updates'), }, components: { GlLink, @@ -58,14 +55,14 @@ export default { showPipelinesInfo() { return Boolean(this.firstPipeline?.id); }, - archiviedLines() { + archivedLines() { return Math.max(this.pipelines.length - HISTORY_PIPELINES_LIMIT - 1, 0); }, archivedPipelineMessage() { return n__( - this.$options.i18n.archivedPipelineMessageSingular, - this.$options.i18n.archivedPipelineMessagePlural, - this.archiviedLines, + 'PackageRegistry|Package has %{updatesCount} archived update', + 'PackageRegistry|Package has %{updatesCount} archived updates', + this.archivedLines, ); }, }, @@ -135,10 +132,10 @@ export default { </gl-sprintf> </history-item> - <history-item v-if="archiviedLines" icon="history" data-testid="archived"> + <history-item v-if="archivedLines" icon="history" data-testid="archived"> <gl-sprintf :message="archivedPipelineMessage"> - <template #number> - <strong>{{ archiviedLines }}</strong> + <template #updatesCount> + <strong>{{ archivedLines }}</strong> </template> </gl-sprintf> </history-item> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue new file mode 100644 index 00000000000..08481ac5655 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue @@ -0,0 +1,134 @@ +<script> +/* + * The following component has several commented lines, this is because we are refactoring them piece by piece on several mrs + * For a complete overview of the plan please check: https://gitlab.com/gitlab-org/gitlab/-/issues/330846 + * This work is behind feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/341136 + */ +// import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { historyReplaceState } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; +import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; +import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; +import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; +import { + PROJECT_RESOURCE_TYPE, + GROUP_RESOURCE_TYPE, + LIST_QUERY_DEBOUNCE_TIME, +} from '~/packages_and_registries/package_registry/constants'; +import PackageTitle from './package_title.vue'; +import PackageSearch from './package_search.vue'; +// import PackageList from './packages_list.vue'; + +export default { + components: { + // GlEmptyState, + // GlLink, + // GlSprintf, + // PackageList, + PackageTitle, + PackageSearch, + }, + inject: [ + 'packageHelpUrl', + 'emptyListIllustration', + 'emptyListHelpUrl', + 'isGroupPage', + 'fullPath', + ], + data() { + return { + packages: {}, + sort: '', + filters: {}, + }; + }, + apollo: { + packages: { + query: getPackagesQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data[this.graphqlResource].packages; + }, + debounce: LIST_QUERY_DEBOUNCE_TIME, + }, + }, + computed: { + queryVariables() { + return { + isGroupPage: this.isGroupPage, + fullPath: this.fullPath, + sort: this.isGroupPage ? undefined : this.sort, + groupSort: this.isGroupPage ? this.sort : undefined, + packageName: this.filters?.packageName, + packageType: this.filters?.packageType, + }; + }, + graphqlResource() { + return this.isGroupPage ? GROUP_RESOURCE_TYPE : PROJECT_RESOURCE_TYPE; + }, + packagesCount() { + return this.packages?.count; + }, + hasFilters() { + return this.filters.packageName && this.filters.packageType; + }, + emptyStateTitle() { + return this.emptySearch + ? this.$options.i18n.emptyPageTitle + : this.$options.i18n.noResultsTitle; + }, + }, + mounted() { + this.checkDeleteAlert(); + }, + methods: { + checkDeleteAlert() { + const urlParams = new URLSearchParams(window.location.search); + const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT); + if (showAlert) { + // to be refactored to use gl-alert + createFlash({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, type: 'notice' }); + const cleanUrl = window.location.href.split('?')[0]; + historyReplaceState(cleanUrl); + } + }, + handleSearchUpdate({ sort, filters }) { + this.sort = sort; + this.filters = { ...filters }; + }, + }, + i18n: { + widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'), + emptyPageTitle: s__('PackageRegistry|There are no packages yet'), + noResultsTitle: s__('PackageRegistry|Sorry, your filter produced no results'), + noResultsText: s__( + 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', + ), + }, +}; +</script> + +<template> + <div> + <package-title :help-url="packageHelpUrl" :count="packagesCount" /> + <package-search @update="handleSearchUpdate" /> + + <!-- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> + <template #empty-state> + <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration"> + <template #description> + <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" /> + <gl-sprintf v-else :message="$options.i18n.noResultsText"> + <template #noPackagesLink="{ content }"> + <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-empty-state> + </template> + </package-list> --> + </div> +</template> 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 new file mode 100644 index 00000000000..195ff7af583 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -0,0 +1,151 @@ +<script> +import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { + PACKAGE_ERROR_STATUS, + PACKAGE_DEFAULT_STATUS, +} from '~/packages_and_registries/package_registry/constants'; +import { getPackageTypeLabel } from '~/packages/shared/utils'; +import PackagePath from '~/packages/shared/components/package_path.vue'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue'; +import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'PackageListRow', + components: { + GlButton, + GlLink, + GlSprintf, + GlTruncate, + PackageTags, + PackagePath, + PublishMethod, + ListItem, + PackageIconAndName, + TimeagoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['isGroupPage'], + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + computed: { + packageType() { + return getPackageTypeLabel(this.packageEntity.packageType.toLowerCase()); + }, + packageLink() { + const { project, id } = this.packageEntity; + return `${project?.webUrl}/-/packages/${getIdFromGraphQLId(id)}`; + }, + pipeline() { + return this.packageEntity?.pipelines?.nodes[0]; + }, + pipelineUser() { + return this.pipeline?.user?.name; + }, + showWarningIcon() { + return this.packageEntity.status === PACKAGE_ERROR_STATUS; + }, + showTags() { + return Boolean(this.packageEntity.tags?.nodes?.length); + }, + disabledRow() { + return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS; + }, + }, + i18n: { + erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'), + }, +}; +</script> + +<template> + <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" + class="gl-text-body gl-min-w-0" + data-qa-selector="package_link" + :disabled="disabledRow" + > + <gl-truncate :text="packageEntity.name" /> + </gl-link> + + <gl-button + v-if="showWarningIcon" + v-gl-tooltip="{ title: $options.i18n.erroredPackageText }" + class="gl-hover-bg-transparent!" + icon="warning" + category="tertiary" + data-testid="warning-icon" + :aria-label="__('Warning')" + /> + + <package-tags + v-if="showTags" + class="gl-ml-3" + :tags="packageEntity.tags.nodes" + hide-label + :tag-display-limit="1" + /> + </div> + </template> + <template #left-secondary> + <div class="gl-display-flex" data-testid="left-secondary-infos"> + <span>{{ packageEntity.version }}</span> + + <div v-if="pipelineUser" class="gl-display-none gl-sm-display-flex gl-ml-2"> + <gl-sprintf :message="s__('PackageRegistry|published by %{author}')"> + <template #author>{{ pipelineUser }}</template> + </gl-sprintf> + </div> + + <package-icon-and-name> + {{ packageType }} + </package-icon-and-name> + + <package-path + v-if="isGroupPage" + :path="packageEntity.project.fullPath" + :disabled="disabledRow" + /> + </div> + </template> + + <template #right-primary> + <publish-method :pipeline="pipeline" /> + </template> + + <template #right-secondary> + <span> + <gl-sprintf :message="__('Created %{timestamp}')"> + <template #timestamp> + <timeago-tooltip :time="packageEntity.createdAt" /> + </template> + </gl-sprintf> + </span> + </template> + + <template v-if="!disabledRow" #right-action> + <gl-button + data-testid="action-delete" + icon="remove" + category="secondary" + variant="danger" + :title="s__('PackageRegistry|Remove package')" + :aria-label="s__('PackageRegistry|Remove package')" + @click="$emit('packageToDelete', packageEntity)" + /> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue index 280d292ce0b..836df59ca58 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue @@ -1,10 +1,14 @@ <script> -import { mapState, mapActions } from 'vuex'; import { s__ } from '~/locale'; import { sortableFields } from '~/packages/list/utils'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; +import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; +import { + FILTERED_SEARCH_TERM, + FILTERED_SEARCH_TYPE, +} from '~/packages_and_registries/shared/constants'; import PackageTypeToken from './tokens/package_type_token.vue'; export default { @@ -19,21 +23,71 @@ export default { }, ], components: { RegistrySearch, UrlSync }, + inject: ['isGroupPage'], + data() { + return { + filters: [], + sorting: { + orderBy: 'name', + sort: 'desc', + }, + mountRegistrySearch: false, + }; + }, computed: { - ...mapState({ - isGroupPage: (state) => state.config.isGroupPage, - sorting: (state) => state.sorting, - filter: (state) => state.filter, - }), sortableFields() { return sortableFields(this.isGroupPage); }, + parsedSorting() { + const cleanOrderBy = this.sorting?.orderBy.replace('_at', ''); + return `${cleanOrderBy}_${this.sorting?.sort}`.toUpperCase(); + }, + parsedFilters() { + const parsed = { + packageName: '', + packageType: undefined, + }; + + return this.filters.reduce((acc, filter) => { + if (filter.type === FILTERED_SEARCH_TYPE && filter.value?.data) { + return { + ...acc, + packageType: filter.value.data.toUpperCase(), + }; + } + + if (filter.type === FILTERED_SEARCH_TERM) { + return { + ...acc, + packageName: `${acc.packageName} ${filter.value.data}`.trim(), + }; + } + + return acc; + }, parsed); + }, + }, + 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: { - ...mapActions(['setSorting', 'setFilter']), + updateFilters(newValue) { + this.filters = newValue; + }, updateSorting(newValue) { - this.setSorting(newValue); - this.$emit('update'); + this.sorting = { ...this.sorting, ...newValue }; + }, + updateSortingAndEmitUpdate(newValue) { + this.updateSorting(newValue); + this.emitUpdate(); + }, + emitUpdate() { + this.$emit('update', { sort: this.parsedSorting, filters: this.parsedFilters }); }, }, }; @@ -43,13 +97,14 @@ export default { <url-sync> <template #default="{ updateQuery }"> <registry-search - :filter="filter" + v-if="mountRegistrySearch" + :filter="filters" :sorting="sorting" :tokens="$options.tokens" :sortable-fields="sortableFields" - @sorting:changed="updateSorting" - @filter:changed="setFilter" - @filter:submit="$emit('update')" + @sorting:changed="updateSortingAndEmitUpdate" + @filter:changed="updateFilters" + @filter:submit="emitUpdate" @query:changed="updateQuery" /> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue deleted file mode 100644 index 75fbdb80192..00000000000 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue +++ /dev/null @@ -1,132 +0,0 @@ -<script> -import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; -import createFlash from '~/flash'; -import { historyReplaceState } from '~/lib/utils/common_utils'; -import { s__ } from '~/locale'; -import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; -import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; -import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; -import PackageList from './packages_list.vue'; - -export default { - components: { - GlEmptyState, - GlLink, - GlSprintf, - PackageList, - PackageTitle: () => - import(/* webpackChunkName: 'package_registry_components' */ './package_title.vue'), - PackageSearch: () => - import(/* webpackChunkName: 'package_registry_components' */ './package_search.vue'), - InfrastructureTitle: () => - import( - /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue' - ), - InfrastructureSearch: () => - import( - /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue' - ), - }, - inject: { - titleComponent: { - from: 'titleComponent', - default: 'PackageTitle', - }, - searchComponent: { - from: 'searchComponent', - default: 'PackageSearch', - }, - emptyPageTitle: { - from: 'emptyPageTitle', - default: s__('PackageRegistry|There are no packages yet'), - }, - noResultsText: { - from: 'noResultsText', - default: s__( - 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', - ), - }, - }, - computed: { - ...mapState({ - emptyListIllustration: (state) => state.config.emptyListIllustration, - emptyListHelpUrl: (state) => state.config.emptyListHelpUrl, - filter: (state) => state.filter, - selectedType: (state) => state.selectedType, - packageHelpUrl: (state) => state.config.packageHelpUrl, - packagesCount: (state) => state.pagination?.total, - }), - emptySearch() { - return ( - this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0 - ); - }, - - emptyStateTitle() { - return this.emptySearch - ? this.emptyPageTitle - : s__('PackageRegistry|Sorry, your filter produced no results'); - }, - }, - mounted() { - const queryParams = getQueryParams(window.document.location.search); - const { sorting, filters } = extractFilterAndSorting(queryParams); - this.setSorting(sorting); - this.setFilter(filters); - this.requestPackagesList(); - this.checkDeleteAlert(); - }, - methods: { - ...mapActions([ - 'requestPackagesList', - 'requestDeletePackage', - 'setSelectedType', - 'setSorting', - 'setFilter', - ]), - onPageChanged(page) { - return this.requestPackagesList({ page }); - }, - onPackageDeleteRequest(item) { - return this.requestDeletePackage(item); - }, - checkDeleteAlert() { - const urlParams = new URLSearchParams(window.location.search); - const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT); - if (showAlert) { - // to be refactored to use gl-alert - createFlash({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, type: 'notice' }); - const cleanUrl = window.location.href.split('?')[0]; - historyReplaceState(cleanUrl); - } - }, - }, - i18n: { - widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'), - }, -}; -</script> - -<template> - <div> - <component :is="titleComponent" :help-url="packageHelpUrl" :count="packagesCount" /> - <component :is="searchComponent" @update="requestPackagesList" /> - - <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> - <template #empty-state> - <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration"> - <template #description> - <gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" /> - <gl-sprintf v-else :message="noResultsText"> - <template #noPackagesLink="{ content }"> - <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </template> - </gl-empty-state> - </template> - </package-list> - </div> -</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue new file mode 100644 index 00000000000..8ecf433f3ab --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue @@ -0,0 +1,61 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + name: 'PublishMethod', + components: { + ClipboardButton, + GlIcon, + GlLink, + }, + props: { + pipeline: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + hasPipeline() { + return Boolean(this.pipeline); + }, + packageShaShort() { + return this.pipeline?.sha?.substring(0, 8); + }, + }, + i18n: { + COPY_COMMIT_SHA: __('Copy commit SHA'), + MANUALLY_PUBLISHED: s__('PackageRegistry|Manually Published'), + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <template v-if="hasPipeline"> + <gl-icon name="git-merge" class="gl-mr-2" /> + <span data-testid="pipeline-ref" class="gl-mr-2">{{ pipeline.ref }}</span> + + <gl-icon name="commit" class="gl-mr-2" /> + <gl-link data-testid="pipeline-sha" :href="pipeline.commitPath" class="gl-mr-2">{{ + packageShaShort + }}</gl-link> + + <clipboard-button + :text="pipeline.sha" + :title="$options.i18n.COPY_COMMIT_SHA" + category="tertiary" + size="small" + /> + </template> + + <template v-else> + <gl-icon name="upload" class="gl-mr-2" /> + <span data-testid="manually-published"> + {{ $options.i18n.MANUALLY_PUBLISHED }} + </span> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index f023b4481a0..6a88880fa90 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -1,5 +1,4 @@ -/* eslint-disable @gitlab/require-string-literal-i18n-helpers */ -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; export const PACKAGE_TYPE_CONAN = 'CONAN'; export const PACKAGE_TYPE_MAVEN = 'MAVEN'; @@ -71,7 +70,7 @@ export const DELETE_PACKAGE_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting the package.', ); export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( - __('PackageRegistry|Something went wrong while deleting the package file.'), + 'PackageRegistry|Something went wrong while deleting the package file.', ); export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( 'PackageRegistry|Package file deleted successfully', @@ -87,3 +86,10 @@ export const PACKAGE_PROCESSING_STATUS = 'PROCESSING'; export const NPM_PACKAGE_MANAGER = 'npm'; export const YARN_PACKAGE_MANAGER = 'yarn'; + +export const PROJECT_PACKAGE_ENDPOINT_TYPE = 'project'; +export const INSTANCE_PACKAGE_ENDPOINT_TYPE = 'instance'; + +export const PROJECT_RESOURCE_TYPE = 'project'; +export const GROUP_RESOURCE_TYPE = 'group'; +export const LIST_QUERY_DEBOUNCE_TIME = 50; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql new file mode 100644 index 00000000000..aaf0eb54aff --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql @@ -0,0 +1,27 @@ +fragment PackageData on Package { + id + name + version + packageType + createdAt + status + tags { + nodes { + name + } + } + pipelines { + nodes { + sha + ref + commitPath + user { + name + } + } + } + project { + fullPath + webUrl + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql new file mode 100644 index 00000000000..74e6de87866 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql @@ -0,0 +1,27 @@ +#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql" + +query getPackages( + $fullPath: ID! + $isGroupPage: Boolean! + $sort: PackageSort + $groupSort: PackageGroupSort + $packageName: String + $packageType: PackageTypeEnum +) { + project(fullPath: $fullPath) @skip(if: $isGroupPage) { + packages(sort: $sort, packageName: $packageName, packageType: $packageType) { + count + nodes { + ...PackageData + } + } + } + group(fullPath: $fullPath) @include(if: $isGroupPage) { + packages(sort: $groupSort, packageName: $packageName, packageType: $packageType) { + count + nodes { + ...PackageData + } + } + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js index 1e01b75aabc..d797a0a5327 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js @@ -1,14 +1,22 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import PackagesListApp from '../components/list/packages_list_app.vue'; +import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index'; +import PackagesListApp from '../components/list/app.vue'; Vue.use(Translate); export default () => { const el = document.getElementById('js-vue-packages-list'); + const isGroupPage = el.dataset.pageType === 'groups'; + return new Vue({ el, + apolloProvider, + provide: { + ...el.dataset, + isGroupPage, + }, render(createElement) { return createElement(PackagesListApp); }, diff --git a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js index 5cd8261ac23..9b5a0d221b8 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js @@ -19,6 +19,7 @@ export default () => { apolloProvider, provide: { defaultExpanded: parseBoolean(el.dataset.defaultExpanded), + dependencyProxyAvailable: parseBoolean(el.dataset.dependencyProxyAvailable), groupPath: el.dataset.groupPath, }, render(createElement) { diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue new file mode 100644 index 00000000000..2dbe36def0e --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue @@ -0,0 +1,110 @@ +<script> +import { GlToggle, GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql'; +import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update'; +import { updateGroupDependencyProxySettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; + +import { + DEPENDENCY_PROXY_HEADER, + DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, + DEPENDENCY_PROXY_DOCS_PATH, +} from '~/packages_and_registries/settings/group/constants'; + +export default { + name: 'DependencyProxySettings', + components: { + GlToggle, + GlSprintf, + GlLink, + SettingsBlock, + }, + i18n: { + DEPENDENCY_PROXY_HEADER, + DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, + label: s__('DependencyProxy|Enable Proxy'), + }, + links: { + DEPENDENCY_PROXY_DOCS_PATH, + }, + inject: ['defaultExpanded', 'groupPath'], + props: { + dependencyProxySettings: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + enabled: { + get() { + return this.dependencyProxySettings.enabled; + }, + set(enabled) { + this.updateSettings({ enabled }); + }, + }, + }, + methods: { + async updateSettings(payload) { + try { + const { data } = await this.$apollo.mutate({ + mutation: updateDependencyProxySettings, + variables: { + input: { + groupPath: this.groupPath, + ...payload, + }, + }, + update: updateGroupPackageSettings(this.groupPath), + optimisticResponse: updateGroupDependencyProxySettingsOptimisticResponse({ + ...this.dependencyProxySettings, + ...payload, + }), + }); + + if (data.updateDependencyProxySettings?.errors?.length > 0) { + throw new Error(); + } else { + this.$emit('success'); + } + } catch { + this.$emit('error'); + } + }, + }, +}; +</script> + +<template> + <settings-block + :default-expanded="defaultExpanded" + data-qa-selector="dependency_proxy_settings_content" + > + <template #title> {{ $options.i18n.DEPENDENCY_PROXY_HEADER }} </template> + <template #description> + <span data-testid="description"> + <gl-sprintf :message="$options.i18n.DEPENDENCY_PROXY_SETTINGS_DESCRIPTION"> + <template #docLink="{ content }"> + <gl-link :href="$options.links.DEPENDENCY_PROXY_DOCS_PATH">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </template> + <template #default> + <div> + <gl-toggle + v-model="enabled" + :disabled="isLoading" + :label="$options.i18n.label" + data-qa-selector="dependency_proxy_setting_toggle" + /> + </div> + </template> + </settings-block> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue index d66a30e7e81..b0088838acc 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue @@ -86,6 +86,7 @@ export default { :label="$options.i18n.DUPLICATES_TOGGLE_LABEL" label-position="hidden" :value="duplicatesAllowed" + :disabled="loading" @change="update(modelNames.allowed, $event)" /> <div class="gl-ml-5"> @@ -108,6 +109,7 @@ export default { > <gl-form-input id="maven-duplicated-settings-regex-input" + :disabled="loading" :value="duplicateExceptionRegex" @change="update(modelNames.exception, $event)" /> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index ec3be43196c..b45cedcdd66 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -1,108 +1,66 @@ <script> -import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; -import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; -import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; -import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; -import { - PACKAGE_SETTINGS_HEADER, - PACKAGE_SETTINGS_DESCRIPTION, - PACKAGES_DOCS_PATH, - ERROR_UPDATING_SETTINGS, - SUCCESS_UPDATING_SETTINGS, -} from '~/packages_and_registries/settings/group/constants'; -import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; +import { GlAlert } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue'; +import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; + import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; -import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update'; -import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; -import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; export default { name: 'GroupSettingsApp', - i18n: { - PACKAGE_SETTINGS_HEADER, - PACKAGE_SETTINGS_DESCRIPTION, - }, - links: { - PACKAGES_DOCS_PATH, - }, components: { GlAlert, - GlSprintf, - GlLink, - SettingsBlock, - MavenSettings, - GenericSettings, - DuplicatesSettings, + PackagesSettings, + DependencyProxySettings, }, - inject: ['defaultExpanded', 'groupPath'], + inject: ['groupPath', 'dependencyProxyAvailable'], apollo: { - packageSettings: { + group: { query: getGroupPackagesSettingsQuery, variables() { return { fullPath: this.groupPath, }; }, - update(data) { - return data.group?.packageSettings; - }, }, }, data() { return { - packageSettings: {}, - errors: {}, + group: {}, alertMessage: null, }; }, computed: { + packageSettings() { + return this.group?.packageSettings || {}; + }, + dependencyProxySettings() { + return this.group?.dependencyProxySetting || {}; + }, isLoading() { - return this.$apollo.queries.packageSettings.loading; + return this.$apollo.queries.group.loading; }, }, methods: { dismissAlert() { this.alertMessage = null; }, - updateSettings(payload) { - this.errors = {}; - return this.$apollo - .mutate({ - mutation: updateNamespacePackageSettings, - variables: { - input: { - namespacePath: this.groupPath, - ...payload, - }, - }, - update: updateGroupPackageSettings(this.groupPath), - optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({ - ...this.packageSettings, - ...payload, - }), - }) - .then(({ data }) => { - if (data.updateNamespacePackageSettings?.errors?.length > 0) { - this.alertMessage = ERROR_UPDATING_SETTINGS; - } else { - this.dismissAlert(); - this.$toast.show(SUCCESS_UPDATING_SETTINGS); - } - }) - .catch((e) => { - if (e.graphQLErrors) { - e.graphQLErrors.forEach((error) => { - const [ - { - path: [key], - message, - }, - ] = error.extensions.problems; - this.errors = { ...this.errors, [key]: message }; - }); - } - this.alertMessage = ERROR_UPDATING_SETTINGS; - }); + handleSuccess(amount = 1) { + const successMessage = n__( + 'Setting saved successfully', + 'Settings saved successfully', + amount, + ); + this.$toast.show(successMessage); + this.dismissAlert(); + }, + handleError(amount = 1) { + const errorMessage = n__( + 'An error occurred while saving the setting', + 'An error occurred while saving the settings', + amount, + ); + this.alertMessage = errorMessage; }, }, }; @@ -114,50 +72,19 @@ export default { {{ alertMessage }} </gl-alert> - <settings-block - :default-expanded="defaultExpanded" - data-qa-selector="package_registry_settings_content" - > - <template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template> - <template #description> - <span data-testid="description"> - <gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION"> - <template #link="{ content }"> - <gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </span> - </template> - <template #default> - <maven-settings data-testid="maven-settings"> - <template #default="{ modelNames }"> - <duplicates-settings - :duplicates-allowed="packageSettings.mavenDuplicatesAllowed" - :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex" - :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex" - :model-names="modelNames" - :loading="isLoading" - toggle-qa-selector="allow_duplicates_toggle" - label-qa-selector="allow_duplicates_label" - @update="updateSettings" - /> - </template> - </maven-settings> - <generic-settings class="gl-mt-6" data-testid="generic-settings"> - <template #default="{ modelNames }"> - <duplicates-settings - :duplicates-allowed="packageSettings.genericDuplicatesAllowed" - :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex" - :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex" - :model-names="modelNames" - :loading="isLoading" - @update="updateSettings" - /> - </template> - </generic-settings> - </template> - </settings-block> + <packages-settings + :package-settings="packageSettings" + :is-loading="isLoading" + @success="handleSuccess(2)" + @error="handleError(2)" + /> + + <dependency-proxy-settings + v-if="dependencyProxyAvailable" + :dependency-proxy-settings="dependencyProxySettings" + :is-loading="isLoading" + @success="handleSuccess" + @error="handleError" + /> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue new file mode 100644 index 00000000000..b7e88945dbd --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue @@ -0,0 +1,139 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; +import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; +import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; +import { + PACKAGE_SETTINGS_HEADER, + PACKAGE_SETTINGS_DESCRIPTION, + PACKAGES_DOCS_PATH, +} from '~/packages_and_registries/settings/group/constants'; +import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; +import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update'; +import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; + +export default { + name: 'PackageSettings', + i18n: { + PACKAGE_SETTINGS_HEADER, + PACKAGE_SETTINGS_DESCRIPTION, + }, + links: { + PACKAGES_DOCS_PATH, + }, + components: { + GlSprintf, + GlLink, + SettingsBlock, + MavenSettings, + GenericSettings, + DuplicatesSettings, + }, + inject: ['defaultExpanded', 'groupPath'], + props: { + packageSettings: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + errors: {}, + }; + }, + methods: { + async updateSettings(payload) { + this.errors = {}; + try { + const { data } = await this.$apollo.mutate({ + mutation: updateNamespacePackageSettings, + variables: { + input: { + namespacePath: this.groupPath, + ...payload, + }, + }, + update: updateGroupPackageSettings(this.groupPath), + optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({ + ...this.packageSettings, + ...payload, + }), + }); + + if (data.updateNamespacePackageSettings?.errors?.length > 0) { + throw new Error(); + } else { + this.$emit('success'); + } + } catch (e) { + if (e.graphQLErrors) { + e.graphQLErrors.forEach((error) => { + const [ + { + path: [key], + message, + }, + ] = error.extensions.problems; + this.errors = { ...this.errors, [key]: message }; + }); + } + this.$emit('error'); + } + }, + }, +}; +</script> + +<template> + <settings-block + :default-expanded="defaultExpanded" + data-qa-selector="package_registry_settings_content" + > + <template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template> + <template #description> + <span data-testid="description"> + <gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION"> + <template #link="{ content }"> + <gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </span> + </template> + <template #default> + <maven-settings data-testid="maven-settings"> + <template #default="{ modelNames }"> + <duplicates-settings + :duplicates-allowed="packageSettings.mavenDuplicatesAllowed" + :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex" + :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex" + :model-names="modelNames" + :loading="isLoading" + toggle-qa-selector="allow_duplicates_toggle" + label-qa-selector="allow_duplicates_label" + @update="updateSettings" + /> + </template> + </maven-settings> + <generic-settings class="gl-mt-6" data-testid="generic-settings"> + <template #default="{ modelNames }"> + <duplicates-settings + :duplicates-allowed="packageSettings.genericDuplicatesAllowed" + :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex" + :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex" + :model-names="modelNames" + :loading="isLoading" + @update="updateSettings" + /> + </template> + </generic-settings> + </template> + </settings-block> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js index d29489a0b33..ee922457993 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -18,9 +18,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( 'PackageRegistry|Publish packages if their name or version matches this regex.', ); -export const SUCCESS_UPDATING_SETTINGS = s__('PackageRegistry|Settings saved successfully'); -export const ERROR_UPDATING_SETTINGS = s__( - 'PackageRegistry|An error occurred while saving the settings', +export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy'); +export const DEPENDENCY_PROXY_SETTINGS_DESCRIPTION = s__( + 'DependencyProxy|Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies.', ); // Parameters @@ -28,3 +28,5 @@ export const ERROR_UPDATING_SETTINGS = s__( export const PACKAGES_DOCS_PATH = helpPagePath('user/packages'); export const MAVEN_DUPLICATES_ALLOWED = 'mavenDuplicatesAllowed'; export const MAVEN_DUPLICATE_EXCEPTION_REGEX = 'mavenDuplicateExceptionRegex'; + +export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index'); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql new file mode 100644 index 00000000000..d24a645fecb --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql @@ -0,0 +1,8 @@ +mutation updateDependencyProxySettings($input: UpdateDependencyProxySettingsInput!) { + updateDependencyProxySettings(input: $input) { + dependencyProxySetting { + enabled + } + errors + } +} diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql index a1c01300893..d3edebfbe20 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql @@ -1,5 +1,8 @@ query getGroupPackagesSettings($fullPath: ID!) { group(fullPath: $fullPath) { + dependencyProxySetting { + enabled + } packageSettings { mavenDuplicatesAllowed mavenDuplicateExceptionRegex diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js index fb06f557d66..fe94203f51b 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js @@ -9,9 +9,16 @@ export const updateGroupPackageSettings = (fullPath) => (client, { data: updated const sourceData = client.readQuery(queryAndParams); const data = produce(sourceData, (draftState) => { - draftState.group.packageSettings = { - ...updatedData.updateNamespacePackageSettings.packageSettings, - }; + if (updatedData.updateNamespacePackageSettings) { + draftState.group.packageSettings = { + ...updatedData.updateNamespacePackageSettings.packageSettings, + }; + } + if (updatedData.updateDependencyProxySettings) { + draftState.group.dependencyProxySetting = { + ...updatedData.updateDependencyProxySettings.dependencyProxySetting, + }; + } }); client.writeQuery({ diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js index f2c8de85bf8..a30d8ca0b81 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js @@ -9,3 +9,15 @@ export const updateGroupPackagesSettingsOptimisticResponse = (changes) => ({ }, }, }); + +export const updateGroupDependencyProxySettingsOptimisticResponse = (changes) => ({ + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + updateDependencyProxySettings: { + __typename: 'UpdateDependencyProxySettingsPayload', + errors: [], + dependencyProxySetting: { + ...changes, + }, + }, +}); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue index bf286c84d5f..7be3bba7cae 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -95,7 +95,7 @@ export default { <gl-sprintf :message=" __( - 'Save space and find images in the container Registry. remove unneeded tags and keep only the ones you want. %{linkStart}How does cleanup work?%{linkEnd}', + 'Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}', ) " > diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index 165c4aae3cb..4d477fbd05d 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -73,6 +73,7 @@ export const OLDER_THAN_OPTIONS = [ { key: 'SEVEN_DAYS', variable: 7, default: false }, { key: 'FOURTEEN_DAYS', variable: 14, default: false }, { key: 'THIRTY_DAYS', variable: 30, default: false }, + { key: 'SIXTY_DAYS', variable: 60, default: false }, { key: 'NINETY_DAYS', variable: 90, default: true }, ]; diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants.js index 55b5816cc5a..7d2971bd8c7 100644 --- a/app/assets/javascripts/packages_and_registries/shared/constants.js +++ b/app/assets/javascripts/packages_and_registries/shared/constants.js @@ -1 +1,3 @@ export const FILTERED_SEARCH_TERM = 'filtered-search-term'; +export const FILTERED_SEARCH_TYPE = 'type'; +export const HISTORY_PIPELINES_LIMIT = 5; diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js index 4c312a008cb..68849857d0f 100644 --- a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js +++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js @@ -1,7 +1,7 @@ import { __ } from '~/locale'; export const HELPER_TEXT_SERVICE_PING_DISABLED = __( - 'To enable Registration Features, make sure "Enable service ping" is checked.', + 'To enable Registration Features, first enable Service Ping.', ); export const HELPER_TEXT_SERVICE_PING_ENABLED = __( diff --git a/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue new file mode 100644 index 00000000000..c75c031b0b1 --- /dev/null +++ b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue @@ -0,0 +1,143 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSearchBoxByType, + GlLoadingIcon, +} from '@gitlab/ui'; +import Api from '~/api'; +import { __ } from '~/locale'; + +export default { + i18n: { + dropdownHeader: __('Namespaces'), + searchPlaceholder: __('Search for Namespace'), + anyNamespace: __('Any namespace'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlLoadingIcon, + GlSearchBoxByType, + }, + props: { + showAny: { + type: Boolean, + required: false, + default: false, + }, + placeholder: { + type: String, + required: false, + default: __('Namespace'), + }, + fieldName: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + namespaceOptions: [], + selectedNamespaceId: null, + selectedNamespace: null, + searchTerm: '', + isLoading: false, + }; + }, + computed: { + selectedNamespaceName() { + if (this.selectedNamespaceId === null) { + return this.placeholder; + } + return this.selectedNamespace; + }, + }, + watch: { + searchTerm() { + this.fetchNamespaces(this.searchTerm); + }, + }, + mounted() { + this.fetchNamespaces(); + }, + methods: { + fetchNamespaces(filter) { + this.isLoading = true; + this.namespaceOptions = []; + return Api.namespaces(filter, (namespaces) => { + this.namespaceOptions = namespaces; + this.isLoading = false; + }); + }, + selectNamespace(key) { + this.selectedNamespaceId = this.namespaceOptions[key].id; + this.selectedNamespace = this.getNamespaceString(this.namespaceOptions[key]); + this.$emit('setNamespace', this.selectedNamespaceId); + }, + selectAnyNamespace() { + this.selectedNamespaceId = null; + this.selectedNamespace = null; + this.$emit('setNamespace', null); + }, + getNamespaceString(namespace) { + return `${namespace.kind}: ${namespace.full_path}`; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex"> + <input + v-if="fieldName" + :name="fieldName" + :value="selectedNamespaceId" + type="hidden" + data-testid="hidden-input" + /> + <gl-dropdown + :text="selectedNamespaceName" + :header-text="$options.i18n.dropdownHeader" + toggle-class="dropdown-menu-toggle large" + data-testid="namespace-dropdown" + :right="true" + > + <template #header> + <gl-search-box-by-type + v-model.trim="searchTerm" + class="namespace-search-box" + debounce="250" + :placeholder="$options.i18n.searchPlaceholder" + /> + </template> + + <template v-if="showAny"> + <gl-dropdown-item @click="selectAnyNamespace"> + {{ $options.i18n.anyNamespace }} + </gl-dropdown-item> + <gl-dropdown-divider /> + </template> + + <gl-loading-icon v-if="isLoading" /> + + <gl-dropdown-item + v-for="(namespace, key) in namespaceOptions" + :key="namespace.id" + @click="selectNamespace(key)" + > + {{ getNamespaceString(namespace) }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> + +<style scoped> +/* workaround position: relative imposed by .top-area .nav-controls */ +.namespace-search-box >>> input { + position: static; +} +</style> diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js index b07ca815f13..3098d06510b 100644 --- a/app/assets/javascripts/pages/admin/projects/index.js +++ b/app/assets/javascripts/pages/admin/projects/index.js @@ -1,8 +1,38 @@ -import NamespaceSelect from '~/namespace_select'; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; import ProjectsList from '~/projects_list'; +import NamespaceSelect from './components/namespace_select.vue'; new ProjectsList(); // eslint-disable-line no-new -document - .querySelectorAll('.js-namespace-select') - .forEach((dropdown) => new NamespaceSelect({ dropdown })); +function mountNamespaceSelect() { + const el = document.querySelector('.js-namespace-select'); + if (!el) { + return false; + } + + const { showAny, fieldName, placeholder, updateLocation } = el.dataset; + + return new Vue({ + el, + render(createComponent) { + return createComponent(NamespaceSelect, { + props: { + showAny: parseBoolean(showAny), + fieldName, + placeholder, + }, + on: { + setNamespace(newNamespace) { + if (fieldName && updateLocation) { + window.location = mergeUrlParams({ [fieldName]: newNamespace }, window.location.href); + } + }, + }, + }); + }, + }); +} + +mountNamespaceSelect(); diff --git a/app/assets/javascripts/pages/admin/serverless/domains/index.js b/app/assets/javascripts/pages/admin/serverless/domains/index.js deleted file mode 100644 index 4fab7a1d9cb..00000000000 --- a/app/assets/javascripts/pages/admin/serverless/domains/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import initSettingsPanels from '~/settings_panels'; - -// Initialize expandable settings panels -initSettingsPanels(); - -const domainCard = document.querySelector('.js-domain-cert-show'); -const domainForm = document.querySelector('.js-domain-cert-inputs'); -const domainReplaceButton = document.querySelector('.js-domain-cert-replace-btn'); -const domainSubmitButton = document.querySelector('.js-serverless-domain-submit'); - -if (domainReplaceButton && domainCard && domainForm) { - domainReplaceButton.addEventListener('click', () => { - domainCard.classList.add('hidden'); - domainForm.classList.remove('hidden'); - domainSubmitButton.removeAttribute('disabled'); - }); -} diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js new file mode 100644 index 00000000000..c4e05bbd092 --- /dev/null +++ b/app/assets/javascripts/pages/admin/topics/edit/index.js @@ -0,0 +1,8 @@ +import $ from 'jquery'; +import GLForm from '~/gl_form'; +import initFilePickers from '~/file_pickers'; +import ZenMode from '~/zen_mode'; + +new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new +initFilePickers(); +new ZenMode(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/topics/new/index.js b/app/assets/javascripts/pages/admin/topics/new/index.js new file mode 100644 index 00000000000..c4e05bbd092 --- /dev/null +++ b/app/assets/javascripts/pages/admin/topics/new/index.js @@ -0,0 +1,8 @@ +import $ from 'jquery'; +import GLForm from '~/gl_form'; +import initFilePickers from '~/file_pickers'; +import ZenMode from '~/zen_mode'; + +new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new +initFilePickers(); +new ZenMode(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/dependency_proxies/index.js b/app/assets/javascripts/pages/groups/dependency_proxies/index.js index 77c885d3858..862ba468296 100644 --- a/app/assets/javascripts/pages/groups/dependency_proxies/index.js +++ b/app/assets/javascripts/pages/groups/dependency_proxies/index.js @@ -1,13 +1,3 @@ -import $ from 'jquery'; -import initDependencyProxy from '~/dependency_proxy'; +import { initDependencyProxyApp } from '~/packages_and_registries/dependency_proxy/'; -initDependencyProxy(); - -const form = document.querySelector('form.edit_dependency_proxy_group_setting'); -const toggleInput = $('input.js-project-feature-toggle-input'); - -if (form && toggleInput) { - toggleInput.on('trigger-change', () => { - form.submit(); - }); -} +initDependencyProxyApp(); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 0137ff87979..01a371920f8 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -11,7 +11,7 @@ import { MEMBER_TYPES } from '~/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; import UsersSelect from '~/users_select'; -const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; +const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-group-members-list-app'), { [MEMBER_TYPES.user]: { diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js index 1c4a10fd653..95522573b53 100644 --- a/app/assets/javascripts/pages/groups/packages/index/index.js +++ b/app/assets/javascripts/pages/groups/packages/index/index.js @@ -1,5 +1,10 @@ -import initPackageList from '~/packages/list/packages_list_app_bundle'; +(async function packageApp() { + if (window.gon.features.packageListApollo) { + const newPackageList = await import('~/packages_and_registries/package_registry/pages/list'); -if (document.getElementById('js-vue-packages-list')) { - initPackageList(); -} + newPackageList.default(); + } else { + const packageList = await import('~/packages/list/packages_list_app_bundle'); + packageList.default(); + } +})(); diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue new file mode 100644 index 00000000000..ec3cf4a8a92 --- /dev/null +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -0,0 +1,176 @@ +<script> +import { GlButton, GlEmptyState, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; + +import { s__, __ } from '~/locale'; +import createFlash from '~/flash'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { getBulkImportsHistory } from '~/rest_api'; +import ImportStatus from '~/import_entities/components/import_status.vue'; +import PaginationBar from '~/import_entities/components/pagination_bar.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; + +import { DEFAULT_ERROR } from '../utils/error_messages'; + +const DEFAULT_PER_PAGE = 20; +const DEFAULT_TH_CLASSES = + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!'; + +const tableCell = (config) => ({ + thClass: `${DEFAULT_TH_CLASSES}`, + tdClass: (value, key, item) => { + return { + // eslint-disable-next-line no-underscore-dangle + 'gl-border-b-0!': item._showDetails, + }; + }, + ...config, +}); + +export default { + components: { + GlButton, + GlEmptyState, + GlLink, + GlLoadingIcon, + GlTable, + PaginationBar, + ImportStatus, + TimeAgo, + }, + + data() { + return { + loading: true, + historyItems: [], + paginationConfig: { + page: 1, + perPage: DEFAULT_PER_PAGE, + }, + pageInfo: {}, + }; + }, + + fields: [ + tableCell({ + key: 'source_full_path', + label: s__('BulkImport|Source group'), + thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`, + }), + tableCell({ + key: 'destination_name', + label: s__('BulkImport|New group'), + thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`, + }), + tableCell({ + key: 'created_at', + label: __('Date'), + }), + tableCell({ + key: 'status', + label: __('Status'), + tdAttr: { 'data-qa-selector': 'import_status_indicator' }, + }), + ], + + computed: { + hasHistoryItems() { + return this.historyItems.length > 0; + }, + }, + + watch: { + paginationConfig: { + handler() { + this.loadHistoryItems(); + }, + deep: true, + immediate: true, + }, + }, + + methods: { + async loadHistoryItems() { + try { + this.loading = true; + const { data: historyItems, headers } = await getBulkImportsHistory({ + page: this.paginationConfig.page, + per_page: this.paginationConfig.perPage, + }); + this.pageInfo = parseIntPagination(normalizeHeaders(headers)); + this.historyItems = historyItems; + } catch (e) { + createFlash({ message: DEFAULT_ERROR, captureError: true, error: e }); + } finally { + this.loading = false; + } + }, + + getDestinationUrl({ destination_name: name, destination_namespace: namespace }) { + return [namespace, name].filter(Boolean).join('/'); + }, + + getFullDestinationUrl(params) { + return joinPaths(gon.relative_url_root || '', this.getDestinationUrl(params)); + }, + }, + + gitlabLogo: window.gon.gitlab_logo, +}; +</script> + +<template> + <div> + <div + class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center" + > + <h1 class="gl-my-0 gl-py-4 gl-font-size-h1"> + <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> + {{ s__('BulkImport|Group import history') }} + </h1> + </div> + <gl-loading-icon v-if="loading" size="md" class="gl-mt-5" /> + <gl-empty-state + v-else-if="!hasHistoryItems" + :title="s__('BulkImport|No history is available')" + :description="s__('BulkImport|Your imported groups will appear here.')" + /> + <template v-else> + <gl-table + :fields="$options.fields" + :items="historyItems" + data-qa-selector="import_history_table" + class="gl-w-full" + > + <template #cell(destination_name)="{ item }"> + <gl-link :href="getFullDestinationUrl(item)" target="_blank"> + {{ getDestinationUrl(item) }} + </gl-link> + </template> + <template #cell(created_at)="{ value }"> + <time-ago :time="value" /> + </template> + <template #cell(status)="{ value, item, toggleDetails, detailsShowing }"> + <import-status :status="value" class="gl-display-inline-block gl-w-13" /> + <gl-button + v-if="item.failures.length" + class="gl-ml-3" + :selected="detailsShowing" + @click="toggleDetails" + >{{ __('Details') }}</gl-button + > + </template> + <template #row-details="{ item }"> + <pre>{{ item.failures }}</pre> + </template> + </gl-table> + <pagination-bar + :page-info="pageInfo" + :items-count="historyItems.length" + class="gl-m-0 gl-mt-3" + @set-page="paginationConfig.page = $event" + @set-page-size="paginationConfig.perPage = $event" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/index.js b/app/assets/javascripts/pages/import/bulk_imports/history/index.js new file mode 100644 index 00000000000..5a67aa99baa --- /dev/null +++ b/app/assets/javascripts/pages/import/bulk_imports/history/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import BulkImportHistoryApp from './components/bulk_imports_history_app.vue'; + +function mountImportHistoryApp(mountElement) { + if (!mountElement) return undefined; + + return new Vue({ + el: mountElement, + render(createElement) { + return createElement(BulkImportHistoryApp); + }, + }); +} + +mountImportHistoryApp(document.querySelector('#import-history-mount-element')); diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js b/app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js new file mode 100644 index 00000000000..24669e22ade --- /dev/null +++ b/app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js @@ -0,0 +1,3 @@ +import { __ } from '~/locale'; + +export const DEFAULT_ERROR = __('Something went wrong on our end.'); diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js index 80bc32dd43f..6afb3636998 100644 --- a/app/assets/javascripts/pages/profiles/index.js +++ b/app/assets/javascripts/pages/profiles/index.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import '~/profile/gl_crop'; import Profile from '~/profile/profile'; import initSearchSettings from '~/search_settings'; +import initPasswordPrompt from './password_prompt'; // eslint-disable-next-line func-names $(document).on('input.ssh_key', '#key_key', function () { @@ -19,3 +20,4 @@ $(document).on('input.ssh_key', '#key_key', function () { new Profile(); // eslint-disable-line no-new initSearchSettings(); +initPasswordPrompt(); diff --git a/app/assets/javascripts/pages/profiles/password_prompt/constants.js b/app/assets/javascripts/pages/profiles/password_prompt/constants.js new file mode 100644 index 00000000000..99b8442c928 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/password_prompt/constants.js @@ -0,0 +1,9 @@ +import { __, s__ } from '~/locale'; + +export const I18N_PASSWORD_PROMPT_TITLE = s__('PasswordPrompt|Confirm password to continue'); +export const I18N_PASSWORD_PROMPT_FORM_LABEL = s__( + 'PasswordPrompt|Please enter your password to confirm', +); +export const I18N_PASSWORD_PROMPT_ERROR_MESSAGE = s__('PasswordPrompt|Password is required'); +export const I18N_PASSWORD_PROMPT_CONFIRM_BUTTON = s__('PasswordPrompt|Confirm password'); +export const I18N_PASSWORD_PROMPT_CANCEL_BUTTON = __('Cancel'); diff --git a/app/assets/javascripts/pages/profiles/password_prompt/index.js b/app/assets/javascripts/pages/profiles/password_prompt/index.js new file mode 100644 index 00000000000..20645112893 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/password_prompt/index.js @@ -0,0 +1,58 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import PasswordPromptModal from './password_prompt_modal.vue'; + +Vue.use(Translate); + +const emailFieldSelector = '#user_email'; +const editFormSelector = '.js-password-prompt-form'; +const passwordPromptFieldSelector = '.js-password-prompt-field'; +const passwordPromptBtnSelector = '.js-password-prompt-btn'; + +const passwordPromptModalId = 'password-prompt-modal'; + +const getEmailValue = () => document.querySelector(emailFieldSelector).value.trim(); +const passwordPromptButton = document.querySelector(passwordPromptBtnSelector); +const field = document.querySelector(passwordPromptFieldSelector); +const form = document.querySelector(editFormSelector); + +const handleConfirmPassword = (pw) => { + // update the validation_password field + field.value = pw; + // submit the form + form.submit(); +}; + +export default () => { + const passwordPromptModalEl = document.getElementById(passwordPromptModalId); + + if (passwordPromptModalEl && field) { + return new Vue({ + el: passwordPromptModalEl, + data() { + return { + initialEmail: '', + }; + }, + mounted() { + this.initialEmail = getEmailValue(); + passwordPromptButton.addEventListener('click', this.handleSettingsUpdate); + }, + methods: { + handleSettingsUpdate(ev) { + const email = getEmailValue(); + if (email !== this.initialEmail) { + ev.preventDefault(); + this.$root.$emit('bv::show::modal', passwordPromptModalId, passwordPromptBtnSelector); + } + }, + }, + render(createElement) { + return createElement(PasswordPromptModal, { + props: { handleConfirmPassword }, + }); + }, + }); + } + return null; +}; diff --git a/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue new file mode 100644 index 00000000000..44728ea9cdf --- /dev/null +++ b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue @@ -0,0 +1,82 @@ +<script> +import { GlModal, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { + I18N_PASSWORD_PROMPT_TITLE, + I18N_PASSWORD_PROMPT_FORM_LABEL, + I18N_PASSWORD_PROMPT_ERROR_MESSAGE, + I18N_PASSWORD_PROMPT_CANCEL_BUTTON, + I18N_PASSWORD_PROMPT_CONFIRM_BUTTON, +} from './constants'; + +export default { + components: { + GlModal, + GlForm, + GlFormGroup, + GlFormInput, + }, + props: { + handleConfirmPassword: { + type: Function, + required: true, + }, + }, + data() { + return { + passwordCheck: '', + }; + }, + computed: { + isValid() { + return Boolean(this.passwordCheck.length); + }, + primaryProps() { + return { + text: I18N_PASSWORD_PROMPT_CONFIRM_BUTTON, + attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.isValid }], + }; + }, + }, + methods: { + onConfirmPassword() { + this.handleConfirmPassword(this.passwordCheck); + }, + }, + cancelProps: { + text: I18N_PASSWORD_PROMPT_CANCEL_BUTTON, + }, + i18n: { + title: I18N_PASSWORD_PROMPT_TITLE, + formLabel: I18N_PASSWORD_PROMPT_FORM_LABEL, + errorMessage: I18N_PASSWORD_PROMPT_ERROR_MESSAGE, + }, +}; +</script> + +<template> + <gl-modal + data-testid="password-prompt-modal" + modal-id="password-prompt-modal" + :title="$options.i18n.title" + :action-primary="primaryProps" + :action-cancel="$options.cancelProps" + @primary="onConfirmPassword" + > + <gl-form @submit.prevent="onConfirmPassword"> + <gl-form-group + :label="$options.i18n.formLabel" + label-for="password-prompt-confirmation" + :invalid-feedback="$options.i18n.errorMessage" + :state="isValid" + > + <gl-form-input + id="password-prompt-confirmation" + v-model="passwordCheck" + name="password-confirmation" + type="password" + data-testid="password-prompt-field" + /> + </gl-form-group> + </gl-form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/pages/projects/cluster_agents/show/index.js b/app/assets/javascripts/pages/projects/cluster_agents/show/index.js new file mode 100644 index 00000000000..4ed3e2f7bea --- /dev/null +++ b/app/assets/javascripts/pages/projects/cluster_agents/show/index.js @@ -0,0 +1,3 @@ +import loadClusterAgentVues from '~/clusters/agents'; + +loadClusterAgentVues(); diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js index 2b5451bd18b..a1ba920b322 100644 --- a/app/assets/javascripts/pages/projects/clusters/index/index.js +++ b/app/assets/javascripts/pages/projects/clusters/index/index.js @@ -1,4 +1,4 @@ -import initClustersListApp from 'ee_else_ce/clusters_list'; +import initClustersListApp from '~/clusters_list'; import PersistentUserCallout from '~/persistent_user_callout'; const callout = document.querySelector('.gcp-signup-offer'); diff --git a/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue deleted file mode 100644 index ba8858c985a..00000000000 --- a/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue +++ /dev/null @@ -1,98 +0,0 @@ -<script> -import { - GlButton, - GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlLoadingIcon, - GlSearchBoxByType, -} from '@gitlab/ui'; -import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import Tracking from '~/tracking'; -import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; -import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql'; - -export default { - components: { - GlButton, - GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlLoadingIcon, - GlSearchBoxByType, - }, - mixins: [Tracking.mixin()], - apollo: { - currentUser: { - query: searchNamespacesWhereUserCanCreateProjectsQuery, - variables() { - return { - search: this.search, - }; - }, - skip() { - return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH; - }, - debounce: DEBOUNCE_DELAY, - }, - }, - inject: ['namespaceFullPath', 'namespaceId', 'rootUrl', 'trackLabel'], - data() { - return { - currentUser: {}, - search: '', - selectedNamespace: { - id: this.namespaceId, - fullPath: this.namespaceFullPath, - }, - }; - }, - computed: { - userGroups() { - return this.currentUser.groups?.nodes || []; - }, - userNamespace() { - return this.currentUser.namespace || {}; - }, - }, - methods: { - handleClick({ id, fullPath }) { - this.selectedNamespace = { - id: getIdFromGraphQLId(id), - fullPath, - }; - }, - }, -}; -</script> - -<template> - <gl-button-group class="gl-w-full"> - <gl-button label>{{ rootUrl }}</gl-button> - <gl-dropdown - class="gl-w-full" - :text="selectedNamespace.fullPath" - toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base!" - data-qa-selector="select_namespace_dropdown" - @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })" - > - <gl-search-box-by-type v-model.trim="search" /> - <gl-loading-icon v-if="$apollo.queries.currentUser.loading" /> - <template v-else> - <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> - <gl-dropdown-item v-for="group of userGroups" :key="group.id" @click="handleClick(group)"> - {{ group.fullPath }} - </gl-dropdown-item> - <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> - <gl-dropdown-item @click="handleClick(userNamespace)"> - {{ userNamespace.fullPath }} - </gl-dropdown-item> - </template> - </gl-dropdown> - - <input type="hidden" name="project[namespace_id]" :value="selectedNamespace.id" /> - </gl-button-group> -</template> diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index ed816e3be95..d89b4d0e0a3 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -1,66 +1,6 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import initProjectVisibilitySelector from '../../../project_visibility'; -import initProjectNew from '../../../projects/project_new'; -import NewProjectCreationApp from './components/app.vue'; -import NewProjectUrlSelect from './components/new_project_url_select.vue'; - -function initNewProjectCreation() { - const el = document.querySelector('.js-new-project-creation'); - - const { - pushToCreateProjectCommand, - workingWithProjectsHelpPath, - newProjectGuidelines, - hasErrors, - isCiCdAvailable, - } = el.dataset; - - const props = { - hasErrors: parseBoolean(hasErrors), - isCiCdAvailable: parseBoolean(isCiCdAvailable), - newProjectGuidelines, - }; - - const provide = { - workingWithProjectsHelpPath, - pushToCreateProjectCommand, - }; - - return new Vue({ - el, - provide, - render(h) { - return h(NewProjectCreationApp, { props }); - }, - }); -} - -function initNewProjectUrlSelect() { - const el = document.querySelector('.js-vue-new-project-url-select'); - - if (!el) { - return undefined; - } - - Vue.use(VueApollo); - - return new Vue({ - el, - apolloProvider: new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), - }), - provide: { - namespaceFullPath: el.dataset.namespaceFullPath, - namespaceId: el.dataset.namespaceId, - rootUrl: el.dataset.rootUrl, - trackLabel: el.dataset.trackLabel, - }, - render: (createElement) => createElement(NewProjectUrlSelect), - }); -} +import { initNewProjectCreation, initNewProjectUrlSelect } from '~/projects/new'; +import initProjectVisibilitySelector from '~/project_visibility'; +import initProjectNew from '~/projects/project_new'; initProjectVisibilitySelector(); initProjectNew.bindEvents(); diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js index c94782fdf1b..95522573b53 100644 --- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js +++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js @@ -1,3 +1,10 @@ -import initPackageList from '~/packages/list/packages_list_app_bundle'; +(async function packageApp() { + if (window.gon.features.packageListApollo) { + const newPackageList = await import('~/packages_and_registries/package_registry/pages/list'); -initPackageList(); + newPackageList.default(); + } else { + const packageList = await import('~/packages/list/packages_list_app_bundle'); + packageList.default(); + } +})(); 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 16c4a6191b2..e92b9b30fa4 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 @@ -27,19 +27,22 @@ export const findTimezoneByIdentifier = (tzList = [], identifier = null) => { }; export default class TimezoneDropdown { - constructor({ $dropdownEl, $inputEl, onSelectTimezone, displayFormat } = defaults) { + constructor({ + $dropdownEl, + $inputEl, + onSelectTimezone, + displayFormat, + allowEmpty = false, + } = defaults) { this.$dropdown = $dropdownEl; this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); this.$input = $inputEl; - this.timezoneData = this.$dropdown.data('data'); + this.timezoneData = this.$dropdown.data('data') || []; this.onSelectTimezone = onSelectTimezone; this.displayFormat = displayFormat || defaults.displayFormat; + this.allowEmpty = allowEmpty; - this.initialTimezone = - findTimezoneByIdentifier(this.timezoneData, this.$input.val()) || defaultTimezone; - - this.initDefaultTimezone(); this.initDropdown(); } @@ -52,24 +55,25 @@ export default class TimezoneDropdown { search: { fields: ['name'], }, - clicked: (cfg) => this.updateInputValue(cfg), + clicked: (cfg) => this.handleDropdownChange(cfg), text: (item) => formatTimezone(item), }); - this.setDropdownToggle(this.displayFormat(this.initialTimezone)); - } + const initialTimezone = findTimezoneByIdentifier(this.timezoneData, this.$input.val()); - initDefaultTimezone() { - if (!this.$input.val()) { - this.$input.val(defaultTimezone.name); + if (initialTimezone !== null) { + this.setDropdownValue(initialTimezone); + } else if (!this.allowEmpty) { + this.setDropdownValue(defaultTimezone); } } - setDropdownToggle(dropdownText) { - this.$dropdownToggle.text(dropdownText); + setDropdownValue(timezone) { + this.$dropdownToggle.text(this.displayFormat(timezone)); + this.$input.val(timezone.name); } - updateInputValue({ selectedObj, e }) { + handleDropdownChange({ selectedObj, e }) { e.preventDefault(); this.$input.val(selectedObj.identifier); if (this.onSelectTimezone) { diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 0b662c945c6..947bbdacf2c 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -26,7 +26,7 @@ initInviteMembersForm(); new UsersSelect(); // eslint-disable-line no-new -const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; +const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-project-members-list-app'), { [MEMBER_TYPES.user]: { tableFields: SHARED_FIELDS.concat(['source', 'granted']), diff --git a/app/assets/javascripts/pages/projects/wikis/diff/index.js b/app/assets/javascripts/pages/projects/wikis/diff/index.js new file mode 100644 index 00000000000..73440db761f --- /dev/null +++ b/app/assets/javascripts/pages/projects/wikis/diff/index.js @@ -0,0 +1,3 @@ +import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown'; + +initDiffStatsDropdown(); diff --git a/app/assets/javascripts/pages/projects/wikis/edit/index.js b/app/assets/javascripts/pages/projects/wikis/edit/index.js new file mode 100644 index 00000000000..b2288c2655c --- /dev/null +++ b/app/assets/javascripts/pages/projects/wikis/edit/index.js @@ -0,0 +1,3 @@ +import { mountApplications } from '~/pages/shared/wikis/edit'; + +mountApplications(); diff --git a/app/assets/javascripts/pages/projects/wikis/git_access/index.js b/app/assets/javascripts/pages/projects/wikis/git_access/index.js new file mode 100644 index 00000000000..b1f3006bc1a --- /dev/null +++ b/app/assets/javascripts/pages/projects/wikis/git_access/index.js @@ -0,0 +1,3 @@ +import initClonePanel from '~/clone_panel'; + +initClonePanel(); diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js index 2c1f9e634ab..83fcd348ddf 100644 --- a/app/assets/javascripts/pages/projects/wikis/index.js +++ b/app/assets/javascripts/pages/projects/wikis/index.js @@ -1,5 +1,3 @@ -import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown'; -import initWikis from '~/pages/shared/wikis'; +import Wikis from '~/pages/shared/wikis/wikis'; -initWikis(); -initDiffStatsDropdown(); +export default new Wikis(); diff --git a/app/assets/javascripts/pages/projects/wikis/show/index.js b/app/assets/javascripts/pages/projects/wikis/show/index.js new file mode 100644 index 00000000000..c08a10122b6 --- /dev/null +++ b/app/assets/javascripts/pages/projects/wikis/show/index.js @@ -0,0 +1,3 @@ +import { mountApplications as mountEditApplications } from '~/pages/shared/wikis/async_edit'; + +mountEditApplications(); diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js index 8d2d5d41f6a..ee48543f0d2 100644 --- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js +++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js @@ -20,7 +20,7 @@ export default class OAuthRememberMe { toggleRememberMe(event) { const rememberMe = $(event.target).is(':checked'); - $('.oauth-login', this.container).each((i, element) => { + $('.js-oauth-login', this.container).each((i, element) => { const $form = $(element).parent('form'); const href = $form.attr('action'); diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js index e83c73edfde..1cb7259be64 100644 --- a/app/assets/javascripts/pages/shared/mount_runner_instructions.js +++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js @@ -9,7 +9,12 @@ export function initInstallRunner(componentId = 'js-install-runner') { const installRunnerEl = document.getElementById(componentId); if (installRunnerEl) { - const defaultClient = createDefaultClient(); + const defaultClient = createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ); const apolloProvider = new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/pages/shared/wikis/async_edit.js b/app/assets/javascripts/pages/shared/wikis/async_edit.js new file mode 100644 index 00000000000..4536a076568 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/async_edit.js @@ -0,0 +1,11 @@ +export const mountApplications = async () => { + const el = document.querySelector('.js-wiki-edit-page'); + + if (el) { + const { mountApplications: mountEditApplications } = await import( + /* webpackChunkName: 'wiki_edit' */ './edit' + ); + + mountEditApplications(); + } +}; diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/edit.js index 42aefe81325..beeabfde1a6 100644 --- a/app/assets/javascripts/pages/shared/wikis/index.js +++ b/app/assets/javascripts/pages/shared/wikis/edit.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import Vue from 'vue'; -import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import csrf from '~/lib/utils/csrf'; import Translate from '~/vue_shared/translate'; @@ -9,14 +8,8 @@ import ZenMode from '../../../zen_mode'; import deleteWikiModal from './components/delete_wiki_modal.vue'; import wikiAlert from './components/wiki_alert.vue'; import wikiForm from './components/wiki_form.vue'; -import Wikis from './wikis'; const createModalVueApp = () => { - new Wikis(); // eslint-disable-line no-new - new ShortcutsWiki(); // eslint-disable-line no-new - new ZenMode(); // eslint-disable-line no-new - new GLForm($('.wiki-form')); // eslint-disable-line no-new - const deleteWikiModalWrapperEl = document.getElementById('delete-wiki-modal-wrapper'); if (deleteWikiModalWrapperEl) { @@ -85,7 +78,10 @@ const createWikiFormApp = () => { } }; -export default () => { +export const mountApplications = () => { + new ZenMode(); // eslint-disable-line no-new + new GLForm($('.wiki-form')); // eslint-disable-line no-new + createModalVueApp(); createAlertVueApp(); createWikiFormApp(); diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js index 7d0b0c90c8d..8d0105bc681 100644 --- a/app/assets/javascripts/pages/shared/wikis/wikis.js +++ b/app/assets/javascripts/pages/shared/wikis/wikis.js @@ -1,6 +1,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Tracking from '~/tracking'; import showToast from '~/vue_shared/plugins/global_toast'; +import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki'; const TRACKING_EVENT_NAME = 'view_wiki_page'; const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-1'; @@ -20,6 +21,7 @@ export default class Wikis { Wikis.trackPageView(); Wikis.showToasts(); + Wikis.initShortcuts(); } handleToggleSidebar(e) { @@ -64,4 +66,8 @@ export default class Wikis { const toasts = document.querySelectorAll('.js-toast-message'); toasts.forEach((toast) => showToast(toast.dataset.message)); } + + static initShortcuts() { + new ShortcutsWiki(); // eslint-disable-line no-new + } } diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index f204f0ebfaa..ed30198244f 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,6 +1,7 @@ <script> import { GlSafeHtmlDirective } from '@gitlab/ui'; import { glEmojiTag } from '~/emoji'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import AddRequest from './add_request.vue'; @@ -131,6 +132,12 @@ export default { changeCurrentRequest(newRequestId) { this.currentRequest = newRequestId; }, + flamegraphPath(mode) { + return mergeUrlParams( + { performance_bar: 'flamegraph', stackprof_mode: mode }, + window.location.href, + ); + }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; @@ -175,6 +182,20 @@ export default { s__('PerformanceBar|Download') }}</a> </div> + <div v-if="currentRequest.details" id="peek-flamegraph" class="view"> + <span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span> + <a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{ + s__('PerformanceBar|wall') + }}</a> + / + <a class="gl-text-blue-200" :href="flamegraphPath('cpu')">{{ + s__('PerformanceBar|cpu') + }}</a> + / + <a class="gl-text-blue-200" :href="flamegraphPath('object')">{{ + s__('PerformanceBar|object') + }}</a> + </div> <a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{ s__('PerformanceBar|Stats') }}</a> diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 8170a1f8443..a7f8704b559 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -9,6 +9,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-registration-enabled-callout', '.js-new-user-signups-cap-reached', '.js-eoa-bronze-plan-banner', + '.js-security-newsletter-callout', ]; const initCallouts = () => { 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 f2a0f474bc4..7b8e97b573e 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue @@ -9,15 +9,8 @@ export default { SourceEditor, }, mixins: [glFeatureFlagMixin()], - inject: ['ciConfigPath', 'projectPath', 'projectNamespace', 'defaultBranch'], + inject: ['ciConfigPath'], inheritAttrs: false, - props: { - commitSha: { - type: String, - required: false, - default: '', - }, - }, methods: { onCiConfigUpdate(content) { this.$emit('updateCiConfig', content); @@ -27,11 +20,7 @@ export default { const editorInstance = this.$refs.editor.getEditor(); editorInstance.use(new CiSchemaExtension({ instance: editorInstance })); - editorInstance.registerCiSchema({ - projectPath: this.projectPath, - projectNamespace: this.projectNamespace, - ref: this.commitSha || this.defaultBranch, - }); + editorInstance.registerCiSchema(); } }, }, diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue new file mode 100644 index 00000000000..75b1398a3c2 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -0,0 +1,49 @@ +<script> +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; + +export default { + components: { + PipelineMiniGraph, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + }, + computed: { + pipelinePath() { + return this.pipeline.detailedStatus?.detailsPath || ''; + }, + pipelineStages() { + const stages = this.pipeline.stages?.edges; + if (!stages) { + return []; + } + + return stages.map(({ node }) => { + const { name, detailedStatus } = node; + return { + // TODO: fetch dropdown_path from graphql when available + // see https://gitlab.com/gitlab-org/gitlab/-/issues/342585 + dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`, + name, + path: `${this.pipelinePath}#${name}`, + status: { + details_path: `${this.pipelinePath}#${name}`, + has_details: detailedStatus.hasDetails, + ...detailedStatus, + }, + title: `${name}: ${detailedStatus.text}`, + }; + }); + }, + }, +}; +</script> + +<template> + <div v-if="pipelineStages.length > 0" class="stage-cell gl-mr-5"> + <pipeline-mini-graph class="gl-display-inline" :stages="pipelineStages" /> + </div> +</template> 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 ec240854be5..a1fa2147994 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -10,6 +10,8 @@ import { toggleQueryPollingByVisibility, } from '~/pipelines/components/graph/utils'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; const POLL_INTERVAL = 10000; export const i18n = { @@ -30,7 +32,9 @@ export default { GlLink, GlLoadingIcon, GlSprintf, + PipelineEditorMiniGraph, }, + mixins: [glFeatureFlagMixin()], inject: ['projectFullPath'], props: { commitSha: { @@ -55,12 +59,15 @@ export default { }; }, update(data) { - const { id, commitPath = '', detailedStatus = {} } = data.project?.pipeline || {}; + const { id, commitPath = '', detailedStatus = {}, stages, status } = + data.project?.pipeline || {}; return { id, commitPath, detailedStatus, + stages, + status, }; }, result(res) { @@ -111,9 +118,7 @@ export default { </script> <template> - <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-white-space-nowrap gl-max-w-full" - > + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-wrap"> <template v-if="showLoadingState"> <div> <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> @@ -129,19 +134,12 @@ export default { <template v-else> <div> <a :href="status.detailsPath" class="gl-mr-auto"> - <ci-icon :status="status" :size="16" /> + <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" /> </a> <span class="gl-font-weight-bold"> <gl-sprintf :message="$options.i18n.pipelineInfo"> <template #id="{ content }"> - <gl-link - :href="status.detailsPath" - class="pipeline-id gl-font-weight-normal pipeline-number" - target="_blank" - data-testid="pipeline-id" - > - {{ content }}{{ pipelineId }}</gl-link - > + <span data-testid="pipeline-id"> {{ content }}{{ pipelineId }} </span> </template> <template #status>{{ status.text }}</template> <template #commit> @@ -157,8 +155,13 @@ export default { </gl-sprintf> </span> </div> - <div> + <div class="gl-display-flex gl-flex-wrap"> + <pipeline-editor-mini-graph + v-if="glFeatures.pipelineEditorMiniGraph" + :pipeline="pipeline" + /> <gl-button + class="gl-mt-2 gl-md-mt-0" target="_blank" category="secondary" variant="confirm" diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue index fbb66231f16..dcd08c9de8d 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -2,7 +2,6 @@ import { GlButton, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -17,17 +16,11 @@ export default { ), btnText: __('Create new CI/CD pipeline'), }, - mixins: [glFeatureFlagsMixin()], inject: { emptyStateIllustrationPath: { default: '', }, }, - computed: { - showCTAButton() { - return this.glFeatures.pipelineEditorEmptyStateAction; - }, - }, methods: { createEmptyConfigFile() { this.$emit('createEmptyConfigFile'); @@ -48,12 +41,7 @@ export default { </template> </gl-sprintf> </p> - <gl-button - v-if="showCTAButton" - variant="confirm" - class="gl-mt-3" - @click="createEmptyConfigFile" - > + <gl-button variant="confirm" class="gl-mt-3" @click="createEmptyConfigFile"> {{ $options.i18n.btnText }} </gl-button> </div> diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql index d3a7387ad2d..0c3653a2880 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql @@ -11,6 +11,25 @@ query getPipeline($fullPath: ID!, $sha: String!) { group text } + stages { + edges { + node { + id + name + status + detailedStatus { + detailsPath + group + hasDetails + icon + id + label + text + tooltip + } + } + } + } } } } diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index 4324c64ab3b..ba567023946 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -1,5 +1,4 @@ <script> -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import CommitSection from './components/commit/commit_section.vue'; import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue'; import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue'; @@ -15,7 +14,6 @@ export default { PipelineEditorHeader, PipelineEditorTabs, }, - mixins: [glFeatureFlagMixin()], props: { ciConfigData: { type: Object, @@ -44,9 +42,6 @@ export default { showCommitForm() { return TABS_WITH_COMMIT_FORM.includes(this.currentTab); }, - showPipelineDrawer() { - return this.glFeatures.pipelineEditorDrawer; - }, }, methods: { setCurrentTab(tabName) { @@ -77,6 +72,6 @@ export default { :commit-sha="commitSha" v-on="$listeners" /> - <pipeline-editor-drawer v-if="showPipelineDrawer" /> + <pipeline-editor-drawer /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index fd40ca0b9c9..0216b2717ed 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -52,7 +52,7 @@ export default { required: true, }, cssClassJobName: { - type: [String, Array], + type: [String, Array, Object], required: false, default: '', }, @@ -167,9 +167,13 @@ export default { return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded; }, jobClasses() { - return this.relatedDownstreamHovered || this.relatedDownstreamExpanded - ? `${this.$options.hoverClass} ${this.cssClassJobName}` - : this.cssClassJobName; + return [ + { + [this.$options.hoverClass]: + this.relatedDownstreamHovered || this.relatedDownstreamExpanded, + }, + this.cssClassJobName, + ]; }, }, errorCaptured(err, _vm, info) { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue index 3470c963ade..b778fe28e59 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue @@ -5,7 +5,6 @@ import { GlDropdownItem, GlDropdownSectionHeader, GlLoadingIcon, - GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; @@ -13,7 +12,6 @@ import { __, s__ } from '~/locale'; export const i18n = { artifacts: __('Artifacts'), - downloadArtifact: __('Download %{name} artifact'), artifactSectionHeader: __('Download artifacts'), artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), emptyArtifactsMessage: __('No artifacts found'), @@ -30,7 +28,6 @@ export default { GlDropdownItem, GlDropdownSectionHeader, GlLoadingIcon, - GlSprintf, }, inject: { artifactsEndpoint: { @@ -113,9 +110,7 @@ export default { class="gl-word-break-word" data-testid="artifact-item" > - <gl-sprintf :message="$options.i18n.downloadArtifact"> - <template #name>{{ artifact.name }}</template> - </gl-sprintf> + {{ artifact.name }} </gl-dropdown-item> </gl-dropdown> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue index 3bd149fc782..ef21673115e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue @@ -1,5 +1,5 @@ <script> -import { GlLink, GlModal } from '@gitlab/ui'; +import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { __, s__, sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -13,6 +13,7 @@ export default { components: { GlModal, GlLink, + GlSprintf, CiIcon, }, props: { @@ -33,13 +34,7 @@ export default { ); }, modalText() { - return sprintf( - s__(`Pipeline|You’re about to stop pipeline %{pipelineId}.`), - { - pipelineId: `<strong>#${this.pipeline.id}</strong>`, - }, - false, - ); + return s__(`Pipeline|You’re about to stop pipeline #%{pipelineId}.`); }, hasRef() { return !isEmpty(this.pipeline.ref); @@ -71,7 +66,13 @@ export default { :action-cancel="cancelProps" @primary="emitSubmit($event)" > - <p v-html="modalText /* eslint-disable-line vue/no-v-html */"></p> + <p> + <gl-sprintf :message="modalText"> + <template #pipelineId> + <strong>{{ pipeline.id }}</strong> + </template> + </gl-sprintf> + </p> <p v-if="pipeline"> <ci-icon diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 36629d9f1f1..1c7c4d7c704 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue @@ -3,8 +3,8 @@ import { GlAlert, GlDropdown, GlDropdownItem, + GlDropdownSectionHeader, GlLoadingIcon, - GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; @@ -12,7 +12,6 @@ import { __, s__ } from '~/locale'; export const i18n = { artifacts: __('Artifacts'), - downloadArtifact: __('Download %{name} artifact'), artifactSectionHeader: __('Download artifacts'), artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), noArtifacts: s__('Pipelines|No artifacts available'), @@ -27,8 +26,8 @@ export default { GlAlert, GlDropdown, GlDropdownItem, + GlDropdownSectionHeader, GlLoadingIcon, - GlSprintf, }, inject: { artifactsEndpoint: { @@ -92,6 +91,10 @@ export default { text-sr-only @show.once="fetchArtifacts" > + <gl-dropdown-section-header>{{ + $options.i18n.artifactSectionHeader + }}</gl-dropdown-section-header> + <gl-alert v-if="hasError" variant="danger" :dismissible="false"> {{ $options.i18n.artifactsFetchErrorMessage }} </gl-alert> @@ -108,10 +111,9 @@ export default { :href="artifact.path" rel="nofollow" download + class="gl-word-break-word" > - <gl-sprintf :message="$options.i18n.downloadArtifact"> - <template #name>{{ artifact.name }}</template> - </gl-sprintf> + {{ artifact.name }} </gl-dropdown-item> </gl-dropdown> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js index 02baa76f627..d8f15cfde91 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js @@ -2,51 +2,51 @@ import { s__ } from '~/locale'; export const PIPELINE_SOURCES = [ { - text: s__('Pipeline|Source|Push'), + text: s__('PipelineSource|Push'), value: 'push', }, { - text: s__('Pipeline|Source|Web'), + text: s__('PipelineSource|Web'), value: 'web', }, { - text: s__('Pipeline|Source|Trigger'), + text: s__('PipelineSource|Trigger'), value: 'trigger', }, { - text: s__('Pipeline|Source|Schedule'), + text: s__('PipelineSource|Schedule'), value: 'schedule', }, { - text: s__('Pipeline|Source|API'), + text: s__('PipelineSource|API'), value: 'api', }, { - text: s__('Pipeline|Source|External'), + text: s__('PipelineSource|External'), value: 'external', }, { - text: s__('Pipeline|Source|Pipeline'), + text: s__('PipelineSource|Pipeline'), value: 'pipeline', }, { - text: s__('Pipeline|Source|Chat'), + text: s__('PipelineSource|Chat'), value: 'chat', }, { - text: s__('Pipeline|Source|Web IDE'), + text: s__('PipelineSource|Web IDE'), value: 'webide', }, { - text: s__('Pipeline|Source|Merge Request'), + text: s__('PipelineSource|Merge Request'), value: 'merge_request_event', }, { - text: s__('Pipeline|Source|External Pull Request'), + text: s__('PipelineSource|External Pull Request'), value: 'external_pull_request_event', }, { - text: s__('Pipeline|Source|Parent Pipeline'), + text: s__('PipelineSource|Parent Pipeline'), value: 'parent_pipeline', }, ]; diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index c49ade2bbb8..ff9b47cdcd6 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -21,6 +21,7 @@ export default class Profile { $inputEl: this.$inputEl, $dropdownEl: $('.js-timezone-dropdown'), displayFormat: (selectedItem) => formatTimezone(selectedItem), + allowEmpty: true, }); } diff --git a/app/assets/javascripts/pages/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue index 6e9efc50be8..6e9efc50be8 100644 --- a/app/assets/javascripts/pages/projects/new/components/app.vue +++ b/app/assets/javascripts/projects/new/components/app.vue diff --git a/app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue b/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue index e42d9154866..e42d9154866 100644 --- a/app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue +++ b/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue new file mode 100644 index 00000000000..bf44ff70562 --- /dev/null +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -0,0 +1,163 @@ +<script> +import { + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlDropdownSectionHeader, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import Tracking from '~/tracking'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql'; +import eventHub from '../event_hub'; + +export default { + components: { + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlDropdownSectionHeader, + GlLoadingIcon, + GlSearchBoxByType, + }, + mixins: [Tracking.mixin()], + apollo: { + currentUser: { + query: searchNamespacesWhereUserCanCreateProjectsQuery, + variables() { + return { + search: this.search, + }; + }, + skip() { + return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH; + }, + debounce: DEBOUNCE_DELAY, + }, + }, + inject: [ + 'namespaceFullPath', + 'namespaceId', + 'rootUrl', + 'trackLabel', + 'userNamespaceFullPath', + 'userNamespaceId', + ], + data() { + return { + currentUser: {}, + groupToFilterBy: undefined, + search: '', + selectedNamespace: this.namespaceId + ? { + id: this.namespaceId, + fullPath: this.namespaceFullPath, + } + : { + id: this.userNamespaceId, + fullPath: this.userNamespaceFullPath, + }, + }; + }, + computed: { + userGroups() { + return this.currentUser.groups?.nodes || []; + }, + userNamespace() { + return this.currentUser.namespace || {}; + }, + filteredGroups() { + return this.groupToFilterBy + ? this.userGroups.filter((group) => + group.fullPath.startsWith(this.groupToFilterBy.fullPath), + ) + : this.userGroups; + }, + hasGroupMatches() { + return this.filteredGroups.length; + }, + hasNamespaceMatches() { + return ( + this.userNamespace.fullPath?.toLowerCase().includes(this.search.toLowerCase()) && + !this.groupToFilterBy + ); + }, + hasNoMatches() { + return !this.hasGroupMatches && !this.hasNamespaceMatches; + }, + }, + created() { + eventHub.$on('select-template', this.handleSelectTemplate); + }, + beforeDestroy() { + eventHub.$off('select-template', this.handleSelectTemplate); + }, + methods: { + focusInput() { + this.$refs.search.focusInput(); + }, + handleSelectTemplate(groupId) { + this.groupToFilterBy = this.userGroups.find( + (group) => getIdFromGraphQLId(group.id) === groupId, + ); + if (this.groupToFilterBy) { + this.setNamespace(this.groupToFilterBy); + } + }, + setNamespace({ id, fullPath }) { + this.selectedNamespace = { + id: getIdFromGraphQLId(id), + fullPath, + }; + }, + }, +}; +</script> + +<template> + <gl-button-group class="input-lg"> + <gl-button class="gl-text-truncate" label :title="rootUrl">{{ rootUrl }}</gl-button> + <gl-dropdown + :text="selectedNamespace.fullPath" + toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20" + data-qa-selector="select_namespace_dropdown" + @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })" + @shown="focusInput" + > + <gl-search-box-by-type + ref="search" + v-model.trim="search" + data-qa-selector="select_namespace_dropdown_search_field" + /> + <gl-loading-icon v-if="$apollo.queries.currentUser.loading" /> + <template v-else> + <template v-if="hasGroupMatches"> + <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="group of filteredGroups" + :key="group.id" + @click="setNamespace(group)" + > + {{ group.fullPath }} + </gl-dropdown-item> + </template> + <template v-if="hasNamespaceMatches"> + <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> + <gl-dropdown-item @click="setNamespace(userNamespace)"> + {{ userNamespace.fullPath }} + </gl-dropdown-item> + </template> + <gl-dropdown-text v-if="hasNoMatches">{{ __('No matches found') }}</gl-dropdown-text> + </template> + </gl-dropdown> + + <input type="hidden" name="project[namespace_id]" :value="selectedNamespace.id" /> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/projects/new/event_hub.js b/app/assets/javascripts/projects/new/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/projects/new/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js new file mode 100644 index 00000000000..572d3276e4f --- /dev/null +++ b/app/assets/javascripts/projects/new/index.js @@ -0,0 +1,66 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import NewProjectCreationApp from './components/app.vue'; +import NewProjectUrlSelect from './components/new_project_url_select.vue'; + +export function initNewProjectCreation() { + const el = document.querySelector('.js-new-project-creation'); + + const { + pushToCreateProjectCommand, + workingWithProjectsHelpPath, + newProjectGuidelines, + hasErrors, + isCiCdAvailable, + } = el.dataset; + + const props = { + hasErrors: parseBoolean(hasErrors), + isCiCdAvailable: parseBoolean(isCiCdAvailable), + newProjectGuidelines, + }; + + const provide = { + workingWithProjectsHelpPath, + pushToCreateProjectCommand, + }; + + return new Vue({ + el, + provide, + render(h) { + return h(NewProjectCreationApp, { props }); + }, + }); +} + +export function initNewProjectUrlSelect() { + const elements = document.querySelectorAll('.js-vue-new-project-url-select'); + + if (!elements.length) { + return; + } + + Vue.use(VueApollo); + + elements.forEach( + (el) => + new Vue({ + el, + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + }), + provide: { + namespaceFullPath: el.dataset.namespaceFullPath, + namespaceId: el.dataset.namespaceId, + rootUrl: el.dataset.rootUrl, + trackLabel: el.dataset.trackLabel, + userNamespaceFullPath: el.dataset.userNamespaceFullPath, + userNamespaceId: el.dataset.userNamespaceId, + }, + render: (createElement) => createElement(NewProjectUrlSelect), + }), + ); +} diff --git a/app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql index e16fe5dde49..e16fe5dde49 100644 --- a/app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql +++ b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index ebd20583a1c..b350db0c838 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,5 +1,7 @@ import $ from 'jquery'; +import { debounce } from 'lodash'; import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; +import axios from '../lib/utils/axios_utils'; import { convertToTitleCase, humanize, @@ -9,6 +11,23 @@ import { let hasUserDefinedProjectPath = false; let hasUserDefinedProjectName = false; +const invalidInputClass = 'gl-field-error-outline'; + +const validateImportCredentials = (url, user, password) => { + const endpoint = `${gon.relative_url_root}/import/url/validate`; + return axios + .post(endpoint, { + url, + user, + password, + }) + .then(({ data }) => data) + .catch(() => ({ + // intentionally reporting success in case of validation error + // we do not want to block users from trying import in case of validation exception + success: true, + })); +}; const onProjectNameChange = ($projectNameInput, $projectPathInput) => { const slug = slugify(convertUnicodeToAscii($projectNameInput.val())); @@ -85,7 +104,10 @@ const bindHowToImport = () => { const bindEvents = () => { const $newProjectForm = $('#new_project'); const $projectImportUrl = $('#project_import_url'); - const $projectImportUrlWarning = $('.js-import-url-warning'); + const $projectImportUrlUser = $('#project_import_url_user'); + const $projectImportUrlPassword = $('#project_import_url_password'); + const $projectImportUrlError = $('.js-import-url-error'); + const $projectImportForm = $('.project-import form'); const $projectPath = $('.tab-pane.active #project_path'); const $useTemplateBtn = $('.template-button > input'); const $projectFieldsForm = $('.project-fields-form'); @@ -139,12 +161,15 @@ const bindEvents = () => { $projectPath.val($projectPath.val().trim()); }); - function updateUrlPathWarningVisibility() { - const url = $projectImportUrl.val(); - const URL_PATTERN = /(?:git|https?):\/\/.*\/.*\.git$/; - const isUrlValid = URL_PATTERN.test(url); - $projectImportUrlWarning.toggleClass('hide', isUrlValid); - } + const updateUrlPathWarningVisibility = debounce(async () => { + const { success: isUrlValid } = await validateImportCredentials( + $projectImportUrl.val(), + $projectImportUrlUser.val(), + $projectImportUrlPassword.val(), + ); + $projectImportUrl.toggleClass(invalidInputClass, !isUrlValid); + $projectImportUrlError.toggleClass('hide', isUrlValid); + }, 500); let isProjectImportUrlDirty = false; $projectImportUrl.on('blur', () => { @@ -153,9 +178,22 @@ const bindEvents = () => { }); $projectImportUrl.on('keyup', () => { deriveProjectPathFromUrl($projectImportUrl); - // defer error message till first input blur - if (isProjectImportUrlDirty) { - updateUrlPathWarningVisibility(); + }); + + [$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => { + $f.on('input', () => { + if (isProjectImportUrlDirty) { + updateUrlPathWarningVisibility(); + } + }); + }); + + $projectImportForm.on('submit', (e) => { + const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`); + if ($invalidFields.length > 0) { + $invalidFields[0].focus(); + e.preventDefault(); + e.stopPropagation(); } }); diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index a5e53ee3927..7fb7a416dca 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -2,8 +2,8 @@ import { escape, find, countBy } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; import { n__, s__, __, sprintf } from '~/locale'; +import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api'; import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants'; export default class AccessDropdown { @@ -16,9 +16,6 @@ export default class AccessDropdown { this.accessLevelsData = accessLevelsData.roles; this.$dropdown = $dropdown; this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`); - this.usersPath = '/-/autocomplete/users.json'; - this.groupsPath = '/-/autocomplete/project_groups.json'; - this.deployKeysPath = '/-/autocomplete/deploy_keys_with_owners.json'; this.defaultLabel = this.$dropdown.data('defaultLabel'); this.setSelectedItems([]); @@ -318,9 +315,9 @@ export default class AccessDropdown { getData(query, callback) { if (this.hasLicense) { Promise.all([ - this.getDeployKeys(query), - this.getUsers(query), - this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(), + getDeployKeys(query), + getUsers(query), + this.groupsData ? Promise.resolve(this.groupsData) : getGroups(), ]) .then(([deployKeysResponse, usersResponse, groupsResponse]) => { this.groupsData = groupsResponse; @@ -332,7 +329,7 @@ export default class AccessDropdown { createFlash({ message: __('Failed to load groups, users and deploy keys.') }); }); } else { - this.getDeployKeys(query) + getDeployKeys(query) .then((deployKeysResponse) => callback(this.consolidateData(deployKeysResponse.data))) .catch(() => createFlash({ message: __('Failed to load deploy keys.') })); } @@ -473,46 +470,6 @@ export default class AccessDropdown { return consolidatedData; } - getUsers(query) { - return axios.get(this.buildUrl(gon.relative_url_root, this.usersPath), { - params: { - search: query, - per_page: 20, - active: true, - project_id: gon.current_project_id, - push_code: true, - }, - }); - } - - getGroups() { - return axios.get(this.buildUrl(gon.relative_url_root, this.groupsPath), { - params: { - project_id: gon.current_project_id, - }, - }); - } - - getDeployKeys(query) { - return axios.get(this.buildUrl(gon.relative_url_root, this.deployKeysPath), { - params: { - search: query, - per_page: 20, - active: true, - project_id: gon.current_project_id, - push_code: true, - }, - }); - } - - buildUrl(urlRoot, url) { - let newUrl; - if (urlRoot != null) { - newUrl = urlRoot.replace(/\/$/, '') + url; - } - return newUrl; - } - renderRow(item) { let criteria = {}; let groupRowEl; diff --git a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js new file mode 100644 index 00000000000..10f6c28a7bf --- /dev/null +++ b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js @@ -0,0 +1,45 @@ +import axios from '~/lib/utils/axios_utils'; + +const USERS_PATH = '/-/autocomplete/users.json'; +const GROUPS_PATH = '/-/autocomplete/project_groups.json'; +const DEPLOY_KEYS_PATH = '/-/autocomplete/deploy_keys_with_owners.json'; + +const buildUrl = (urlRoot, url) => { + let newUrl; + if (urlRoot != null) { + newUrl = urlRoot.replace(/\/$/, '') + url; + } + return newUrl; +}; + +export const getUsers = (query) => { + return axios.get(buildUrl(gon.relative_url_root || '', USERS_PATH), { + params: { + search: query, + per_page: 20, + active: true, + project_id: gon.current_project_id, + push_code: true, + }, + }); +}; + +export const getGroups = () => { + return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH), { + params: { + project_id: gon.current_project_id, + }, + }); +}; + +export const getDeployKeys = (query) => { + return axios.get(buildUrl(gon.relative_url_root || '', DEPLOY_KEYS_PATH), { + params: { + search: query, + per_page: 20, + active: true, + project_id: gon.current_project_id, + push_code: true, + }, + }); +}; diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue new file mode 100644 index 00000000000..9823b0229a0 --- /dev/null +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -0,0 +1,409 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, + GlSearchBoxByType, + GlAvatar, + GlSprintf, +} from '@gitlab/ui'; +import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash'; +import createFlash from '~/flash'; +import { __, s__, n__ } from '~/locale'; +import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api'; +import { LEVEL_TYPES, ACCESS_LEVELS } from '../constants'; + +export const i18n = { + selectUsers: s__('ProtectedEnvironment|Select users'), + rolesSectionHeader: s__('AccessDropdown|Roles'), + groupsSectionHeader: s__('AccessDropdown|Groups'), + usersSectionHeader: s__('AccessDropdown|Users'), + deployKeysSectionHeader: s__('AccessDropdown|Deploy Keys'), + ownedBy: __('Owned by %{image_tag}'), +}; + +export default { + i18n, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, + GlSearchBoxByType, + GlAvatar, + GlSprintf, + }, + props: { + accessLevelsData: { + type: Array, + required: true, + }, + accessLevel: { + required: true, + type: String, + }, + hasLicense: { + required: false, + type: Boolean, + default: true, + }, + label: { + type: String, + required: false, + default: i18n.selectUsers, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + preselectedItems: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + loading: false, + initialLoading: false, + query: '', + users: [], + groups: [], + roles: [], + deployKeys: [], + selected: { + [LEVEL_TYPES.GROUP]: [], + [LEVEL_TYPES.USER]: [], + [LEVEL_TYPES.ROLE]: [], + [LEVEL_TYPES.DEPLOY_KEY]: [], + }, + }; + }, + computed: { + preselected() { + return groupBy(this.preselectedItems, 'type'); + }, + showDeployKeys() { + return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length; + }, + toggleLabel() { + const counts = Object.entries(this.selected).reduce((acc, [key, value]) => { + acc[key] = value.length; + return acc; + }, {}); + + const isOnlyRoleSelected = + counts[LEVEL_TYPES.ROLE] === 1 && + [counts[LEVEL_TYPES.USER], counts[LEVEL_TYPES.GROUP], counts[LEVEL_TYPES.DEPLOY_KEY]].every( + (count) => count === 0, + ); + + if (isOnlyRoleSelected) { + return this.selected[LEVEL_TYPES.ROLE][0].text; + } + + const labelPieces = []; + + if (counts[LEVEL_TYPES.ROLE] > 0) { + labelPieces.push(n__('1 role', '%d roles', counts[LEVEL_TYPES.ROLE])); + } + + if (counts[LEVEL_TYPES.USER] > 0) { + labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER])); + } + + if (counts[LEVEL_TYPES.DEPLOY_KEY] > 0) { + labelPieces.push(n__('1 deploy key', '%d deploy keys', counts[LEVEL_TYPES.DEPLOY_KEY])); + } + + if (counts[LEVEL_TYPES.GROUP] > 0) { + labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP])); + } + + return labelPieces.join(', ') || this.label; + }, + toggleClass() { + return this.toggleLabel === this.label ? 'gl-text-gray-500!' : ''; + }, + selection() { + return [ + ...this.getDataForSave(LEVEL_TYPES.ROLE, 'access_level'), + ...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id'), + ...this.getDataForSave(LEVEL_TYPES.USER, 'user_id'), + ...this.getDataForSave(LEVEL_TYPES.DEPLOY_KEY, 'deploy_key_id'), + ]; + }, + }, + watch: { + query: debounce(function debouncedSearch() { + return this.getData(); + }, 500), + }, + created() { + this.getData({ initial: true }); + }, + methods: { + focusInput() { + this.$refs.search.focusInput(); + }, + getData({ initial = false } = {}) { + this.initialLoading = initial; + this.loading = true; + + if (this.hasLicense) { + Promise.all([ + getDeployKeys(this.query), + getUsers(this.query), + this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(), + ]) + .then(([deployKeysResponse, usersResponse, groupsResponse]) => { + this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data); + this.setSelected({ initial }); + }) + .catch(() => + createFlash({ message: __('Failed to load groups, users and deploy keys.') }), + ) + .finally(() => { + this.initialLoading = false; + this.loading = false; + }); + } else { + getDeployKeys(this.query) + .then((deployKeysResponse) => { + this.consolidateData(deployKeysResponse.data); + this.setSelected({ initial }); + }) + .catch(() => createFlash({ message: __('Failed to load deploy keys.') })) + .finally(() => { + this.initialLoading = false; + this.loading = false; + }); + } + }, + consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) { + // This re-assignment is intentional as level.type property is being used for comparision, + // and accessLevelsData is provided by gon.create_access_levels which doesn't have `type` included. + // See this discussion https://gitlab.com/gitlab-org/gitlab/merge_requests/1629#note_31285823 + this.roles = this.accessLevelsData.map((role) => ({ ...role, type: LEVEL_TYPES.ROLE })); + + if (this.hasLicense) { + this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP })); + this.users = usersResponse.map(({ id, name, username, avatar_url }) => ({ + id, + name, + username, + avatar_url, + type: LEVEL_TYPES.USER, + })); + } + + this.deployKeys = deployKeysResponse.map((response) => { + const { + id, + fingerprint, + title, + owner: { avatar_url, name, username }, + } = response; + + const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`; + + return { + id, + title: title.concat(' ', shortFingerprint), + avatar_url, + fullname: name, + username, + type: LEVEL_TYPES.DEPLOY_KEY, + }; + }); + }, + setSelected({ initial } = {}) { + if (initial) { + // as all available groups && roles are always visible in the dropdown, we set local selected by looking + // for intersection in all roles/groups and initial selected (returned from BE). + // It is different for the users - not all the users will be returned on the first data load (another set + // will be returned on search, only first 20 are displayed initially). + // That is why we set ALL initial selected users (returned from BE) as local selected (not looking + // for the intersection with all users data) and later if the selected happens to be in the users list + // we filter it out from the list so that not to have duplicates + // TODO: we'll need to get back to how to handle deploy keys here but they are out of scope + // and will be checked when migrating protected branches access dropdown to the current component + // related issue - https://gitlab.com/gitlab-org/gitlab/-/issues/284784 + const selectedRoles = intersectionWith( + this.roles, + this.preselectedItems, + (role, selected) => { + return selected.type === LEVEL_TYPES.ROLE && role.id === selected.access_level; + }, + ); + this.selected[LEVEL_TYPES.ROLE] = selectedRoles; + + const selectedGroups = intersectionWith( + this.groups, + this.preselectedItems, + (group, selected) => { + return selected.type === LEVEL_TYPES.GROUP && group.id === selected.group_id; + }, + ); + this.selected[LEVEL_TYPES.GROUP] = selectedGroups; + + const selectedDeployKeys = intersectionWith( + this.deployKeys, + this.preselectedItems, + (key, selected) => { + return selected.type === LEVEL_TYPES.DEPLOY_KEY && key.id === selected.deploy_key_id; + }, + ); + this.selected[LEVEL_TYPES.DEPLOY_KEY] = selectedDeployKeys; + + const selectedUsers = this.preselectedItems + .filter(({ type }) => type === LEVEL_TYPES.USER) + .map(({ user_id, name, username, avatar_url, type }) => ({ + id: user_id, + name, + username, + avatar_url, + type, + })); + + this.selected[LEVEL_TYPES.USER] = selectedUsers; + } + + this.users = this.users.filter( + (user) => !this.selected[LEVEL_TYPES.USER].some((selected) => selected.id === user.id), + ); + this.users.unshift(...this.selected[LEVEL_TYPES.USER]); + }, + getDataForSave(accessType, key) { + const selected = this.selected[accessType].map(({ id }) => ({ [key]: id })); + const preselected = this.preselected[accessType]; + const added = differenceBy(selected, preselected, key); + const preserved = intersectionBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({ + id, + [key]: keyId, + })); + const removed = differenceBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({ + id, + [key]: keyId, + _destroy: true, + })); + return [...added, ...removed, ...preserved]; + }, + onItemClick(item) { + this.toggleSelection(this.selected[item.type], item); + this.emitUpdate(); + }, + toggleSelection(arr, item) { + const itemIndex = arr.findIndex(({ id }) => id === item.id); + if (itemIndex > -1) { + arr.splice(itemIndex, 1); + } else arr.push(item); + }, + isSelected(item) { + return this.selected[item.type].some((selected) => selected.id === item.id); + }, + emitUpdate() { + this.$emit('select', this.selection); + }, + onHide() { + this.$emit('hidden', this.selection); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :disabled="disabled || initialLoading" + :text="toggleLabel" + class="gl-min-w-20" + :toggle-class="toggleClass" + aria-labelledby="allowed-users-label" + @shown="focusInput" + @hidden="onHide" + > + <template #header> + <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" /> + </template> + <template v-if="roles.length"> + <gl-dropdown-section-header>{{ + $options.i18n.rolesSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="role in roles" + :key="`${role.id}${role.text}`" + data-testid="role-dropdown-item" + is-check-item + :is-checked="isSelected(role)" + @click.native.capture.stop="onItemClick(role)" + > + {{ role.text }} + </gl-dropdown-item> + <gl-dropdown-divider v-if="groups.length || users.length || showDeployKeys" /> + </template> + + <template v-if="groups.length"> + <gl-dropdown-section-header>{{ + $options.i18n.groupsSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="group in groups" + :key="`${group.id}${group.name}`" + fingerprint + data-testid="group-dropdown-item" + :avatar-url="group.avatar_url" + is-check-item + :is-checked="isSelected(group)" + @click.native.capture.stop="onItemClick(group)" + > + {{ group.name }} + </gl-dropdown-item> + <gl-dropdown-divider v-if="users.length || showDeployKeys" /> + </template> + + <template v-if="users.length"> + <gl-dropdown-section-header>{{ + $options.i18n.usersSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="user in users" + :key="`${user.id}${user.username}`" + data-testid="user-dropdown-item" + :avatar-url="user.avatar_url" + :secondary-text="user.username" + is-check-item + :is-checked="isSelected(user)" + @click.native.capture.stop="onItemClick(user)" + > + {{ user.name }} + </gl-dropdown-item> + <gl-dropdown-divider v-if="showDeployKeys" /> + </template> + + <template v-if="showDeployKeys"> + <gl-dropdown-section-header>{{ + $options.i18n.deployKeysSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="key in deployKeys" + :key="`${key.id}${key.fingerprint}`" + data-testid="deploy_key-dropdown-item" + is-check-item + :is-checked="isSelected(key)" + class="gl-text-truncate" + @click.native.capture.stop="onItemClick(key)" + > + <div class="gl-text-truncate gl-font-weight-bold">{{ key.title }}</div> + <div class="gl-text-gray-700 gl-text-truncate"> + <gl-sprintf :message="$options.i18n.ownedBy"> + <template #image_tag> + <gl-avatar :src="key.avatar_url" :size="24" /> + </template> </gl-sprintf + >{{ key.fullname }} ({{ key.username }}) + </div> + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/projects/settings/init_access_dropdown.js b/app/assets/javascripts/projects/settings/init_access_dropdown.js new file mode 100644 index 00000000000..11272652b63 --- /dev/null +++ b/app/assets/javascripts/projects/settings/init_access_dropdown.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; +import Vue from 'vue'; +import AccessDropdown from './components/access_dropdown.vue'; + +export const initAccessDropdown = (el, options) => { + if (!el) { + return false; + } + + const { accessLevelsData, accessLevel } = options; + const { label, disabled, preselectedItems } = el.dataset; + let preselected = []; + try { + preselected = JSON.parse(preselectedItems); + } catch (e) { + Sentry.captureException(e); + } + + return new Vue({ + el, + render(createElement) { + const vm = this; + return createElement(AccessDropdown, { + props: { + accessLevel, + accessLevelsData: accessLevelsData.roles, + preselectedItems: preselected, + label, + disabled, + }, + on: { + select(selected) { + vm.$emit('select', selected); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue index eecb3573046..befbca48736 100644 --- a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue +++ b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue @@ -1,8 +1,16 @@ <script> -import { GlButton, GlFormGroup, GlFormInput, GlModal, GlModalDirective } from '@gitlab/ui'; +import { + GlButton, + GlFormGroup, + GlFormInput, + GlModal, + GlModalDirective, + GlSprintf, + GlLink, +} from '@gitlab/ui'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; export default { @@ -13,6 +21,8 @@ export default { GlFormInput, GlModal, ClipboardButton, + GlSprintf, + GlLink, }, directives: { 'gl-modal': GlModalDirective, @@ -44,16 +54,6 @@ export default { data() { return { authorizationKey: this.initialAuthorizationKey, - sectionDescription: sprintf( - __( - 'To receive alerts from manually configured Prometheus services, add the following URL and Authorization key to your Prometheus webhook config file. Learn more about %{linkStart}configuring Prometheus%{linkEnd} to send alerts to GitLab.', - ), - { - linkStart: `<a href="${this.learnMoreUrl}" target="_blank" rel="noopener noreferrer">`, - linkEnd: '</a>', - }, - false, - ), }; }, methods: { @@ -84,7 +84,17 @@ export default { </p> </div> <div class="col-lg-9"> - <p v-html="sectionDescription /* eslint-disable-line vue/no-v-html */"></p> + <gl-sprintf + :message=" + __( + 'To receive alerts from manually configured Prometheus services, add the following URL and Authorization key to your Prometheus webhook config file. Learn more about %{linkStart}configuring Prometheus%{linkEnd} to send alerts to GitLab.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="learnMoreUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> <gl-form-group :label="__('URL')" label-for="notify-url" label-class="label-bold"> <div class="input-group"> <gl-form-input id="notify-url" :readonly="true" :value="notifyUrl" /> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue index 45eb2ce51e4..0556fd298aa 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -1,5 +1,12 @@ <script> -import { GlFormCheckbox, GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui'; +import { + GlFormCheckbox, + GlTooltipDirective, + GlSprintf, + GlIcon, + GlDropdown, + GlDropdownItem, +} from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { n__ } from '~/locale'; @@ -11,22 +18,22 @@ import { REMOVE_TAG_BUTTON_TITLE, DIGEST_LABEL, CREATED_AT_LABEL, - REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, PUBLISHED_DETAILS_ROW_TEXT, MANIFEST_DETAILS_ROW_TEST, CONFIGURATION_DETAILS_ROW_TEST, MISSING_MANIFEST_WARNING_TOOLTIP, NOT_AVAILABLE_TEXT, NOT_AVAILABLE_SIZE, + MORE_ACTIONS_TEXT, } from '../../constants/index'; -import DeleteButton from '../delete_button.vue'; export default { components: { GlSprintf, GlFormCheckbox, GlIcon, - DeleteButton, + GlDropdown, + GlDropdownItem, ListItem, ClipboardButton, TimeAgoTooltip, @@ -60,11 +67,11 @@ export default { REMOVE_TAG_BUTTON_TITLE, DIGEST_LABEL, CREATED_AT_LABEL, - REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, PUBLISHED_DETAILS_ROW_TEXT, MANIFEST_DETAILS_ROW_TEST, CONFIGURATION_DETAILS_ROW_TEST, MISSING_MANIFEST_WARNING_TOOLTIP, + MORE_ACTIONS_TEXT, }, computed: { formattedSize() { @@ -173,15 +180,27 @@ export default { </span> </template> <template #right-action> - <delete-button + <gl-dropdown :disabled="isDeleteDisabled" - :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP" - :tooltip-disabled="tag.canDelete" - data-qa-selector="tag_delete_button" - data-testid="single-delete-button" - @delete="$emit('delete')" - /> + icon="ellipsis_v" + :text="$options.i18n.MORE_ACTIONS_TEXT" + :text-sr-only="true" + category="tertiary" + no-caret + right + :class="{ 'gl-opacity-0 gl-pointer-events-none': isDeleteDisabled }" + data-testid="additional-actions" + data-qa-selector="more_actions_menu" + > + <gl-dropdown-item + variant="danger" + data-testid="single-delete-button" + data-qa-selector="tag_delete_button" + @click="$emit('delete')" + > + {{ $options.i18n.REMOVE_TAG_BUTTON_TITLE }} + </gl-dropdown-item> + </gl-dropdown> </template> <template v-if="!isInvalidTag" #details-published> diff --git a/app/assets/javascripts/registry/explorer/constants/common.js b/app/assets/javascripts/registry/explorer/constants/common.js index dc71ef8450b..f7beec2c935 100644 --- a/app/assets/javascripts/registry/explorer/constants/common.js +++ b/app/assets/javascripts/registry/explorer/constants/common.js @@ -1,3 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image'); +export const MORE_ACTIONS_TEXT = __('More actions'); diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index 0836260b71e..19e1a75fb2f 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -30,7 +30,7 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__( 'ContainerRegistry|Configuration digest: %{digest}', ); -export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); +export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Delete tag'); export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected tags'); export const REMOVE_TAG_CONFIRMATION_TEXT = s__( @@ -61,10 +61,6 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__( 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', ); -export const REMOVE_TAG_BUTTON_DISABLE_TOOLTIP = s__( - 'ContainerRegistry|Deletion disabled due to missing or insufficient permissions.', -); - export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( 'ContainerRegistry|Invalid tag: missing manifest digest', ); diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js index f59b9d7a9f5..d21a154d1b8 100644 --- a/app/assets/javascripts/registry/explorer/constants/list.js +++ b/app/assets/javascripts/registry/explorer/constants/list.js @@ -5,7 +5,7 @@ import { s__, __ } from '~/locale'; export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry'); export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error'); export const CONNECTION_ERROR_MESSAGE = s__( - `ContainerRegistry|We are having trouble connecting to the Registry, which could be due to an issue with your project name or path. %{docLinkStart}More information%{docLinkEnd}`, + `ContainerRegistry|We are having trouble connecting to the Container Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`, ); export const LIST_INTRO_TEXT = s__( `ContainerRegistry|With the GitLab Container Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`, diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js index 1f82fd7f238..246a6768593 100644 --- a/app/assets/javascripts/registry/explorer/index.js +++ b/app/assets/javascripts/registry/explorer/index.js @@ -36,6 +36,8 @@ export default () => { isAdmin, showCleanupPolicyOnAlert, showUnfinishedTagCleanupCallout, + connectionError, + invalidPathError, ...config } = el.dataset; @@ -67,6 +69,8 @@ export default () => { isAdmin: parseBoolean(isAdmin), showCleanupPolicyOnAlert: parseBoolean(showCleanupPolicyOnAlert), showUnfinishedTagCleanupCallout: parseBoolean(showUnfinishedTagCleanupCallout), + connectionError: parseBoolean(connectionError), + invalidPathError: parseBoolean(invalidPathError), }, /* eslint-disable @gitlab/require-i18n-strings */ dockerBuildCommand: `docker build -t ${config.repositoryUrl} .`, diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 3c8790fa6e5..73b957f42f2 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -171,6 +171,9 @@ export default { showDeleteAlert() { return this.deleteAlertType && this.itemToDelete?.path; }, + showConnectionError() { + return this.config.connectionError || this.config.invalidPathError; + }, deleteImageAlertMessage() { return this.deleteAlertType === 'success' ? DELETE_IMAGE_SUCCESS_MESSAGE @@ -292,7 +295,7 @@ export default { /> <gl-empty-state - v-if="config.characterError" + v-if="showConnectionError" :title="$options.i18n.CONNECTION_ERROR_TITLE" :svg-path="config.containersErrorImage" > diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue index 02929062cee..f936c03c5d3 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -74,6 +74,16 @@ export default { required: false, default: false, }, + autoCompleteEpics: { + type: Boolean, + required: false, + default: true, + }, + autoCompleteIssues: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -177,7 +187,7 @@ export default { :path-id-separator="pathIdSeparator" :input-value="inputValue" :auto-complete-sources="transformedAutocompleteSources" - :auto-complete-options="{ issues: true, epics: true }" + :auto-complete-options="{ issues: autoCompleteIssues, epics: autoCompleteEpics }" :issuable-type="issuableType" @pendingIssuableRemoveRequest="onPendingIssuableRemoveRequest" @formCancel="onFormCancel" @@ -187,15 +197,15 @@ export default { <p v-if="hasError" class="gl-field-error"> {{ addRelatedErrorMessage }} </p> - <div class="add-issuable-form-actions clearfix"> + <div class="gl-mt-5 gl-clearfix"> <gl-button ref="addButton" category="primary" - variant="success" + variant="confirm" :disabled="isSubmitButtonDisabled" :loading="isSubmitting" type="submit" - class="js-add-issuable-form-add-button float-left" + class="float-left" data-qa-selector="add_issue_button" > {{ __('Add') }} diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue index 9665ed173b9..abbd612d3ec 100644 --- a/app/assets/javascripts/related_issues/components/issue_token.vue +++ b/app/assets/javascripts/related_issues/components/issue_token.vue @@ -48,7 +48,7 @@ export default { <template> <div :class="{ - 'issue-token': isCondensed, + 'issue-token gl-display-inline-flex gl-align-items-stretch gl-max-w-full gl-line-height-24 gl-white-space-nowrap': isCondensed, 'flex-row issuable-info-container': !isCondensed, }" > @@ -57,7 +57,7 @@ export default { ref="link" v-gl-tooltip :class="{ - 'issue-token-link': isCondensed, + 'issue-token-link gl-display-inline-flex gl-min-w-0 gl-text-gray-500': isCondensed, 'issuable-main-info': !isCondensed, }" :href="computedPath" @@ -69,19 +69,19 @@ export default { v-if="hasTitle" ref="title" :class="{ - 'issue-token-title issue-token-end': isCondensed, + 'issue-token-title issue-token-end gl-overflow-hidden gl-display-flex gl-align-items-baseline gl-text-gray-500 gl-pl-3': isCondensed, 'issue-title block-truncated': !isCondensed, - 'issue-token-title-standalone': !canRemove, + 'gl-rounded-top-right-small gl-rounded-bottom-right-small gl-pr-3': !canRemove, }" class="js-issue-token-title" > - <span class="issue-token-title-text">{{ title }}</span> + <span class="gl-text-truncate">{{ title }}</span> </component> <component :is="innerComponentType" ref="reference" :class="{ - 'issue-token-reference': isCondensed, + 'issue-token-reference gl-display-flex gl-align-items-center gl-rounded-top-left-small gl-rounded-bottom-left-small gl-px-3': isCondensed, 'issuable-info': !isCondensed, }" > @@ -103,7 +103,7 @@ export default { ref="removeButton" v-gl-tooltip :class="{ - 'issue-token-remove-button': isCondensed, + 'issue-token-remove-button gl-display-flex gl-align-items-center gl-px-3 gl-border-0 gl-rounded-top-right-small gl-rounded-bottom-right-small gl-text-gray-500': isCondensed, 'btn btn-default': !isCondensed, }" :title="removeButtonLabel" @@ -111,7 +111,6 @@ export default { :disabled="removeDisabled" data-testid="removeBtn" type="button" - class="js-issue-token-remove-button" @click="onRemoveRequest" > <gl-icon name="close" /> diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index 46b97370d66..270d4632a54 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -107,9 +107,6 @@ export default { onAutoCompleteToggled(isOpen) { this.isAutoCompleteOpen = isOpen; }, - onInputWrapperClick() { - this.$refs.input.focus(); - }, onInput() { const { value } = this.$refs.input; const caretPos = this.$refs.input.selectionStart; @@ -185,26 +182,23 @@ export default { <div ref="issuableFormWrapper" :class="{ focus: isInputFocused }" - class="add-issuable-form-input-wrapper form-control gl-field-error-outline" + class="add-issuable-form-input-wrapper form-control gl-field-error-outline gl-h-auto gl-p-3 gl-pb-2" role="button" @click="onIssuableFormWrapperClick" > - <ul class="add-issuable-form-input-token-list"> - <!-- - We need to ensure this key changes any time the pendingReferences array is updated - else two consecutive pending ref strings in an array with the same name will collide - and cause odd behavior when one is removed. - --> + <ul + class="gl-display-flex gl-flex-wrap gl-align-items-baseline gl-list-style-none gl-m-0 gl-p-0" + > <li v-for="(reference, index) in references" - :key="`related-issues-token-${reference}`" - class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item" + :key="reference" + class="gl-max-w-full gl-mb-2 gl-mr-2" > <issue-token :id-key="index" :display-reference="reference.text || reference" - :can-remove="true" - :is-condensed="true" + can-remove + is-condensed :path-id-separator="pathIdSeparator" event-namespace="pendingIssuable" @pendingIssuableRemoveRequest=" @@ -214,14 +208,15 @@ export default { " /> </li> - <li class="add-issuable-form-input-list-item"> + <li class="gl-mb-2 gl-flex-grow-1"> <input :id="inputId" ref="input" :value="inputValue" :placeholder="inputPlaceholder" + :aria-label="inputPlaceholder" type="text" - class="js-add-issuable-form-input add-issuable-form-input" + class="gl-w-full gl-border-none gl-outline-0" data-qa-selector="add_issue_field" autocomplete="off" @input="onInput" diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue index c042f0eef5f..94535e1b8c9 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -123,7 +123,7 @@ export default { </script> <template> - <div id="related-issues" class="related-issues-block"> + <div id="related-issues" class="related-issues-block gl-mt-5"> <div class="card card-slim gl-overflow-hidden"> <div :class="{ 'panel-empty-heading border-bottom-0': !hasBody }" @@ -162,7 +162,6 @@ export default { icon="plus" :aria-label="__('Add a related issue')" :class="qaClass" - class="js-issue-count-badge-add-button" @click="$emit('toggleAddRelatedIssuesForm', $event)" /> </div> 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 8f486fb1b07..a21e294a34a 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -97,11 +97,7 @@ export default { class="related-issues-token-body bordered-box bg-white" :class="{ 'sortable-container': canReorder }" > - <div - v-if="isFetching" - class="related-issues-loading-icon" - data-qa-selector="related_issues_loading_placeholder" - > + <div v-if="isFetching" class="gl-mb-2" data-qa-selector="related_issues_loading_placeholder"> <gl-loading-icon ref="loadingIcon" size="sm" diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue index 6fb1d1ed365..05858c7469d 100644 --- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue @@ -81,13 +81,13 @@ export default { {{ __('Related merge requests') }} </span> <div v-if="totalCount" class="d-inline-flex lh-100 align-middle"> - <div class="mr-count-badge gl-display-inline-flex"> - <div class="mr-count-badge-count"> - <svg class="s16 mr-1 text-secondary"> - <gl-icon name="merge-request" class="mr-1 text-secondary" /> - </svg> - <span class="js-items-count">{{ totalCount }}</span> - </div> + <div + class="mr-count-badge gl-display-inline-flex gl-align-items-center gl-py-2 gl-px-3" + > + <svg class="s16 mr-1 text-secondary"> + <gl-icon name="merge-request" class="mr-1 text-secondary" /> + </svg> + <span class="js-items-count">{{ totalCount }}</span> </div> </div> </div> diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index 3201ca1f443..b2bd405574f 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -1,4 +1,5 @@ <script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import $ from 'jquery'; import { isEmpty } from 'lodash'; import { scrollToElement } from '~/lib/utils/common_utils'; @@ -21,6 +22,9 @@ export default { ReleaseBlockHeader, ReleaseBlockMilestoneInfo, }, + directives: { + SafeHtml, + }, mixins: [glFeatureFlagsMixin()], props: { release: { @@ -79,6 +83,7 @@ export default { $(this.$refs['gfm-content']).renderGFM(); }, }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; </script> <template> @@ -102,10 +107,7 @@ export default { <evidence-block v-if="hasEvidence" :release="release" /> <div ref="gfm-content" class="card-text gl-mt-3"> - <div - class="md" - v-html="release.descriptionHtml /* eslint-disable-line vue/no-v-html */" - ></div> + <div v-safe-html:[$options.safeHtmlConfig]="release.descriptionHtml" class="md"></div> </div> </div> diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js index 7272880197a..686f9e294b7 100644 --- a/app/assets/javascripts/releases/mount_show.js +++ b/app/assets/javascripts/releases/mount_show.js @@ -6,7 +6,12 @@ import ReleaseShowApp from './components/app_show.vue'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), }); export default () => { diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue index 736c8668a34..59bd54eab60 100644 --- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue +++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue @@ -33,17 +33,20 @@ export default { issueName() { return `${this.severityLabel} - ${this.issue.name}`; }, + issueSeverity() { + return this.issue.severity.toLowerCase(); + }, isStatusSuccess() { return this.status === STATUS_SUCCESS; }, severityClass() { - return SEVERITY_CLASSES[this.issue.severity] || SEVERITY_CLASSES.unknown; + return SEVERITY_CLASSES[this.issueSeverity] || SEVERITY_CLASSES.unknown; }, severityIcon() { - return SEVERITY_ICONS[this.issue.severity] || SEVERITY_ICONS.unknown; + return SEVERITY_ICONS[this.issueSeverity] || SEVERITY_ICONS.unknown; }, severityLabel() { - return this.$options.severityText[this.issue.severity] || this.$options.severityText.unknown; + return this.$options.severityText[this.issueSeverity] || this.$options.severityText.unknown; }, }, severityText: { diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue index 0e18d0992cd..599e8d35708 100644 --- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue +++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue @@ -55,10 +55,12 @@ export default { ...mapActions(['fetchReports', 'setPaths']), }, loadingText: sprintf(s__('ciReport|Loading %{reportName} report'), { - reportName: 'codeclimate', + // eslint-disable-next-line @gitlab/require-i18n-strings + reportName: 'Code quality', }), errorText: sprintf(s__('ciReport|Failed to load %{reportName} report'), { - reportName: 'codeclimate', + // eslint-disable-next-line @gitlab/require-i18n-strings + reportName: 'Code quality', }), }; </script> diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js index 3fb8c5be351..4712f8cbefe 100644 --- a/app/assets/javascripts/reports/codequality_report/store/getters.js +++ b/app/assets/javascripts/reports/codequality_report/store/getters.js @@ -1,5 +1,5 @@ import { spriteIcon } from '~/lib/utils/common_utils'; -import { sprintf, __, s__, n__ } from '~/locale'; +import { sprintf, s__ } from '~/locale'; import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '../../constants'; export const hasCodequalityIssues = (state) => @@ -18,27 +18,23 @@ export const codequalityStatus = (state) => { export const codequalityText = (state) => { const { newIssues, resolvedIssues } = state; - const text = []; - + let text; if (!newIssues.length && !resolvedIssues.length) { - text.push(s__('ciReport|No changes to code quality')); - } else { - text.push(s__('ciReport|Code quality')); - - if (resolvedIssues.length) { - text.push(n__(' improved on %d point', ' improved on %d points', resolvedIssues.length)); - } - - if (newIssues.length && resolvedIssues.length) { - text.push(__(' and')); - } - - if (newIssues.length) { - text.push(n__(' degraded on %d point', ' degraded on %d points', newIssues.length)); - } + text = s__('ciReport|No changes to code quality'); + } else if (newIssues.length && resolvedIssues.length) { + text = sprintf( + s__(`ciReport|Code quality scanning detected %{issueCount} changes in merged results`), + { + issueCount: newIssues.length + resolvedIssues.length, + }, + ); + } else if (resolvedIssues.length) { + text = s__(`ciReport|Code quality improved`); + } else if (newIssues.length) { + text = s__(`ciReport|Code quality degraded`); } - return text.join(''); + return text; }; export const codequalityPopover = (state) => { diff --git a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js index a794f5f0577..417297df43c 100644 --- a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js +++ b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js @@ -1,14 +1,16 @@ -export const parseCodeclimateMetrics = (issues = [], path = '') => { +export const parseCodeclimateMetrics = (issues = [], blobPath = '') => { return issues.map((issue) => { + // the `file_path` attribute from the artifact is returned as `file` by GraphQL + const issuePath = issue.file_path || issue.path; const parsedIssue = { name: issue.description, - path: issue.file_path, - urlPath: `${path}/${issue.file_path}#L${issue.line}`, + path: issuePath, + urlPath: `${blobPath}/${issuePath}#L${issue.line}`, ...issue, }; if (issue?.location?.path) { - let parseCodeQualityUrl = `${path}/${issue.location.path}`; + let parseCodeQualityUrl = `${blobPath}/${issue.location.path}`; parsedIssue.path = issue.location.path; if (issue?.location?.lines?.begin) { diff --git a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue index 82806793401..be49a03a9a5 100644 --- a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue @@ -3,7 +3,6 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import api from '~/api'; import { sprintf, s__ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import GroupedIssuesList from '../components/grouped_issues_list.vue'; import { componentNames } from '../components/issue_body'; import ReportSection from '../components/report_section.vue'; @@ -28,7 +27,6 @@ export default { GlButton, GlIcon, }, - mixins: [glFeatureFlagsMixin()], props: { endpoint: { type: String, @@ -82,9 +80,7 @@ export default { methods: { ...mapActions(['setPaths', 'fetchReports', 'closeModal']), handleToggleEvent() { - if (this.glFeatures.usageDataITestingSummaryWidgetTotal) { - api.trackRedisHllUserEvent(this.$options.expandEvent); - } + api.trackRedisHllUserEvent(this.$options.expandEvent); }, reportText(report) { const { name, summary } = report || {}; diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js new file mode 100644 index 00000000000..504efaea8cc --- /dev/null +++ b/app/assets/javascripts/repository/commits_service.js @@ -0,0 +1,65 @@ +import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { normalizeData } from 'ee_else_ce/repository/utils/commit'; +import createFlash from '~/flash'; +import { COMMIT_BATCH_SIZE, I18N_COMMIT_DATA_FETCH_ERROR } from './constants'; + +let requestedOffsets = []; +let fetchedBatches = []; + +export const isRequested = (offset) => requestedOffsets.includes(offset); + +export const resetRequestedCommits = () => { + requestedOffsets = []; + fetchedBatches = []; +}; + +const addRequestedOffset = (offset) => { + if (isRequested(offset) || offset < 0) { + return; + } + + requestedOffsets.push(offset); +}; + +const removeLeadingSlash = (path) => path.replace(/^\//, ''); + +const fetchData = (projectPath, path, ref, offset) => { + if (fetchedBatches.includes(offset) || offset < 0) { + return []; + } + + fetchedBatches.push(offset); + + const url = joinPaths( + gon.relative_url_root || '/', + projectPath, + '/-/refs/', + ref, + '/logs_tree/', + encodeURIComponent(removeLeadingSlash(path)), + ); + + return axios + .get(url, { params: { format: 'json', offset } }) + .then(({ data }) => normalizeData(data, path)) + .catch(() => createFlash({ message: I18N_COMMIT_DATA_FETCH_ERROR })); +}; + +export const loadCommits = async (projectPath, path, ref, offset) => { + if (isRequested(offset)) { + return []; + } + + // We fetch in batches of 25, so this ensures we don't refetch + Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => { + addRequestedOffset(offset - i); + addRequestedOffset(offset + i); + }); + + // Since a user could scroll either up or down, we want to support lazy loading in both directions + const commitsBatchUp = await fetchData(projectPath, path, ref, offset - COMMIT_BATCH_SIZE); + const commitsBatchDown = await fetchData(projectPath, path, ref, offset); + + return commitsBatchUp.concat(commitsBatchDown); +}; diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 1d79818cbe8..7ad9fb56972 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -8,10 +8,12 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; +import { redirectTo } from '~/lib/utils/url_utility'; import getRefMixin from '../mixins/get_ref'; import blobInfoQuery from '../queries/blob_info.query.graphql'; import BlobButtonGroup from './blob_button_group.vue'; import BlobEdit from './blob_edit.vue'; +import ForkSuggestion from './fork_suggestion.vue'; import { loadViewer, viewerProps } from './blob_viewers'; export default { @@ -21,6 +23,7 @@ export default { BlobButtonGroup, BlobContent, GlLoadingIcon, + ForkSuggestion, }, mixins: [getRefMixin], inject: { @@ -42,9 +45,6 @@ export default { this.switchViewer( this.hasRichViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER, ); - if (this.hasRichViewer && !this.blobViewer) { - this.loadLegacyViewer(); - } }, error() { this.displayError(); @@ -68,7 +68,9 @@ export default { }, data() { return { + forkTarget: null, legacyRichViewer: null, + legacySimpleViewer: null, isBinary: false, isLoadingLegacyViewer: false, activeViewerType: SIMPLE_BLOB_VIEWER, @@ -76,6 +78,8 @@ export default { userPermissions: { pushCode: false, downloadCode: false, + createMergeRequestIn: false, + forkProject: false, }, pathLocks: { nodes: [], @@ -94,12 +98,14 @@ export default { path: '', editBlobPath: '', ideEditPath: '', + forkAndEditPath: '', + ideForkAndEditPath: '', storedExternally: false, + canModifyBlob: false, rawPath: '', externalStorageUrl: '', replacePath: '', deletePath: '', - forkPath: '', simpleViewer: {}, richViewer: null, webPath: '', @@ -115,7 +121,7 @@ export default { return isLoggedIn(); }, isLoading() { - return this.$apollo.queries.project.loading || this.isLoadingLegacyViewer; + return this.$apollo.queries.project.loading; }, isBinaryFileType() { return this.isBinary || this.blobInfo.simpleViewer?.fileType !== 'text'; @@ -151,24 +157,66 @@ export default { isLocked() { return this.project.pathLocks.nodes.some((node) => node.path === this.path); }, + showForkSuggestion() { + const { createMergeRequestIn, forkProject } = this.project.userPermissions; + const { canModifyBlob } = this.blobInfo; + + return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject; + }, + forkPath() { + return this.forkTarget === 'ide' + ? this.blobInfo.ideForkAndEditPath + : this.blobInfo.forkAndEditPath; + }, }, methods: { - loadLegacyViewer() { + loadLegacyViewer(type) { + if (this.legacyViewerLoaded(type)) { + return; + } + this.isLoadingLegacyViewer = true; axios - .get(`${this.blobInfo.webPath}?format=json&viewer=rich`) + .get(`${this.blobInfo.webPath}?format=json&viewer=${type}`) .then(({ data: { html, binary } }) => { - this.legacyRichViewer = html; + if (type === 'simple') { + this.legacySimpleViewer = html; + } else { + this.legacyRichViewer = html; + } + this.isBinary = binary; this.isLoadingLegacyViewer = false; }) .catch(() => this.displayError()); }, + legacyViewerLoaded(type) { + return ( + (type === SIMPLE_BLOB_VIEWER && this.legacySimpleViewer) || + (type === RICH_BLOB_VIEWER && this.legacyRichViewer) + ); + }, displayError() { createFlash({ message: __('An error occurred while loading the file. Please try again.') }); }, switchViewer(newViewer) { this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER; + + if (!this.blobViewer) { + this.loadLegacyViewer(this.activeViewerType); + } + }, + editBlob(target) { + if (this.showForkSuggestion) { + this.setForkTarget(target); + return; + } + + const { ideEditPath, editBlobPath } = this.blobInfo; + redirectTo(target === 'ide' ? ideEditPath : editBlobPath); + }, + setForkTarget(target) { + this.forkTarget = target; }, }, }; @@ -191,6 +239,8 @@ export default { :show-edit-button="!isBinaryFileType" :edit-path="blobInfo.editBlobPath" :web-ide-path="blobInfo.ideEditPath" + :needs-to-fork="showForkSuggestion" + @edit="editBlob" /> <blob-button-group v-if="isLoggedIn" @@ -206,14 +256,20 @@ export default { /> </template> </blob-header> + <fork-suggestion + v-if="forkTarget && showForkSuggestion" + :fork-path="forkPath" + @cancel="setForkTarget(null)" + /> <blob-content v-if="!blobViewer" :rich-viewer="legacyRichViewer" :blob="blobInfo" - :content="blobInfo.rawTextBlob" + :content="legacySimpleViewer" :is-raw-content="true" :active-viewer="viewer" - :loading="false" + :hide-line-numbers="true" + :loading="isLoadingLegacyViewer" /> <component :is="blobViewer" v-else v-bind="viewerProps" class="blob-viewer" /> </div> diff --git a/app/assets/javascripts/repository/components/blob_edit.vue b/app/assets/javascripts/repository/components/blob_edit.vue index 30ed4cd57f1..fd377ba1b81 100644 --- a/app/assets/javascripts/repository/components/blob_edit.vue +++ b/app/assets/javascripts/repository/components/blob_edit.vue @@ -27,6 +27,16 @@ export default { type: String, required: true, }, + needsToFork: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + onEdit(target) { + this.$emit('edit', target); + }, }, }; </script> @@ -38,7 +48,9 @@ export default { class="gl-mr-3" :edit-url="editPath" :web-ide-url="webIdePath" + :needs-to-fork="needsToFork" :is-blob="true" + @edit="onEdit" /> <div v-else> <gl-button @@ -46,8 +58,8 @@ export default { class="gl-mr-2" category="primary" variant="confirm" - :href="editPath" data-testid="edit" + @click="onEdit('simple')" > {{ $options.i18n.edit }} </gl-button> @@ -56,8 +68,8 @@ export default { class="gl-mr-3" category="primary" variant="confirm" - :href="webIdePath" data-testid="web-ide" + @click="onEdit('ide')" > {{ $options.i18n.webIde }} </gl-button> diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index 3b4f4eb51fe..c5209d97abb 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -3,11 +3,15 @@ export const loadViewer = (type) => { case 'empty': return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue'); case 'text': - return () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue'); + return gon.features.refactorTextViewer + ? () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue') + : null; case 'download': return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue'); case 'image': return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue'); + case 'video': + return () => import(/* webpackChunkName: 'blob_video_viewer' */ './video_viewer.vue'); default: return null; } @@ -29,5 +33,8 @@ export const viewerProps = (type, blob) => { url: blob.rawPath, alt: blob.name, }, + video: { + url: blob.rawPath, + }, }[type]; }; diff --git a/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue new file mode 100644 index 00000000000..dec0c4802ca --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue @@ -0,0 +1,15 @@ +<script> +export default { + props: { + url: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="gl-text-center gl-p-7 gl-bg-gray-50"> + <video :src="url" controls data-testid="video" class="gl-max-w-full"></video> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index db84e2b5912..d3717f10ec7 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -9,11 +9,13 @@ import { } from '@gitlab/ui'; import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __ } from '../../locale'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import projectShortPathQuery from '../queries/project_short_path.query.graphql'; import UploadBlobModal from './upload_blob_modal.vue'; +import NewDirectoryModal from './new_directory_modal.vue'; const ROW_TYPES = { header: 'header', @@ -21,6 +23,7 @@ const ROW_TYPES = { }; const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob'; +const NEW_DIRECTORY_MODAL_ID = 'modal-new-directory'; export default { components: { @@ -30,6 +33,7 @@ export default { GlDropdownItem, GlIcon, UploadBlobModal, + NewDirectoryModal, }, apollo: { projectShortPath: { @@ -54,7 +58,7 @@ export default { directives: { GlModal: GlModalDirective, }, - mixins: [getRefMixin], + mixins: [getRefMixin, glFeatureFlagsMixin()], props: { currentPath: { type: String, @@ -121,8 +125,14 @@ export default { required: false, default: '', }, + newDirPath: { + type: String, + required: false, + default: '', + }, }, uploadBlobModalId: UPLOAD_BLOB_MODAL_ID, + newDirectoryModalId: NEW_DIRECTORY_MODAL_ID, data() { return { projectShortPath: '', @@ -160,6 +170,13 @@ export default { showUploadModal() { return this.canEditTree && !this.$apollo.queries.userPermissions.loading; }, + showNewDirectoryModal() { + return ( + this.glFeatures.newDirModal && + this.canEditTree && + !this.$apollo.queries.userPermissions.loading + ); + }, dropdownItems() { const items = []; @@ -185,15 +202,26 @@ export default { text: __('Upload file'), modalId: UPLOAD_BLOB_MODAL_ID, }, - { + ); + + if (this.glFeatures.newDirModal) { + items.push({ + attrs: { + href: '#modal-create-new-dir', + }, + text: __('New directory'), + modalId: NEW_DIRECTORY_MODAL_ID, + }); + } else { + items.push({ attrs: { href: '#modal-create-new-dir', 'data-target': '#modal-create-new-dir', 'data-toggle': 'modal', }, text: __('New directory'), - }, - ); + }); + } } else if (this.canCreateMrFromFork) { items.push( { @@ -306,5 +334,14 @@ export default { :can-push-code="canPushCode" :path="uploadPath" /> + <new-directory-modal + v-if="showNewDirectoryModal" + :can-push-code="canPushCode" + :modal-id="$options.newDirectoryModalId" + :commit-message="__('Add new directory')" + :target-branch="selectedBranch" + :original-branch="originalBranch" + :path="newDirPath" + /> </nav> </template> diff --git a/app/assets/javascripts/repository/components/fork_suggestion.vue b/app/assets/javascripts/repository/components/fork_suggestion.vue new file mode 100644 index 00000000000..c266bea319b --- /dev/null +++ b/app/assets/javascripts/repository/components/fork_suggestion.vue @@ -0,0 +1,45 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + i18n: { + message: __( + 'You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.', + ), + fork: __('Fork'), + cancel: __('Cancel'), + }, + components: { + GlButton, + }, + props: { + forkPath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-justify-content-end gl-align-items-center gl-bg-gray-10 gl-px-5 gl-py-2 gl-border-1 gl-border-b-solid gl-border-gray-100" + > + <span class="gl-mr-6" data-testid="message">{{ $options.i18n.message }}</span> + + <gl-button + class="gl-mr-3" + category="secondary" + variant="confirm" + :href="forkPath" + data-testid="fork" + > + {{ $options.i18n.fork }} + </gl-button> + + <gl-button data-testid="cancel" @click="$emit('cancel')"> + {{ $options.i18n.cancel }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/new_directory_modal.vue b/app/assets/javascripts/repository/components/new_directory_modal.vue new file mode 100644 index 00000000000..6c5797bf5b2 --- /dev/null +++ b/app/assets/javascripts/repository/components/new_directory_modal.vue @@ -0,0 +1,183 @@ +<script> +import { + GlAlert, + GlForm, + GlModal, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlToggle, +} from '@gitlab/ui'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { + SECONDARY_OPTIONS_TEXT, + COMMIT_LABEL, + TARGET_BRANCH_LABEL, + TOGGLE_CREATE_MR_LABEL, + NEW_BRANCH_IN_FORK, +} from '../constants'; + +const MODAL_TITLE = __('Create New Directory'); +const PRIMARY_OPTIONS_TEXT = __('Create directory'); +const DIR_LABEL = __('Directory name'); +const ERROR_MESSAGE = __('Error creating new directory. Please try again.'); + +export default { + components: { + GlAlert, + GlModal, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlToggle, + }, + i18n: { + DIR_LABEL, + COMMIT_LABEL, + TARGET_BRANCH_LABEL, + TOGGLE_CREATE_MR_LABEL, + NEW_BRANCH_IN_FORK, + PRIMARY_OPTIONS_TEXT, + ERROR_MESSAGE, + }, + props: { + modalTitle: { + type: String, + default: MODAL_TITLE, + required: false, + }, + modalId: { + type: String, + required: true, + }, + primaryBtnText: { + type: String, + default: PRIMARY_OPTIONS_TEXT, + required: false, + }, + commitMessage: { + type: String, + required: true, + }, + targetBranch: { + type: String, + required: true, + }, + originalBranch: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + canPushCode: { + type: Boolean, + required: true, + }, + }, + data() { + return { + dir: null, + commit: this.commitMessage, + target: this.targetBranch, + createNewMr: true, + loading: false, + }; + }, + computed: { + primaryOptions() { + return { + text: this.primaryBtnText, + attributes: [ + { + variant: 'confirm', + loading: this.loading, + disabled: !this.formCompleted || this.loading, + }, + ], + }; + }, + cancelOptions() { + return { + text: SECONDARY_OPTIONS_TEXT, + attributes: [ + { + disabled: this.loading, + }, + ], + }; + }, + showCreateNewMrToggle() { + return this.canPushCode; + }, + formCompleted() { + return this.dir && this.commit && this.target; + }, + }, + methods: { + submitForm() { + this.loading = true; + + const formData = new FormData(); + formData.append('dir_name', this.dir); + formData.append('commit_message', this.commit); + formData.append('branch_name', this.target); + formData.append('original_branch', this.originalBranch); + + if (this.createNewMr) { + formData.append('create_merge_request', this.createNewMr); + } + + return axios + .post(this.path, formData) + .then((response) => { + visitUrl(response.data.filePath); + }) + .catch(() => { + this.loading = false; + createFlash({ message: ERROR_MESSAGE }); + }); + }, + }, +}; +</script> + +<template> + <gl-form> + <gl-modal + :modal-id="modalId" + :title="modalTitle" + :action-primary="primaryOptions" + :action-cancel="cancelOptions" + @primary.prevent="submitForm" + > + <gl-form-group :label="$options.i18n.DIR_LABEL" label-for="dir_name"> + <gl-form-input v-model="dir" :disabled="loading" name="dir_name" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message"> + <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" /> + </gl-form-group> + <gl-form-group + v-if="canPushCode" + :label="$options.i18n.TARGET_BRANCH_LABEL" + label-for="branch_name" + > + <gl-form-input v-model="target" :disabled="loading" name="branch_name" /> + </gl-form-group> + <gl-toggle + v-if="showCreateNewMrToggle" + v-model="createNewMr" + :disabled="loading" + :label="$options.i18n.TOGGLE_CREATE_MR_LABEL" + /> + <gl-alert v-if="!canPushCode" variant="info" :dismissible="false" class="gl-mt-3"> + {{ $options.i18n.NEW_BRANCH_IN_FORK }} + </gl-alert> + </gl-modal> + </gl-form> +</template> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 54e67c5ab5c..c6e461b10e0 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlIcon, GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; import { handleLocationHash } from '~/lib/utils/common_utils'; @@ -22,6 +22,9 @@ export default { GlLink, GlLoadingIcon, }, + directives: { + SafeHtml, + }, props: { blob: { type: Object, @@ -59,11 +62,7 @@ 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-html="readme.html /* eslint-disable-line vue/no-v-html */" - ></div> + <div v-else-if="readme" ref="readme" v-safe-html="readme.html"></div> </div> </article> </template> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 10a30bd44b1..0a2ed753e38 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,5 +1,6 @@ <script> import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { sprintf, __ } from '../../../locale'; import getRefMixin from '../../mixins/get_ref'; import projectPathQuery from '../../queries/project_path.query.graphql'; @@ -15,13 +16,18 @@ export default { ParentRow, GlButton, }, - mixins: [getRefMixin], + mixins: [getRefMixin, glFeatureFlagMixin()], apollo: { projectPath: { query: projectPathQuery, }, }, props: { + commits: { + type: Array, + required: false, + default: () => [], + }, path: { type: String, required: true, @@ -48,6 +54,7 @@ export default { data() { return { projectPath: '', + rowNumbers: {}, }; }, computed: { @@ -73,10 +80,38 @@ export default { return ['', '/'].indexOf(this.path) === -1; }, }, + watch: { + $route: function routeChange() { + this.$options.totalRowsLoaded = -1; + }, + }, + totalRowsLoaded: -1, methods: { showMore() { this.$emit('showMore'); }, + generateRowNumber(path, id, index) { + const key = `${path}-${id}-${index}`; + if (!this.glFeatures.lazyLoadCommits) { + return 0; + } + + if (!this.rowNumbers[key] && this.rowNumbers[key] !== 0) { + this.$options.totalRowsLoaded += 1; + this.rowNumbers[key] = this.$options.totalRowsLoaded; + } + + return this.rowNumbers[key]; + }, + getCommit(fileName, type) { + if (!this.glFeatures.lazyLoadCommits) { + return {}; + } + + return this.commits.find( + (commitEntry) => commitEntry.fileName === fileName && commitEntry.type === type, + ); + }, }, }; </script> @@ -87,6 +122,7 @@ export default { <table :aria-label="tableCaption" class="table tree-table" + :class="{ 'gl-table-layout-fixed': !showParentRow }" aria-live="polite" data-qa-selector="file_tree_table" > @@ -115,12 +151,17 @@ export default { :lfs-oid="entry.lfsOid" :loading-path="loadingPath" :total-entries="totalEntries" + :row-number="generateRowNumber(entry.flatPath, entry.id, index)" + :commit-info="getCommit(entry.name, entry.type)" + v-on="$listeners" /> </template> <template v-if="isLoading"> <tr v-for="i in 5" :key="i" aria-hidden="true"> <td><gl-skeleton-loading :lines="1" class="h-auto" /></td> - <td><gl-skeleton-loading :lines="1" class="h-auto" /></td> + <td class="gl-display-none gl-sm-display-block"> + <gl-skeleton-loading :lines="1" class="h-auto" /> + </td> <td><gl-skeleton-loading :lines="1" class="ml-auto h-auto w-50" /></td> </tr> </template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 009dd19b4a5..5010d60f374 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -7,6 +7,8 @@ import { GlLoadingIcon, GlIcon, GlHoverLoadDirective, + GlSafeHtmlDirective, + GlIntersectionObserver, } from '@gitlab/ui'; import { escapeRegExp } from 'lodash'; import filesQuery from 'shared_queries/repository/files.query.graphql'; @@ -29,10 +31,12 @@ export default { GlIcon, TimeagoTooltip, FileIcon, + GlIntersectionObserver, }, directives: { GlTooltip: GlTooltipDirective, GlHoverLoad: GlHoverLoadDirective, + SafeHtml: GlSafeHtmlDirective, }, apollo: { commit: { @@ -46,10 +50,23 @@ export default { maxOffset: this.totalEntries, }; }, + skip() { + return this.glFeatures.lazyLoadCommits; + }, }, }, mixins: [getRefMixin, glFeatureFlagMixin()], props: { + commitInfo: { + type: Object, + required: false, + default: null, + }, + rowNumber: { + type: Number, + required: false, + default: null, + }, totalEntries: { type: Number, required: true, @@ -111,9 +128,13 @@ export default { data() { return { commit: null, + hasRowAppeared: false, }; }, computed: { + commitData() { + return this.glFeatures.lazyLoadCommits ? this.commitInfo : this.commit; + }, refactorBlobViewerEnabled() { return this.glFeatures.refactorBlobViewer; }, @@ -146,7 +167,10 @@ export default { return this.sha.slice(0, 8); }, hasLockLabel() { - return this.commit && this.commit.lockLabel; + return this.commitData && this.commitData.lockLabel; + }, + showSkeletonLoader() { + return !this.commitData && this.hasRowAppeared; }, }, methods: { @@ -177,7 +201,21 @@ export default { apolloQuery(query, variables) { this.$apollo.query({ query, variables }); }, + rowAppeared() { + this.hasRowAppeared = true; + + if (this.glFeatures.lazyLoadCommits) { + this.$emit('row-appear', { + rowNumber: this.rowNumber, + hasCommit: Boolean(this.commitInfo), + }); + } + }, + rowDisappeared() { + this.hasRowAppeared = false; + }, }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; </script> @@ -219,7 +257,7 @@ export default { <gl-icon v-if="hasLockLabel" v-gl-tooltip - :title="commit.lockLabel" + :title="commitData.lockLabel" name="lock" :size="12" class="ml-1" @@ -227,17 +265,19 @@ export default { </td> <td class="d-none d-sm-table-cell tree-commit cursor-default"> <gl-link - v-if="commit" - :href="commit.commitPath" - :title="commit.message" + v-if="commitData" + v-safe-html:[$options.safeHtmlConfig]="commitData.titleHtml" + :href="commitData.commitPath" + :title="commitData.message" class="str-truncated-100 tree-commit-link" - v-html="commit.titleHtml /* eslint-disable-line vue/no-v-html */" /> - <gl-skeleton-loading v-else :lines="1" class="h-auto" /> + <gl-intersection-observer @appear="rowAppeared" @disappear="rowDisappeared"> + <gl-skeleton-loading v-if="showSkeletonLoader" :lines="1" class="h-auto" /> + </gl-intersection-observer> </td> <td class="tree-time-ago text-right cursor-default"> - <timeago-tooltip v-if="commit" :time="commit.committedDate" /> - <gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" /> + <timeago-tooltip v-if="commitData" :time="commitData.committedDate" /> + <gl-skeleton-loading v-if="showSkeletonLoader" :lines="1" class="ml-auto h-auto w-50" /> </td> </tr> </template> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 5a8ead9ae8f..16dfe3cfb14 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -8,6 +8,7 @@ import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT } from '../co import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import { readmeFile } from '../utils/readme'; +import { loadCommits, isRequested, resetRequestedCommits } from '../commits_service'; import FilePreview from './preview/index.vue'; import FileTable from './table/index.vue'; @@ -36,6 +37,7 @@ export default { }, data() { return { + commits: [], projectPath: '', nextPageCursor: '', pagesLoaded: 1, @@ -81,12 +83,16 @@ export default { this.entries.submodules = []; this.entries.blobs = []; this.nextPageCursor = ''; + resetRequestedCommits(); this.fetchFiles(); }, }, mounted() { // We need to wait for `ref` and `projectPath` to be set - this.$nextTick(() => this.fetchFiles()); + this.$nextTick(() => { + resetRequestedCommits(); + this.fetchFiles(); + }); }, methods: { fetchFiles() { @@ -152,6 +158,18 @@ export default { .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) .find(({ hasNextPage }) => hasNextPage); }, + loadCommitData({ rowNumber = 0, hasCommit } = {}) { + if (!this.glFeatures.lazyLoadCommits || hasCommit || isRequested(rowNumber)) { + return; + } + + loadCommits(this.projectPath, this.path, this.ref, rowNumber) + .then(this.setCommitData) + .catch(() => {}); + }, + setCommitData(data) { + this.commits = this.commits.concat(data); + }, handleShowMore() { this.clickedShowMore = true; this.pagesLoaded += 1; @@ -169,7 +187,9 @@ export default { :is-loading="isLoadingFiles" :loading-path="loadingPath" :has-more="hasShowMore" + :commits="commits" @showMore="handleShowMore" + @row-appear="loadCommitData" /> <file-preview v-if="readme" :blob="readme" /> </div> diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index df5a5ea6163..0199b893453 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -220,6 +220,7 @@ export default { class="gl-h-200! gl-mb-4" single-file-selection :valid-file-mimetypes="$options.validFileMimetypes" + :is-file-valid="() => true" @change="setFile" > <div diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 93032bf17e2..152fabbd7cc 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -4,12 +4,19 @@ export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make +export const COMMIT_BATCH_SIZE = 25; // we request commit data in batches of 25 + export const SECONDARY_OPTIONS_TEXT = __('Cancel'); export const COMMIT_LABEL = __('Commit message'); export const TARGET_BRANCH_LABEL = __('Target branch'); export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); +export const NEW_BRANCH_IN_FORK = __( + 'A new branch will be created in your fork and a new merge request will be started.', +); export const COMMIT_MESSAGE_SUBJECT_MAX_LENGTH = 52; export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72; export const LIMITED_CONTAINER_WIDTH_CLASS = 'limit-container-width'; + +export const I18N_COMMIT_DATA_FETCH_ERROR = __('An error occurred while fetching commit data.'); diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 60a1a0443f7..45e026ad695 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -120,6 +120,7 @@ export default function setupVueRepositoryList() { forkNewDirectoryPath, forkUploadBlobPath, uploadPath, + newDirPath, }, }); }, diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 45f07f7dc58..8e0b5e21ca3 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -4,6 +4,8 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { userPermissions { pushCode downloadCode + createMergeRequestIn + forkProject } pathLocks { nodes { @@ -23,6 +25,9 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { path editBlobPath ideEditPath + forkAndEditPath + ideForkAndEditPath + canModifyBlob storedExternally rawPath replacePath diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index 6637d03a7a4..0a675e14eb5 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -1,7 +1,7 @@ import { escapeRegExp } from 'lodash'; import Vue from 'vue'; import VueRouter from 'vue-router'; -import { joinPaths } from '../lib/utils/url_utility'; +import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility'; import BlobPage from './pages/blob.vue'; import IndexPage from './pages/index.vue'; import TreePage from './pages/tree.vue'; @@ -24,7 +24,7 @@ export default function createRouter(base, baseRef) { }), }; - return new VueRouter({ + const router = new VueRouter({ mode: 'history', base: joinPaths(gon.relative_url_root || '', base), routes: [ @@ -59,4 +59,21 @@ export default function createRouter(base, baseRef) { }, ], }); + + router.afterEach((to) => { + const needsClosingSlash = !to.name.includes('blobPath'); + window.gl.webIDEPath = webIDEUrl( + joinPaths( + '/', + base, + 'edit', + decodeURI(baseRef), + '-', + to.params.path || '', + needsClosingSlash && '/', + ), + ); + }); + + return router; } diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js index 61fe89f4f7e..29642b6633f 100644 --- a/app/assets/javascripts/rest_api.js +++ b/app/assets/javascripts/rest_api.js @@ -2,6 +2,7 @@ export * from './api/groups_api'; export * from './api/projects_api'; export * from './api/user_api'; export * from './api/markdown_api'; +export * from './api/bulk_imports_api'; // Note: It's not possible to spy on methods imported from this file in // Jest tests. diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 23254fcc2eb..381421cdc23 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -111,7 +111,7 @@ Sidebar.prototype.toggleTodo = function (e) { }; Sidebar.prototype.sidebarCollapseClicked = function (e) { - if ($(e.currentTarget).hasClass('dont-change-state')) { + if ($(e.currentTarget).hasClass('js-dont-change-state')) { return; } const sidebar = e.data; 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 fedd2519958..c8513a0b803 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -1,4 +1,5 @@ <script> +import { GlLink } from '@gitlab/ui'; import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -6,8 +7,8 @@ import { formatNumber, sprintf, __ } from '~/locale'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; +import RunnerName from '../components/runner_name.vue'; import RunnerPagination from '../components/runner_pagination.vue'; -import RunnerTypeHelp from '../components/runner_type_help.vue'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { tagTokenConfig } from '../components/search_tokens/tag_token_config'; import { typeTokenConfig } from '../components/search_tokens/type_token_config'; @@ -23,10 +24,11 @@ import { captureException } from '../sentry_utils'; export default { name: 'AdminRunnersApp', components: { + GlLink, RunnerFilteredSearchBar, RunnerList, RunnerManualSetupHelp, - RunnerTypeHelp, + RunnerName, RunnerPagination, }, props: { @@ -124,17 +126,10 @@ export default { </script> <template> <div> - <div class="row"> - <div class="col-sm-6"> - <runner-type-help /> - </div> - <div class="col-sm-6"> - <runner-manual-setup-help - :registration-token="registrationToken" - :type="$options.INSTANCE_TYPE" - /> - </div> - </div> + <runner-manual-setup-help + :registration-token="registrationToken" + :type="$options.INSTANCE_TYPE" + /> <runner-filtered-search-bar v-model="search" @@ -150,7 +145,13 @@ export default { {{ __('No runners found') }} </div> <template v-else> - <runner-list :runners="runners.items" :loading="runnersLoading" /> + <runner-list :runners="runners.items" :loading="runnersLoading"> + <template #runner-name="{ runner }"> + <gl-link :href="runner.adminUrl"> + <runner-name :runner="runner" /> + </gl-link> + </template> + </runner-list> <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" /> </template> </div> 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 863f0ab995f..e26bdbf1aea 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -1,7 +1,6 @@ <script> import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; import createFlash from '~/flash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; @@ -37,13 +36,6 @@ export default { }; }, computed: { - runnerNumericalId() { - return getIdFromGraphQLId(this.runner.id); - }, - runnerUrl() { - // TODO implement using webUrl from the API - return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`; - }, isActive() { return this.runner.active; }, @@ -119,7 +111,7 @@ export default { }, }, awaitRefetchQueries: true, - refetchQueries: ['getRunners'], + refetchQueries: ['getRunners', 'getGroupRunners'], }); if (errors && errors.length) { throw new Error(errors.join(' ')); @@ -147,12 +139,20 @@ export default { <template> <gl-button-group> + <!-- + This button appears for administratos: those with + access to the adminUrl. More advanced permissions policies + will allow more granular permissions. + + See https://gitlab.com/gitlab-org/gitlab/-/issues/334802 + --> <gl-button + v-if="runner.adminUrl" v-gl-tooltip.hover.viewport + :href="runner.adminUrl" :title="$options.i18n.I18N_EDIT" :aria-label="$options.i18n.I18N_EDIT" icon="pencil" - :href="runnerUrl" data-testid="edit-runner" /> <gl-button diff --git a/app/assets/javascripts/runner/components/cells/runner_name_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue index 797a3359147..886b5cb29fc 100644 --- a/app/assets/javascripts/runner/components/cells/runner_name_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue @@ -1,12 +1,11 @@ <script> -import { GlLink } from '@gitlab/ui'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import RunnerName from '../runner_name.vue'; export default { components: { - GlLink, TooltipOnTruncate, + RunnerName, }, props: { runner: { @@ -15,26 +14,18 @@ export default { }, }, computed: { - runnerNumericalId() { - return getIdFromGraphQLId(this.runner.id); - }, - runnerUrl() { - // TODO implement using webUrl from the API - return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`; - }, description() { return this.runner.description; }, - shortSha() { - return this.runner.shortSha; - }, }, }; </script> <template> <div> - <gl-link :href="runnerUrl"> #{{ runnerNumericalId }} ({{ shortSha }})</gl-link> + <slot :runner="runner" name="runner-name"> + <runner-name :runner="runner" /> + </slot> <tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child"> <div class="gl-text-truncate"> {{ description }} diff --git a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue index f186a8daf72..c8cb0bf6088 100644 --- a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue @@ -1,11 +1,18 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import { GlTooltipDirective } from '@gitlab/ui'; import RunnerTypeBadge from '../runner_type_badge.vue'; +import RunnerStateLockedBadge from '../runner_state_locked_badge.vue'; +import RunnerStatePausedBadge from '../runner_state_paused_badge.vue'; +import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants'; export default { components: { - GlBadge, RunnerTypeBadge, + RunnerStateLockedBadge, + RunnerStatePausedBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { runner: { @@ -24,19 +31,17 @@ export default { return !this.runner.active; }, }, + i18n: { + I18N_LOCKED_RUNNER_DESCRIPTION, + I18N_PAUSED_RUNNER_DESCRIPTION, + }, }; </script> <template> <div> <runner-type-badge :type="runnerType" size="sm" /> - - <gl-badge v-if="locked" variant="warning" size="sm"> - {{ s__('Runners|locked') }} - </gl-badge> - - <gl-badge v-if="paused" variant="danger" size="sm"> - {{ s__('Runners|paused') }} - </gl-badge> + <runner-state-locked-badge v-if="locked" size="sm" /> + <runner-state-paused-badge v-if="paused" size="sm" /> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index 69a1f106ca8..3f6ea389288 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -5,7 +5,7 @@ import { formatNumber, __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { RUNNER_JOB_COUNT_LIMIT } from '../constants'; import RunnerActionsCell from './cells/runner_actions_cell.vue'; -import RunnerNameCell from './cells/runner_name_cell.vue'; +import RunnerSummaryCell from './cells/runner_summary_cell.vue'; import RunnerTypeCell from './cells/runner_type_cell.vue'; import RunnerTags from './runner_tags.vue'; @@ -35,7 +35,7 @@ export default { GlSkeletonLoader, TimeAgo, RunnerActionsCell, - RunnerNameCell, + RunnerSummaryCell, RunnerTags, RunnerTypeCell, }, @@ -77,7 +77,7 @@ export default { }, fields: [ tableField({ key: 'type', label: __('Type/State') }), - tableField({ key: 'name', label: s__('Runners|Runner'), width: 30 }), + tableField({ key: 'summary', label: s__('Runners|Runner'), width: 30 }), tableField({ key: 'version', label: __('Version') }), tableField({ key: 'ipAddress', label: __('IP Address') }), tableField({ key: 'projectCount', label: __('Projects'), width: 5 }), @@ -107,8 +107,12 @@ export default { <runner-type-cell :runner="item" /> </template> - <template #cell(name)="{ item }"> - <runner-name-cell :runner="item" /> + <template #cell(summary)="{ item, index }"> + <runner-summary-cell :runner="item"> + <template #runner-name="{ runner }"> + <slot name="runner-name" :runner="runner" :index="index"></slot> + </template> + </runner-summary-cell> </template> <template #cell(version)="{ item: { version } }"> diff --git a/app/assets/javascripts/runner/components/runner_name.vue b/app/assets/javascripts/runner/components/runner_name.vue new file mode 100644 index 00000000000..8e495125e03 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_name.vue @@ -0,0 +1,18 @@ +<script> +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +export default { + props: { + runner: { + type: Object, + required: true, + }, + }, + methods: { + getIdFromGraphQLId, + }, +}; +</script> +<template> + <span>#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span> +</template> diff --git a/app/assets/javascripts/runner/components/runner_state_locked_badge.vue b/app/assets/javascripts/runner/components/runner_state_locked_badge.vue new file mode 100644 index 00000000000..458526010bc --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_state_locked_badge.vue @@ -0,0 +1,25 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + i18n: { + I18N_LOCKED_RUNNER_DESCRIPTION, + }, +}; +</script> +<template> + <gl-badge + v-gl-tooltip="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION" + variant="warning" + v-bind="$attrs" + > + {{ s__('Runners|locked') }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/runner/components/runner_state_paused_badge.vue b/app/assets/javascripts/runner/components/runner_state_paused_badge.vue new file mode 100644 index 00000000000..d1e6fa05e4d --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_state_paused_badge.vue @@ -0,0 +1,25 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { I18N_PAUSED_RUNNER_DESCRIPTION } from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + i18n: { + I18N_PAUSED_RUNNER_DESCRIPTION, + }, +}; +</script> +<template> + <gl-badge + v-gl-tooltip="$options.i18n.I18N_PAUSED_RUNNER_DESCRIPTION" + variant="danger" + v-bind="$attrs" + > + {{ s__('Runners|paused') }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue index c2f43daa899..1a61b80184b 100644 --- a/app/assets/javascripts/runner/components/runner_type_badge.vue +++ b/app/assets/javascripts/runner/components/runner_type_badge.vue @@ -1,20 +1,30 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_INSTANCE_RUNNER_DESCRIPTION, + I18N_GROUP_RUNNER_DESCRIPTION, + I18N_PROJECT_RUNNER_DESCRIPTION, +} from '../constants'; const BADGE_DATA = { [INSTANCE_TYPE]: { variant: 'success', text: s__('Runners|shared'), + tooltip: I18N_INSTANCE_RUNNER_DESCRIPTION, }, [GROUP_TYPE]: { variant: 'success', text: s__('Runners|group'), + tooltip: I18N_GROUP_RUNNER_DESCRIPTION, }, [PROJECT_TYPE]: { variant: 'info', text: s__('Runners|specific'), + tooltip: I18N_PROJECT_RUNNER_DESCRIPTION, }, }; @@ -22,6 +32,9 @@ export default { components: { GlBadge, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { type: { type: String, @@ -40,7 +53,7 @@ export default { }; </script> <template> - <gl-badge v-if="badge" :variant="badge.variant" v-bind="$attrs"> + <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" :variant="badge.variant" v-bind="$attrs"> {{ badge.text }} </gl-badge> </template> diff --git a/app/assets/javascripts/runner/components/runner_type_help.vue b/app/assets/javascripts/runner/components/runner_type_help.vue deleted file mode 100644 index 70456b3ab65..00000000000 --- a/app/assets/javascripts/runner/components/runner_type_help.vue +++ /dev/null @@ -1,60 +0,0 @@ -<script> -import { GlBadge } from '@gitlab/ui'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; -import RunnerTypeBadge from './runner_type_badge.vue'; - -export default { - components: { - GlBadge, - RunnerTypeBadge, - }, - runnerTypes: { - INSTANCE_TYPE, - GROUP_TYPE, - PROJECT_TYPE, - }, -}; -</script> - -<template> - <div class="bs-callout"> - <p>{{ __('Runners are processes that pick up and execute CI/CD jobs for GitLab.') }}</p> - <p> - {{ - __( - 'You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.', - ) - }} - </p> - - <div> - <span> {{ __('Runners can be:') }}</span> - <ul> - <li> - <runner-type-badge :type="$options.runnerTypes.INSTANCE_TYPE" size="sm" /> - - {{ __('Runs jobs from all unassigned projects.') }} - </li> - <li> - <runner-type-badge :type="$options.runnerTypes.GROUP_TYPE" size="sm" /> - - {{ __('Runs jobs from all unassigned projects in its group.') }} - </li> - <li> - <runner-type-badge :type="$options.runnerTypes.PROJECT_TYPE" size="sm" /> - - {{ __('Runs jobs from assigned projects.') }} - </li> - <li> - <gl-badge variant="warning" size="sm"> - {{ s__('Runners|locked') }} - </gl-badge> - - {{ __('Cannot be assigned to other projects.') }} - </li> - <li> - <gl-badge variant="danger" size="sm"> - {{ s__('Runners|paused') }} - </gl-badge> - - {{ __('Not available to run jobs.') }} - </li> - </ul> - </div> - </div> -</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 46e55b322c7..a2fb9d9efd8 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -7,6 +7,14 @@ export const GROUP_RUNNER_COUNT_LIMIT = 1000; export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); +export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects'); +export const I18N_GROUP_RUNNER_DESCRIPTION = s__( + 'Runners|Available to all projects and subgroups in the group', +); +export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects'); +export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects'); +export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs'); + export const RUNNER_TAG_BADGE_VARIANT = 'info'; export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100'; 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 a601ee8d611..3e5109b1ac4 100644 --- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql @@ -24,8 +24,11 @@ query getGroupRunners( search: $search sort: $sort ) { - nodes { - ...RunnerNode + edges { + webUrl + node { + ...RunnerNode + } } pageInfo { ...PageInfo diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql index 9f837197558..51a91b9eb96 100644 --- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql @@ -25,6 +25,7 @@ query getRunners( ) { nodes { ...RunnerNode + adminUrl } pageInfo { ...PageInfo 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 42e1a9e1de9..4bb28796dfa 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -1,13 +1,16 @@ <script> +import { GlLink } from '@gitlab/ui'; import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; import { formatNumber, sprintf, s__ } from '~/locale'; + import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; +import RunnerName from '../components/runner_name.vue'; import RunnerPagination from '../components/runner_pagination.vue'; -import RunnerTypeHelp from '../components/runner_type_help.vue'; + import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { typeTokenConfig } from '../components/search_tokens/type_token_config'; import { @@ -27,10 +30,11 @@ import { captureException } from '../sentry_utils'; export default { name: 'GroupRunnersApp', components: { + GlLink, RunnerFilteredSearchBar, RunnerList, RunnerManualSetupHelp, - RunnerTypeHelp, + RunnerName, RunnerPagination, }, props: { @@ -51,6 +55,7 @@ export default { return { search: fromUrlQueryToSearch(), runners: { + webUrls: [], items: [], pageInfo: {}, }, @@ -68,8 +73,10 @@ export default { }, update(data) { const { runners } = data?.group || {}; + return { - items: runners?.nodes || [], + webUrls: runners?.edges.map(({ webUrl }) => webUrl) || [], + items: runners?.edges.map(({ node }) => node) || [], pageInfo: runners?.pageInfo || {}, }; }, @@ -137,17 +144,7 @@ export default { <template> <div> - <div class="row"> - <div class="col-sm-6"> - <runner-type-help /> - </div> - <div class="col-sm-6"> - <runner-manual-setup-help - :registration-token="registrationToken" - :type="$options.GROUP_TYPE" - /> - </div> - </div> + <runner-manual-setup-help :registration-token="registrationToken" :type="$options.GROUP_TYPE" /> <runner-filtered-search-bar v-model="search" @@ -163,7 +160,13 @@ export default { {{ __('No runners found') }} </div> <template v-else> - <runner-list :runners="runners.items" :loading="runnersLoading" /> + <runner-list :runners="runners.items" :loading="runnersLoading"> + <template #runner-name="{ runner, index }"> + <gl-link :href="runners.webUrls[index]"> + <runner-name :runner="runner" /> + </gl-link> + </template> + </runner-list> <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" /> </template> </div> diff --git a/app/assets/javascripts/search_settings/components/search_settings.vue b/app/assets/javascripts/search_settings/components/search_settings.vue index 116967a62c8..3e23b8a3435 100644 --- a/app/assets/javascripts/search_settings/components/search_settings.vue +++ b/app/assets/javascripts/search_settings/components/search_settings.vue @@ -1,7 +1,13 @@ <script> import { GlSearchBoxByType } from '@gitlab/ui'; -import { uniq } from 'lodash'; -import { EXCLUDED_NODES, HIDE_CLASS, HIGHLIGHT_CLASS, TYPING_DELAY } from '../constants'; +import { uniq, escapeRegExp } from 'lodash'; +import { + EXCLUDED_NODES, + HIDE_CLASS, + HIGHLIGHT_CLASS, + NONE_PADDING_CLASS, + TYPING_DELAY, +} from '../constants'; const origExpansions = new Map(); @@ -37,9 +43,13 @@ const resetSections = ({ sectionSelector }) => { }; const clearHighlights = () => { - document - .querySelectorAll(`.${HIGHLIGHT_CLASS}`) - .forEach((element) => element.classList.remove(HIGHLIGHT_CLASS)); + document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((element) => { + const { parentNode } = element; + const textNode = document.createTextNode(element.textContent); + parentNode.replaceChild(textNode, element); + + parentNode.normalize(); + }); }; const hideSectionsExcept = (sectionSelector, visibleSections) => { @@ -50,17 +60,41 @@ const hideSectionsExcept = (sectionSelector, visibleSections) => { }); }; -const highlightElements = (elements = []) => { - elements.forEach((element) => element.classList.add(HIGHLIGHT_CLASS)); +const transformMatchElement = (element, searchTerm) => { + const textStr = element.textContent; + const escapedSearchTerm = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi'); + + const textList = textStr.split(escapedSearchTerm); + const replaceFragment = document.createDocumentFragment(); + textList.forEach((text) => { + let addElement = document.createTextNode(text); + if (escapedSearchTerm.test(text)) { + addElement = document.createElement('mark'); + addElement.className = `${HIGHLIGHT_CLASS} ${NONE_PADDING_CLASS}`; + addElement.textContent = text; + escapedSearchTerm.lastIndex = 0; + } + replaceFragment.appendChild(addElement); + }); + + return replaceFragment; +}; + +const highlightElements = (elements = [], searchTerm) => { + elements.forEach((element) => { + const replaceFragment = transformMatchElement(element, searchTerm); + element.innerHTML = ''; + element.appendChild(replaceFragment); + }); }; -const displayResults = ({ sectionSelector, expandSection }, matches) => { +const displayResults = ({ sectionSelector, expandSection, searchTerm }, matches) => { const elements = matches.map((match) => match.parentElement); const sections = uniq(elements.map((element) => findSettingsSection(sectionSelector, element))); hideSectionsExcept(sectionSelector, sections); sections.forEach(expandSection); - highlightElements(elements); + highlightElements(elements, searchTerm); }; const clearResults = (params) => { @@ -116,21 +150,21 @@ export default { }, methods: { search(value) { + this.searchTerm = value; const displayOptions = { sectionSelector: this.sectionSelector, expandSection: this.expandSection, collapseSection: this.collapseSection, isExpanded: this.isExpandedFn, + searchTerm: this.searchTerm, }; - this.searchTerm = value; - clearResults(displayOptions); if (value.length) { saveExpansionState(document.querySelectorAll(this.sectionSelector), displayOptions); - displayResults(displayOptions, search(this.searchRoot, value)); + displayResults(displayOptions, search(this.searchRoot, this.searchTerm)); } else { restoreExpansionState(displayOptions); } diff --git a/app/assets/javascripts/search_settings/constants.js b/app/assets/javascripts/search_settings/constants.js index 9452d149122..a49351dc7b0 100644 --- a/app/assets/javascripts/search_settings/constants.js +++ b/app/assets/javascripts/search_settings/constants.js @@ -7,5 +7,8 @@ export const HIDE_CLASS = 'gl-display-none'; // used to highlight the text that matches the * search term export const HIGHLIGHT_CLASS = 'gl-bg-orange-100'; +// used to remove padding for text that matches the * search term +export const NONE_PADDING_CLASS = 'gl-p-0'; + // How many seconds to wait until the user * stops typing export const TYPING_DELAY = 400; diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 0ecfdf420db..86afdbfeb8c 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -128,6 +128,7 @@ export default { variant="confirm" category="primary" class="gl-mt-5" + :data-qa-selector="`${feature.type}_mr_button`" /> <gl-button diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index b7080bb05b8..c2ca87af9ce 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -82,8 +82,12 @@ export default { </div> </assignee-avatar-link> <div v-else> - <div class="user-list"> - <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item"> + <div class="gl-display-flex gl-flex-wrap"> + <div + v-for="user in uncollapsedUsers" + :key="user.id" + class="user-item gl-display-inline-block" + > <assignee-avatar-link :user="user" :issuable-type="issuableType" /> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index 9fdf941579d..d5647619ea3 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -36,6 +36,7 @@ export default { 'allowLabelEdit', 'allowScopedLabels', 'iid', + 'fullPath', 'initiallySelectedLabels', 'issuableType', 'labelsFetchPath', @@ -53,30 +54,32 @@ export default { handleDropdownClose() { $(this.$el).trigger('hidden.gl.dropdown'); }, - getUpdateVariables(dropdownLabels) { - const currentLabelIds = this.selectedLabels.map((label) => label.id); - const dropdownLabelIds = dropdownLabels.map((label) => label.id); - const userAddedLabelIds = this.glFeatures.labelsWidget - ? difference(dropdownLabelIds, currentLabelIds) - : dropdownLabels.filter((label) => label.set).map((label) => label.id); - const userRemovedLabelIds = this.glFeatures.labelsWidget - ? difference(currentLabelIds, dropdownLabelIds) - : dropdownLabels.filter((label) => !label.set).map((label) => label.id); + getUpdateVariables(labels) { + let labelIds = []; - const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds); + if (this.glFeatures.labelsWidget) { + labelIds = labels.map(({ id }) => toLabelGid(id)); + } else { + const currentLabelIds = this.selectedLabels.map((label) => label.id); + const userAddedLabelIds = labels.filter((label) => label.set).map((label) => label.id); + const userRemovedLabelIds = labels.filter((label) => !label.set).map((label) => label.id); + + labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds).map( + toLabelGid, + ); + } switch (this.issuableType) { case IssuableType.Issue: return { - addLabelIds: userAddedLabelIds, iid: this.iid, projectPath: this.projectPath, - removeLabelIds: userRemovedLabelIds, + labelIds, }; case IssuableType.MergeRequest: return { iid: this.iid, - labelIds: labelIds.map(toLabelGid), + labelIds, operationMode: MutationOperationMode.Replace, projectPath: this.projectPath, }; @@ -143,6 +146,8 @@ export default { <labels-select-widget v-if="glFeatures.labelsWidget" class="block labels js-labels-block" + :iid="iid" + :full-path="fullPath" :allow-label-remove="allowLabelEdit" :allow-multiselect="true" :footer-create-label-title="__('Create project label')" @@ -152,8 +157,8 @@ export default { :labels-select-in-progress="isLabelsSelectInProgress" :selected-labels="selectedLabels" :variant="$options.variant" + :issuable-type="issuableType" data-qa-selector="labels_block" - @onDropdownClose="handleDropdownClose" @onLabelRemove="handleLabelRemove" @updateSelectedLabels="handleUpdateSelectedLabels" > diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index ad4bfe5b665..4a255a3b916 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -104,11 +104,11 @@ export default { <gl-loading-icon v-if="loading" size="sm" :inline="true" /> {{ participantLabel }} </div> - <div class="participants-list hide-collapsed"> + <div class="hide-collapsed gl-display-flex gl-flex-wrap"> <div v-for="participant in visibleParticipants" :key="participant.id" - class="participants-author" + class="participants-author gl-display-inline-block gl-pr-3 gl-pb-3" > <a :href="participant.web_url || participant.webUrl" class="author-link"> <user-avatar-image diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue index 87780888c2f..361a082def6 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -77,7 +77,7 @@ export default { > <!-- use d-flex so that slot can be appropriately styled --> <span class="gl-display-flex gl-align-items-center"> - <reviewer-avatar :user="user" :img-size="24" :issuable-type="issuableType" /> + <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> <slot :user="user"></slot> </span> </gl-link> diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index c6fef86c6ff..2922008cfb2 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -91,7 +91,10 @@ export default { data-testid="reviewer" > <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType"> - <div class="gl-ml-3">@{{ user.username }}</div> + <div class="gl-ml-3 gl-line-height-normal gl-display-grid"> + <span>{{ user.name }}</span> + <span>@{{ user.username }}</span> + </div> </reviewer-avatar-link> <gl-icon v-if="user.approved" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index 3705d725a15..7b4be659330 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -83,13 +83,15 @@ export default { :value="timeRemainingPercent" :variant="progressBarVariant" /> - <div class="compare-display-container"> - <div class="compare-display float-left"> - <span class="compare-label">{{ s__('TimeTracking|Spent') }}</span> + <div + class="compare-display-container gl-display-flex gl-justify-content-space-between gl-mt-2" + > + <div class="gl-float-left"> + <span class="gl-text-gray-400">{{ s__('TimeTracking|Spent') }}</span> <span class="compare-value spent">{{ timeSpentHumanReadable }}</span> </div> - <div class="compare-display estimated float-right"> - <span class="compare-label">{{ s__('TimeTrackingEstimated|Est') }}</span> + <div class="estimated gl-float-right"> + <span class="gl-text-gray-400">{{ s__('TimeTrackingEstimated|Est') }}</span> <span class="compare-value">{{ timeEstimateHumanReadable }}</span> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue index db2197ec65e..4564a48fa2d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue @@ -1,30 +1,31 @@ <script> -import { sprintf, s__ } from '~/locale'; +import { GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +const timeSpent = s__('TimeTracking|%{spentStart}Spent: %{spentEnd}'); export default { name: 'TimeTrackingSpentOnlyPane', + timeSpent, + components: { + GlSprintf, + }, props: { timeSpentHumanReadable: { type: String, required: true, }, }, - computed: { - timeSpent() { - return sprintf( - s__('TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}'), - { - startTag: '<span class="gl-font-weight-bold">', - endTag: '</span>', - timeSpentHumanReadable: this.timeSpentHumanReadable, - }, - false, - ); - }, - }, }; </script> <template> - <div data-testid="spentOnlyPane" v-html="timeSpent /* eslint-disable-line vue/no-v-html */"></div> + <div data-testid="spentOnlyPane"> + <gl-sprintf :message="$options.timeSpent"> + <template #spent="{ content }"> + <span class="gl-font-weight-bold">{{ content }}</span + >{{ timeSpentHumanReadable }} + </template> + </gl-sprintf> + </div> </template> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index f7e76cc2b7f..d5782e4b371 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -41,7 +41,7 @@ export default { computed: { buttonClasses() { return this.collapsed - ? 'btn-blank btn-todo sidebar-collapsed-icon dont-change-state' + ? 'btn-blank btn-todo sidebar-collapsed-icon js-dont-change-state' : 'gl-button btn btn-default btn-todo issuable-header-btn float-right'; }, buttonLabel() { diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index fd43fb80b7f..e593973da82 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -31,6 +31,10 @@ import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subs import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql'; import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; +import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql'; +import groupLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql'; +import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; +import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql'; import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; @@ -105,6 +109,17 @@ export const referenceQueries = { }, }; +export const labelsQueries = { + [IssuableType.Issue]: { + issuableQuery: issueLabelsQuery, + workspaceQuery: projectLabelsQuery, + }, + [IssuableType.Epic]: { + issuableQuery: epicLabelsQuery, + workspaceQuery: groupLabelsQuery, + }, +}; + export const dateTypes = { start: 'startDate', due: 'dueDate', diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 10ab80f4ec2..9f5a2f4ebb0 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -241,6 +241,7 @@ function mountMilestoneSelect() { export function mountSidebarLabels() { const el = document.querySelector('.js-sidebar-labels'); + const { fullPath } = getSidebarOptions(); if (!el) { return false; @@ -251,6 +252,7 @@ export function mountSidebarLabels() { apolloProvider, provide: { ...el.dataset, + fullPath, allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate), allowLabelEdit: parseBoolean(el.dataset.canEdit), allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue index 62d95a650da..737a131ce7c 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue @@ -1,10 +1,14 @@ <script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; export default { components: { MarkdownFieldView, }, + directives: { + SafeHtml, + }, props: { description: { type: String, @@ -12,13 +16,14 @@ export default { default: '', }, }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; </script> <template> <markdown-field-view class="snippet-description" data-qa-selector="snippet_description_content"> <div + v-safe-html:[$options.safeHtmlConfig]="description" class="md js-snippet-description" - v-html="description /* eslint-disable-line vue/no-v-html */" ></div> </markdown-field-view> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 466b273cae4..a5c98a7ad90 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -11,15 +11,26 @@ import { GlButton, GlTooltipDirective, } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; import CanCreateProjectSnippet from 'shared_queries/snippet/project_permissions.query.graphql'; import CanCreatePersonalSnippet from 'shared_queries/snippet/user_permissions.query.graphql'; import { fetchPolicies } from '~/lib/graphql'; +import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import createFlash, { FLASH_TYPES } from '~/flash'; import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; +export const i18n = { + snippetSpamSuccess: sprintf( + s__('Snippets|%{spammable_titlecase} was submitted to Akismet successfully.'), + { spammable_titlecase: __('Snippet') }, + ), + snippetSpamFailure: s__('Snippets|Error with Akismet. Please check the logs for more info.'), +}; + export default { components: { GlAvatar, @@ -54,7 +65,7 @@ export default { }, }, }, - inject: ['reportAbusePath'], + inject: ['reportAbusePath', 'canReportSpam'], props: { snippet: { type: Object, @@ -63,7 +74,8 @@ export default { }, data() { return { - isDeleting: false, + isLoading: false, + isSubmittingSpam: false, errorMessage: '', canCreateSnippet: false, }; @@ -105,10 +117,11 @@ export default { category: 'secondary', }, { - condition: this.reportAbusePath, + condition: this.canReportSpam && !isEmpty(this.reportAbusePath), text: __('Submit as spam'), - href: this.reportAbusePath, + click: this.submitAsSpam, title: __('Submit as spam'), + loading: this.isSubmittingSpam, }, ]; }, @@ -157,7 +170,7 @@ export default { this.$refs.deleteModal.show(); }, deleteSnippet() { - this.isDeleting = true; + this.isLoading = true; this.$apollo .mutate({ mutation: DeleteSnippetMutation, @@ -167,17 +180,34 @@ export default { if (data?.destroySnippet?.errors.length) { throw new Error(data?.destroySnippet?.errors[0]); } - this.isDeleting = false; this.errorMessage = undefined; this.closeDeleteModal(); this.redirectToSnippets(); }) .catch((err) => { - this.isDeleting = false; + this.isLoading = false; this.errorMessage = err.message; + }) + .finally(() => { + this.isLoading = false; + }); + }, + async submitAsSpam() { + try { + this.isSubmittingSpam = true; + await axios.post(this.reportAbusePath); + createFlash({ + message: this.$options.i18n.snippetSpamSuccess, + type: FLASH_TYPES.SUCCESS, }); + } catch (error) { + createFlash({ message: this.$options.i18n.snippetSpamFailure }); + } finally { + this.isSubmittingSpam = false; + } }, }, + i18n, }; </script> <template> @@ -189,9 +219,7 @@ export default { :title="snippetVisibilityLevelDescription" data-container="body" > - <span class="sr-only"> - {{ s__(`VisibilityLevel|${visibility}`) }} - </span> + <span class="sr-only">{{ s__(`VisibilityLevel|${visibility}`) }}</span> <gl-icon :name="visibilityLevelIcon" :size="14" /> </div> <div class="creator" data-testid="authored-message"> @@ -233,6 +261,7 @@ export default { > <gl-button :disabled="action.disabled" + :loading="action.loading" :variant="action.variant" :category="action.category" :class="action.cssClass" @@ -240,9 +269,8 @@ export default { data-qa-selector="snippet_action_button" :data-qa-action="action.text" @click="action.click ? action.click() : undefined" + >{{ action.text }}</gl-button > - {{ action.text }} - </gl-button> </div> </template> </div> @@ -266,14 +294,14 @@ export default { <gl-modal ref="deleteModal" modal-id="delete-modal" title="Example title"> <template #modal-title>{{ __('Delete snippet?') }}</template> - <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">{{ - errorMessage - }}</gl-alert> + <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''"> + {{ errorMessage }} + </gl-alert> <gl-sprintf :message="__('Are you sure you want to delete %{name}?')"> - <template #name - ><strong>{{ snippet.title }}</strong></template - > + <template #name> + <strong>{{ snippet.title }}</strong> + </template> </gl-sprintf> <template #modal-footer> @@ -281,11 +309,11 @@ export default { <gl-button variant="danger" category="primary" - :disabled="isDeleting" + :disabled="isLoading" data-qa-selector="delete_snippet_button" @click="deleteSnippet" > - <gl-loading-icon v-if="isDeleting" size="sm" inline /> + <gl-loading-icon v-if="isLoading" size="sm" inline /> {{ __('Delete snippet') }} </gl-button> </template> diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index dec8dcec179..8e7368ef804 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -27,6 +27,7 @@ export default function appFactory(el, Component) { visibilityLevels = '[]', selectedLevel, multipleLevelsRestricted, + canReportSpam, reportAbusePath, ...restDataset } = el.dataset; @@ -39,6 +40,7 @@ export default function appFactory(el, Component) { selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE, multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset, reportAbusePath, + canReportSpam, }, render(createElement) { return createElement(Component, { diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js index 6a29883290a..8d29a65d705 100644 --- a/app/assets/javascripts/token_access/index.js +++ b/app/assets/javascripts/token_access/index.js @@ -6,7 +6,7 @@ import TokenAccess from './components/token_access.vue'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), }); export const initTokenAccess = (containerId = 'js-ci-token-access-app') => { diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index 062a3404355..2593fbe6ed1 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -22,9 +22,8 @@ export const DEFAULT_SNOWPLOW_OPTIONS = { export const ACTION_ATTR_SELECTOR = '[data-track-action]'; export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]'; -export const DEPRECATED_EVENT_ATTR_SELECTOR = '[data-track-event]'; -export const DEPRECATED_LOAD_EVENT_ATTR_SELECTOR = '[data-track-event="render"]'; - export const URLS_CACHE_STORAGE_KEY = 'gl-snowplow-pseudonymized-urls'; export const REFERRER_TTL = 24 * 60 * 60 * 1000; + +export const GOOGLE_ANALYTICS_ID_COOKIE_NAME = '_ga'; diff --git a/app/assets/javascripts/tracking/get_standard_context.js b/app/assets/javascripts/tracking/get_standard_context.js index c318029323d..6014f1ba3ee 100644 --- a/app/assets/javascripts/tracking/get_standard_context.js +++ b/app/assets/javascripts/tracking/get_standard_context.js @@ -1,4 +1,5 @@ -import { SNOWPLOW_JS_SOURCE } from './constants'; +import { getCookie } from '~/lib/utils/common_utils'; +import { SNOWPLOW_JS_SOURCE, GOOGLE_ANALYTICS_ID_COOKIE_NAME } from './constants'; export default function getStandardContext({ extra = {} } = {}) { const { schema, data = {} } = { ...window.gl?.snowplowStandardContext }; @@ -8,6 +9,7 @@ export default function getStandardContext({ extra = {} } = {}) { data: { ...data, source: SNOWPLOW_JS_SOURCE, + google_analytics_id: getCookie(GOOGLE_ANALYTICS_ID_COOKIE_NAME) ?? '', extra: extra || data.extra, }, }; diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js index 657e0a79911..c26abc261ed 100644 --- a/app/assets/javascripts/tracking/tracking.js +++ b/app/assets/javascripts/tracking/tracking.js @@ -1,4 +1,4 @@ -import { LOAD_ACTION_ATTR_SELECTOR, DEPRECATED_LOAD_EVENT_ATTR_SELECTOR } from './constants'; +import { LOAD_ACTION_ATTR_SELECTOR } from './constants'; import { dispatchSnowplowEvent } from './dispatch_snowplow_event'; import getStandardContext from './get_standard_context'; import { @@ -105,9 +105,7 @@ export default class Tracking { return []; } - const loadEvents = parent.querySelectorAll( - `${LOAD_ACTION_ATTR_SELECTOR}, ${DEPRECATED_LOAD_EVENT_ATTR_SELECTOR}`, - ); + const loadEvents = parent.querySelectorAll(LOAD_ACTION_ATTR_SELECTOR); loadEvents.forEach((element) => { const { action, data } = createEventPayload(element); @@ -179,9 +177,12 @@ export default class Tracking { } const referrers = getReferrersCache(); - const pageLinks = Object.seal({ url: '', referrer: '', originalUrl: window.location.href }); + const pageLinks = Object.seal({ + url: pageUrl, + referrer: '', + originalUrl: window.location.href, + }); - pageLinks.url = `${pageUrl}${window.location.hash}`; window.snowplow('setCustomUrl', pageLinks.url); if (document.referrer) { diff --git a/app/assets/javascripts/tracking/utils.js b/app/assets/javascripts/tracking/utils.js index 3507872b511..cc0d7e7a44a 100644 --- a/app/assets/javascripts/tracking/utils.js +++ b/app/assets/javascripts/tracking/utils.js @@ -4,8 +4,6 @@ import { getExperimentData } from '~/experimentation/utils'; import { ACTION_ATTR_SELECTOR, LOAD_ACTION_ATTR_SELECTOR, - DEPRECATED_EVENT_ATTR_SELECTOR, - DEPRECATED_LOAD_EVENT_ATTR_SELECTOR, URLS_CACHE_STORAGE_KEY, REFERRER_TTL, } from './constants'; @@ -27,7 +25,6 @@ export const addExperimentContext = (opts) => { export const createEventPayload = (el, { suffix = '' } = {}) => { const { trackAction, - trackEvent, trackValue, trackExtra, trackExperiment, @@ -36,7 +33,7 @@ export const createEventPayload = (el, { suffix = '' } = {}) => { trackProperty, } = el?.dataset || {}; - const action = (trackAction || trackEvent) + (suffix || ''); + const action = `${trackAction}${suffix || ''}`; let value = trackValue || el.value || undefined; if (el.type === 'checkbox' && !el.checked) { @@ -74,8 +71,7 @@ export const createEventPayload = (el, { suffix = '' } = {}) => { export const eventHandler = (e, func, opts = {}) => { const actionSelector = `${ACTION_ATTR_SELECTOR}:not(${LOAD_ACTION_ATTR_SELECTOR})`; - const deprecatedEventSelector = `${DEPRECATED_EVENT_ATTR_SELECTOR}:not(${DEPRECATED_LOAD_EVENT_ATTR_SELECTOR})`; - const el = e.target.closest(`${actionSelector}, ${deprecatedEventSelector}`); + const el = e.target.closest(actionSelector); if (!el) { return; diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 7a7518bcf83..4544373d8aa 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -41,6 +41,7 @@ const populateUserInfo = (user) => { workInformation: userData.work_information, websiteUrl: userData.website_url, pronouns: userData.pronouns, + localTime: userData.local_time, loaded: true, }); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index ea73ab416de..0c4a5ee35d9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -35,13 +35,17 @@ export default { } if (!this.rulesLeft.length) { - return n__('Requires approval.', 'Requires %d more approvals.', this.approvalsLeft); + return n__( + 'Requires %d approval from eligible users.', + 'Requires %d approvals from eligible users.', + this.approvalsLeft, + ); } return sprintf( n__( - 'Requires approval from %{names}.', - 'Requires %{count} more approvals from %{names}.', + 'Requires %{count} approval from %{names}.', + 'Requires %{count} approvals from %{names}.', this.approvalsLeft, ), { 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 new file mode 100644 index 00000000000..023367a794e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue @@ -0,0 +1,70 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; + +export default { + components: { + GlButton, + GlDropdown, + GlDropdownItem, + }, + props: { + widget: { + type: String, + required: true, + }, + tertiaryButtons: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + dropdownLabel() { + return sprintf(__('%{widget} options'), { widget: this.widget }); + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown + v-if="tertiaryButtons" + :text="dropdownLabel" + icon="ellipsis_v" + no-caret + category="tertiary" + right + lazy + text-sr-only + size="small" + toggle-class="gl-p-2!" + class="gl-display-block gl-md-display-none!" + > + <gl-dropdown-item + v-for="(btn, index) in tertiaryButtons" + :key="index" + :href="btn.href" + :target="btn.target" + > + {{ btn.text }} + </gl-dropdown-item> + </gl-dropdown> + <template v-if="tertiaryButtons.length"> + <gl-button + v-for="(btn, index) in tertiaryButtons" + :key="index" + :href="btn.href" + :target="btn.target" + :class="{ 'gl-mr-3': index > 1 }" + category="tertiary" + variant="confirm" + size="small" + class="gl-display-none gl-md-display-block" + > + {{ btn.text }} + </gl-button> + </template> + </div> +</template> 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 0ac98f6c982..298f7c7ad8c 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 @@ -1,7 +1,18 @@ <script> -import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui'; +import { + GlButton, + GlLoadingIcon, + GlLink, + GlBadge, + GlSafeHtmlDirective, + GlTooltipDirective, + GlIntersectionObserver, +} from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; -import StatusIcon from '../mr_widget_status_icon.vue'; +import { EXTENSION_ICON_CLASS } from '../../constants'; +import StatusIcon from './status_icon.vue'; +import Actions from './actions.vue'; export const LOADING_STATES = { collapsedLoading: 'collapsedLoading', @@ -13,14 +24,16 @@ export default { components: { GlButton, GlLoadingIcon, - GlIcon, GlLink, GlBadge, + GlIntersectionObserver, SmartVirtualList, StatusIcon, + Actions, }, directives: { SafeHtml: GlSafeHtmlDirective, + GlTooltip: GlTooltipDirective, }, data() { return { @@ -28,9 +41,16 @@ export default { collapsedData: null, fullData: null, isCollapsed: true, + showFade: false, }; }, computed: { + widgetLabel() { + return this.$options.i18n?.label || this.$options.name; + }, + widgetLoadingText() { + return this.$options.i18n?.loading || __('Loading...'); + }, isLoadingSummary() { return this.loadingState === LOADING_STATES.collapsedLoading; }, @@ -44,17 +64,22 @@ export default { return true; }, + collapseButtonLabel() { + return sprintf( + this.isCollapsed + ? s__('mrWidget|Show %{widget} details') + : s__('mrWidget|Hide %{widget} details'), + { widget: this.widgetLabel }, + ); + }, statusIconName() { - if (this.isLoadingSummary) { - return 'loading'; - } - - if (this.loadingState === LOADING_STATES.collapsedError) { - return 'warning'; - } + if (this.isLoadingSummary) return null; return this.statusIcon(this.collapsedData); }, + tertiaryActionsButtons() { + return this.tertiaryButtons ? this.tertiaryButtons() : undefined; + }, }, watch: { isCollapsed(newVal) { @@ -95,32 +120,59 @@ export default { throw e; }); }, + appear(index) { + if (index === this.fullData.length - 1) { + this.showFade = false; + } + }, + disappear(index) { + if (index === this.fullData.length - 1) { + this.showFade = true; + } + }, }, + EXTENSION_ICON_CLASS, }; </script> <template> - <section class="media-section mr-widget-border-top"> + <section class="media-section" data-testid="widget-extension"> <div class="media gl-p-5"> - <status-icon :status="statusIconName" class="align-self-center" /> - <div class="media-body d-flex flex-align-self-center align-items-center"> - <div class="code-text"> - <template v-if="isLoadingSummary"> - {{ __('Loading...') }} - </template> + <status-icon + :name="$options.label || $options.name" + :is-loading="isLoadingSummary" + :icon-name="statusIconName" + /> + <div class="media-body gl-display-flex gl-flex-direction-row!"> + <div class="gl-flex-grow-1"> + <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> <div v-else v-safe-html="summary(collapsedData)"></div> </div> - <gl-button - v-if="isCollapsible" - size="small" - class="float-right align-self-center" - @click="toggleCollapsed" - > - {{ isCollapsed ? __('Expand') : __('Collapse') }} - </gl-button> + <actions + :widget="$options.label || $options.name" + :tertiary-buttons="tertiaryActionsButtons" + /> + <div class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"> + <gl-button + v-if="isCollapsible" + v-gl-tooltip + :title="collapseButtonLabel" + :aria-expanded="`${!isCollapsed}`" + :aria-label="collapseButtonLabel" + :icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'" + category="tertiary" + data-testid="toggle-button" + size="small" + @click="toggleCollapsed" + /> + </div> </div> </div> - <div v-if="!isCollapsed" class="mr-widget-grouped-section"> + <div + v-if="!isCollapsed" + class="mr-widget-grouped-section gl-relative" + data-testid="widget-extension-collapsed-section" + > <div v-if="isLoadingExpanded" class="report-block-container"> <gl-loading-icon size="sm" inline /> {{ __('Loading...') }} </div> @@ -131,27 +183,38 @@ export default { :size="32" wtag="ul" wclass="report-block-list" - class="report-block-container" + class="report-block-container gl-px-5 gl-py-0" > - <li v-for="data in fullData" :key="data.id" class="d-flex align-items-center"> - <div v-if="data.icon" :class="data.icon.class" class="d-flex"> - <gl-icon :name="data.icon.name" :size="24" /> - </div> - <div - class="gl-mt-2 gl-mb-2 align-content-around align-items-start flex-wrap align-self-center d-flex" + <li + v-for="(data, index) in fullData" + :key="data.id" + :class="{ + 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1, + }" + class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7" + data-testid="extension-list-item" + > + <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" /> + <gl-intersection-observer + :options="{ rootMargin: '100px', thresholds: 0.1 }" + class="gl-flex-wrap gl-align-self-center gl-display-flex" + @appear="appear(index)" + @disappear="disappear(index)" > - <div class="gl-mr-4"> - {{ data.text }} - </div> + <div v-safe-html="data.text" class="gl-mr-4"></div> <div v-if="data.link"> <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> </div> <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> {{ data.badge.text }} </gl-badge> - </div> + </gl-intersection-observer> </li> </smart-virtual-list> + <div + :class="{ show: showFade }" + class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7" + ></div> </div> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js index 529160de6a7..b9dfd3bd41e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js @@ -1,4 +1,5 @@ -import { extensions } from './index'; +import { __ } from '~/locale'; +import { registeredExtensions } from './index'; export default { props: { @@ -8,20 +9,46 @@ export default { }, }, render(h) { + const { extensions } = registeredExtensions; + + if (extensions.length === 0) return null; + return h( 'div', - {}, - extensions.map((extension) => - h(extension, { - props: extensions[0].props.reduce( - (acc, key) => ({ - ...acc, - [key]: this.mr[key], - }), - {}, - ), - }), - ), + { + attrs: { + role: 'region', + 'aria-label': __('Merge request reports'), + }, + }, + [ + h( + 'ul', + { + class: 'gl-p-0 gl-m-0 gl-list-style-none', + }, + [ + ...extensions.map((extension, index) => + h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [ + h( + { ...extension }, + { + props: { + ...extension.props.reduce( + (acc, key) => ({ + ...acc, + [key]: this.mr[key], + }), + {}, + ), + }, + }, + ), + ]), + ), + ], + ), + ], ); }, }; 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 9796bb44939..4ca0b660696 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 @@ -1,15 +1,17 @@ +import Vue from 'vue'; import ExtensionBase from './base.vue'; // Holds all the currently registered extensions -export const extensions = []; +export const registeredExtensions = Vue.observable({ extensions: [] }); export const registerExtension = (extension) => { // Pushes into the extenions array a dynamically created Vue component // that gets exteneded from `base.vue` - extensions.push({ + registeredExtensions.extensions.push({ extends: ExtensionBase, name: extension.name, props: extension.props, + i18n: extension.i18n, computed: { ...Object.keys(extension.computed).reduce( (acc, computedKey) => ({ diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue new file mode 100644 index 00000000000..01d8de132e7 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue @@ -0,0 +1,61 @@ +<script> +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { EXTENSION_ICON_CLASS, EXTENSION_ICON_NAMES } from '../../constants'; + +export default { + components: { + GlLoadingIcon, + GlIcon, + }, + props: { + name: { + type: String, + required: false, + default: '', + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + iconName: { + type: String, + required: false, + default: null, + }, + size: { + type: Number, + required: false, + default: 16, + }, + }, + computed: { + iconAriaLabel() { + return `${capitalizeFirstCharacter(this.iconName)} ${this.name}`; + }, + }, + EXTENSION_ICON_NAMES, + EXTENSION_ICON_CLASS, +}; +</script> + +<template> + <div + :class="[ + $options.EXTENSION_ICON_CLASS[iconName], + { 'mr-widget-extension-icon': !isLoading && size === 16 }, + { 'gl-p-2': isLoading || size === 16 }, + ]" + class="gl-rounded-full gl-mr-3 gl-relative gl-p-2" + > + <gl-loading-icon v-if="isLoading" size="md" inline class="gl-display-block" /> + <gl-icon + v-else + :name="$options.EXTENSION_ICON_NAMES[iconName]" + :size="size" + :aria-label="iconAriaLabel" + class="gl-display-block" + /> + </div> +</template> 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 966262944ad..5c67b9c7ab5 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 @@ -10,7 +10,7 @@ import { GlSafeHtmlDirective as SafeHtml, GlSprintf, } from '@gitlab/ui'; -import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility'; +import { constructWebIDEPath } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; @@ -58,15 +58,7 @@ export default { }); }, webIdePath() { - return mergeUrlParams( - { - target_project: - this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath - ? this.mr.targetProjectFullPath - : '', - }, - webIDEUrl(`/${this.mr.sourceProjectFullPath}/merge_requests/${this.mr.iid}`), - ); + return constructWebIDEPath(this.mr); }, isFork() { return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath; @@ -79,7 +71,7 @@ export default { }; </script> <template> - <div class="d-flex mr-source-target gl-mb-3"> + <div class="gl-display-flex mr-source-target"> <mr-widget-icon name="git-merge" /> <div class="git-merge-container d-flex"> <div class="normal"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue index 7532eabee8a..68cff1368af 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -1,6 +1,7 @@ <script> /* eslint-disable @gitlab/require-i18n-strings */ import { GlModal, GlLink, GlSprintf } from '@gitlab/ui'; +import { escapeShellString } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -75,20 +76,31 @@ export default { }, computed: { mergeInfo1() { + const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`); + return this.isFork - ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.sourceBranch}\ngit checkout -b "${this.sourceProjectPath}-${this.sourceBranch}" FETCH_HEAD` - : `git fetch origin\ngit checkout -b "${this.sourceBranch}" "origin/${this.sourceBranch}"`; + ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD` + : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; }, mergeInfo2() { return this.isFork - ? `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceProjectPath}-${this.sourceBranch}"` - : `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceBranch}"`; + ? `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedForkBranch}` + : `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedSourceBranch}`; }, mergeInfo3() { return this.canMerge - ? `git push origin "${this.targetBranch}"` + ? `git push origin ${this.escapedTargetBranch}` : __('Note that pushing to GitLab requires write access to this repository.'); }, + escapedForkBranch() { + return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`); + }, + escapedTargetBranch() { + return escapeShellString(this.targetBranch); + }, + escapedSourceBranch() { + return escapeShellString(this.sourceBranch); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index a8272002f16..a05e8747a43 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -88,6 +88,9 @@ export default { return this.mr.preferredAutoMergeStrategy; }, + ciStatus() { + return this.isPostMerge ? this.mr?.mergePipeline?.details?.status?.text : this.mr.ciStatus; + }, }, }; </script> @@ -97,7 +100,7 @@ export default { :pipeline="pipeline" :pipeline-coverage-delta="mr.pipelineCoverageDelta" :builds-with-coverage="mr.buildsWithCoverage" - :ci-status="mr.ciStatus" + :ci-status="ciStatus" :has-ci="mr.hasCI" :pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds" :source-branch="branch" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index a55dba92e16..3ca193514f1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -1,12 +1,15 @@ <script> -/* eslint-disable @gitlab/require-string-literal-i18n-helpers */ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlSprintf } from '@gitlab/ui'; import { escape } from 'lodash'; -import { __, n__, sprintf, s__ } from '~/locale'; +import { __, n__, s__ } from '~/locale'; + +const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit'); export default { + mergeCommitCount, components: { GlButton, + GlSprintf, }, props: { isSquashEnabled: { @@ -37,7 +40,7 @@ export default { return this.expanded ? 'chevron-down' : 'chevron-right'; }, commitsCountMessage() { - return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount); + return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount); }, modifyLinkMessage() { if (this.isFastForwardEnabled) return __('Modify commit message'); @@ -47,22 +50,15 @@ export default { ariaLabel() { return this.expanded ? __('Collapse') : __('Expand'); }, + targetBranchEscaped() { + return escape(this.targetBranch); + }, message() { - const message = this.isFastForwardEnabled + return this.isFastForwardEnabled ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.') : s__( 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.', ); - - return sprintf( - message, - { - commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`, - mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`, - targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`, - }, - false, - ); }, }, methods: { @@ -89,10 +85,19 @@ export default { /> <span v-if="expanded">{{ __('Collapse') }}</span> <span v-else> - <span - class="vertical-align-middle" - v-html="message /* eslint-disable-line vue/no-v-html */" - ></span> + <span class="vertical-align-middle"> + <gl-sprintf :message="message"> + <template #commitCount> + <strong class="commits-count-message">{{ commitsCountMessage }}</strong> + </template> + <template #mergeCommitCount> + <strong>{{ $options.mergeCommitCount }}</strong> + </template> + <template #targetBranch> + <span class="label-branch">{{ targetBranchEscaped }}</span> + </template> + </gl-sprintf> + </span> <gl-button variant="link" class="modify-message-button"> {{ modifyLinkMessage }} </gl-button> 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 7df65e995a5..7d4bd4cf1bf 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 @@ -29,6 +29,7 @@ import { WARNING, MT_MERGE_STRATEGY, PIPELINE_FAILED_STATE, + STATE_MACHINE, } from '../../constants'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; @@ -47,6 +48,9 @@ const MERGE_FAILED_STATUS = 'failed'; const MERGE_SUCCESS_STATUS = 'success'; const MERGE_HOOK_VALIDATION_ERROR_STATUS = 'hook_validation_error'; +const { transitions } = STATE_MACHINE; +const { MERGE, MERGED, MERGE_FAILURE, AUTO_MERGE } = transitions; + export default { name: 'ReadyToMerge', apollo: { @@ -99,8 +103,8 @@ export default { GlDropdownItem, GlFormCheckbox, GlSkeletonLoader, - MergeTrainHelperText: () => - import('ee_component/vue_merge_request_widget/components/merge_train_helper_text.vue'), + MergeTrainHelperIcon: () => + import('ee_component/vue_merge_request_widget/components/merge_train_helper_icon.vue'), MergeImmediatelyConfirmationDialog: () => import( 'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue' @@ -234,7 +238,7 @@ export default { return CONFIRM; }, iconClass() { - if (this.shouldRenderMergeTrainHelperText && !this.mr.preventMerge) { + if (this.shouldRenderMergeTrainHelperIcon && !this.mr.preventMerge) { return PIPELINE_RUNNING_STATE; } @@ -361,6 +365,11 @@ export default { } this.isMakingRequest = true; + + if (!useAutoMerge) { + this.mr.transitionStateMachine({ transition: MERGE }); + } + this.service .merge(options) .then((res) => res.data) @@ -371,10 +380,12 @@ export default { if (AUTO_MERGE_STRATEGIES.includes(data.status)) { eventHub.$emit('MRWidgetUpdateRequested'); + this.mr.transitionStateMachine({ transition: AUTO_MERGE }); } else if (data.status === MERGE_SUCCESS_STATUS) { this.initiateMergePolling(); } else if (hasError) { eventHub.$emit('FailedToMerge', data.merge_error); + this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); } if (this.glFeatures.mergeRequestWidgetGraphql) { @@ -383,6 +394,7 @@ export default { }) .catch(() => { this.isMakingRequest = false; + this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); createFlash({ message: __('Something went wrong. Please try again.'), }); @@ -417,6 +429,7 @@ export default { eventHub.$emit('FetchActionsContent'); MergeRequest.hideCloseButton(); MergeRequest.decreaseCounter(); + this.mr.transitionStateMachine({ transition: MERGED }); stopPolling(); refreshUserMergeRequestCounts(); @@ -428,6 +441,7 @@ export default { } } else if (data.merge_error) { eventHub.$emit('FailedToMerge', data.merge_error); + this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); stopPolling(); } else { // MR is not merged yet, continue polling until the state becomes 'merged' @@ -438,6 +452,7 @@ export default { createFlash({ message: __('Something went wrong while merging this merge request. Please try again.'), }); + this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); stopPolling(); }); }, @@ -489,7 +504,7 @@ export default { </div> </div> <template v-else> - <div class="mr-widget-body media" :class="{ 'gl-pb-3': shouldRenderMergeTrainHelperText }"> + <div class="mr-widget-body media"> <status-icon :status="iconClass" /> <div class="media-body"> <div class="mr-widget-body-controls gl-display-flex gl-align-items-center"> @@ -560,6 +575,13 @@ export default { :is-disabled="isSquashReadOnly" class="gl-mx-3" /> + + <merge-train-helper-icon + v-if="shouldRenderMergeTrainHelperIcon" + :merge-train-when-pipeline-succeeds-docs-path=" + mr.mergeTrainWhenPipelineSucceedsDocsPath + " + /> </div> <template v-else> <div class="bold js-resolve-mr-widget-items-message gl-ml-3"> @@ -590,13 +612,6 @@ export default { </div> </div> </div> - <merge-train-helper-text - v-if="shouldRenderMergeTrainHelperText" - :pipeline-id="pipelineId" - :pipeline-link="pipeline.path" - :merge-train-length="stateData.mergeTrainsCount" - :merge-train-when-pipeline-succeeds-docs-path="mr.mergeTrainWhenPipelineSucceedsDocsPath" - /> <template v-if="shouldShowMergeControls"> <div v-if="!shouldShowMergeEdit" 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 393c599c7e8..790870ee4c6 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 @@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui'; import { produce } from 'immer'; import $ from 'jquery'; import createFlash from '~/flash'; +import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; import MergeRequest from '~/merge_request'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -123,10 +124,7 @@ export default { }, }, }) => { - createFlash({ - message: __('Marked as ready. Merging is now allowed.'), - type: 'notice', - }); + toast(__('Marked as ready. Merging is now allowed.')); $('.merge-request .detail-page-description .title').text(title); }, ) diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index f5710f46b7e..b88e83ccb0f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -1,4 +1,5 @@ import { s__ } from '~/locale'; +import { stateToComponentMap as classStateMap, stateKey } from './stores/state_maps'; export const SUCCESS = 'success'; export const WARNING = 'warning'; @@ -52,3 +53,99 @@ export const MERGE_ACTIVE_STATUS_PHRASES = [ emoji: 'heart_eyes', }, ]; + +const STATE_MACHINE = { + states: { + IDLE: 'IDLE', + MERGING: 'MERGING', + AUTO_MERGE: 'AUTO_MERGE', + }, + transitions: { + MERGE: 'start-merge', + AUTO_MERGE: 'start-auto-merge', + MERGE_FAILURE: 'merge-failed', + MERGED: 'merge-done', + }, +}; +const { states, transitions } = STATE_MACHINE; + +STATE_MACHINE.definition = { + initial: states.IDLE, + states: { + [states.IDLE]: { + on: { + [transitions.MERGE]: states.MERGING, + [transitions.AUTO_MERGE]: states.AUTO_MERGE, + }, + }, + [states.MERGING]: { + on: { + [transitions.MERGED]: states.IDLE, + [transitions.MERGE_FAILURE]: states.IDLE, + }, + }, + [states.AUTO_MERGE]: { + on: { + [transitions.MERGED]: states.IDLE, + [transitions.MERGE_FAILURE]: states.IDLE, + }, + }, + }, +}; + +export const stateToTransitionMap = { + [stateKey.merging]: transitions.MERGE, + [stateKey.merged]: transitions.MERGED, + [stateKey.autoMergeEnabled]: transitions.AUTO_MERGE, +}; +export const stateToComponentMap = { + [states.MERGING]: classStateMap[stateKey.merging], + [states.AUTO_MERGE]: classStateMap[stateKey.autoMergeEnabled], +}; + +export const EXTENSION_ICONS = { + failed: 'failed', + warning: 'warning', + success: 'success', + neutral: 'neutral', + error: 'error', + notice: 'notice', + severityCritical: 'severityCritical', + severityHigh: 'severityHigh', + severityMedium: 'severityMedium', + severityLow: 'severityLow', + severityInfo: 'severityInfo', + severityUnknown: 'severityUnknown', +}; + +export const EXTENSION_ICON_NAMES = { + failed: 'status-failed', + warning: 'status-alert', + success: 'status-success', + neutral: 'status-neutral', + error: 'status-alert', + notice: 'status-alert', + severityCritical: 'severity-critical', + severityHigh: 'severity-high', + severityMedium: 'severity-medium', + severityLow: 'severity-low', + severityInfo: 'severity-info', + severityUnknown: 'severity-unknown', +}; + +export const EXTENSION_ICON_CLASS = { + failed: 'gl-text-red-500', + warning: 'gl-text-orange-500', + success: 'gl-text-green-500', + neutral: 'gl-text-gray-400', + error: 'gl-text-red-500', + notice: 'gl-text-gray-500', + severityCritical: 'gl-text-red-800', + severityHigh: 'gl-text-red-600', + severityMedium: 'gl-text-orange-400', + severityLow: 'gl-text-orange-300', + severityInfo: 'gl-text-blue-400', + severityUnknown: 'gl-text-gray-400', +}; + +export { STATE_MACHINE }; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index 6c6f5e7fc73..349e9d29355 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -1,4 +1,5 @@ /* eslint-disable */ +import { EXTENSION_ICONS } from '../constants'; import issuesCollapsedQuery from './issues_collapsed.query.graphql'; import issuesQuery from './issues.query.graphql'; @@ -6,20 +7,29 @@ export default { // Give the extension a name // Make it easier to track in Vue dev tools name: 'WidgetIssues', + i18n: { + label: 'Issues', + loading: 'Loading issues...', + }, // Add an array of props // These then get mapped to values stored in the MR Widget store - props: ['targetProjectFullPath'], + props: ['targetProjectFullPath', 'conflictsDocsPath'], // Add any extra computed props in here computed: { // Small summary text to be displayed in the collapsed state // Receives the collapsed data as an argument summary(count) { - return `<strong>${count}</strong> open issue`; + return 'Summary text<br/>Second line'; }, // Status icon to be used next to the summary text // Receives the collapsed data as an argument statusIcon(count) { - return count > 0 ? 'warning' : 'success'; + return EXTENSION_ICONS.warning; + }, + // Tertiary action buttons that will take the user elsewhere + // in the GitLab app + tertiaryButtons() { + return [{ text: 'Full report', href: this.conflictsDocsPath, target: '_blank' }]; }, }, methods: { @@ -44,16 +54,13 @@ export default { // Icon to get rendered on the side of each row icon: { // Required: Name maps to an icon in GitLabs SVG - name: - issue.state === 'closed' ? 'status_failed_borderless' : 'status_success_borderless', - // Optional: An extra class to be added to the icon for additional styling - class: issue.state === 'closed' ? 'text-danger' : 'text-success', + name: issue.state === 'closed' ? EXTENSION_ICONS.error : EXTENSION_ICONS.success, }, // Badges get rendered next to the text on each row - badge: issue.state === 'closed' && { - text: 'Closed', // Required: Text to be used inside of the badge - // variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants - }, + // badge: issue.state === 'closed' && { + // text: 'Closed', // Required: Text to be used inside of the badge + // // variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants + // }, // Each row can have its own link that will take the user elsewhere // link: { // href: 'https://google.com', // Required: href for the link diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 9d8e5d12d58..cf6472f2c8c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -32,7 +32,7 @@ export default { isMergeImmediatelyDangerous() { return false; }, - shouldRenderMergeTrainHelperText() { + shouldRenderMergeTrainHelperIcon() { return false; }, pipelineId() { 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 78aa3941bfe..3ac1e881658 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 @@ -4,7 +4,7 @@ import { isEmpty } from 'lodash'; 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'; -import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; +import { stateToComponentMap as classState } from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import createFlash from '~/flash'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import notify from '~/lib/utils/notify'; @@ -38,7 +38,8 @@ import ReadyToMergeState from './components/states/ready_to_merge.vue'; import ShaMismatch from './components/states/sha_mismatch.vue'; import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue'; import WorkInProgressState from './components/states/work_in_progress.vue'; -// import ExtensionsContainer from './components/extensions/container'; +import ExtensionsContainer from './components/extensions/container'; +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'; @@ -52,7 +53,7 @@ export default { }, components: { Loading, - // ExtensionsContainer, + ExtensionsContainer, 'mr-widget-header': WidgetHeader, 'mr-widget-suggest-pipeline': WidgetSuggestPipeline, MrWidgetPipelineContainer, @@ -124,7 +125,9 @@ export default { mr: store, state: store && store.state, service: store && this.createService(store), + machineState: store?.machineValue || STATE_MACHINE.definition.initial, loading: true, + recomputeComponentName: 0, }; }, computed: { @@ -139,7 +142,7 @@ export default { return this.mr.state !== 'nothingToMerge'; }, componentName() { - return stateMaps.stateToComponentMap[this.mr.state]; + return stateToComponentMap[this.machineState] || classState[this.mr.state]; }, hasPipelineMustSucceedConflict() { return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds; @@ -148,9 +151,9 @@ export default { return this.mr.hasCI || this.hasPipelineMustSucceedConflict; }, shouldSuggestPipelines() { - return ( - !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath && !this.mr.isDismissedSuggestPipeline - ); + const { hasCI, mergeRequestAddCiConfigPath, isDismissedSuggestPipeline } = this.mr; + + return !hasCI && mergeRequestAddCiConfigPath && !isDismissedSuggestPipeline; }, shouldRenderCodeQuality() { return this.mr?.codequalityReportsPath; @@ -204,8 +207,19 @@ export default { hasAlerts() { return this.mr.mergeError || this.showMergePipelineForkWarning; }, + shouldShowExtension() { + return ( + window.gon?.features?.refactorMrWidgetsExtensions || + window.gon?.features?.refactorMrWidgetsExtensionsUser + ); + }, }, watch: { + 'mr.machineValue': { + handler(newValue) { + this.machineState = newValue; + }, + }, state(newVal, oldVal) { if (newVal !== oldVal && this.shouldRenderMergedPipeline) { // init polling @@ -247,6 +261,8 @@ export default { this.mr = new MRWidgetStore({ ...window.gl.mrWidgetData, ...data }); } + this.machineState = this.mr.machineValue; + if (!this.state) { this.state = this.mr.state; } @@ -496,7 +512,7 @@ export default { </template> </mr-widget-alert-message> </div> - <!-- <extensions-container :mr="mr" /> --> + <extensions-container :mr="mr" /> <grouped-codequality-reports-app v-if="shouldRenderCodeQuality" :head-blob-path="mr.headBlobPath" 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 29e0c867f6b..6628225cd46 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 @@ -1,11 +1,21 @@ import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import { statusBoxState } from '~/issuable/components/status_box.vue'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; -import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants'; +import { machine } from '~/lib/utils/finite_state_machine'; +import { + MTWPS_MERGE_STRATEGY, + MT_MERGE_STRATEGY, + MWPS_MERGE_STRATEGY, + STATE_MACHINE, + stateToTransitionMap, +} from '../constants'; import { stateKey } from './state_maps'; const { format } = getTimeago(); +const { states } = STATE_MACHINE; +const { IDLE } = states; + export default class MergeRequestStore { constructor(data) { this.sha = data.diff_head_sha; @@ -16,6 +26,9 @@ export default class MergeRequestStore { this.apiUnapprovePath = data.api_unapprove_path; this.hasApprovalsAvailable = data.has_approvals_available; + this.stateMachine = machine(STATE_MACHINE.definition); + this.machineValue = this.stateMachine.value; + this.setPaths(data); this.setData(data); @@ -215,10 +228,7 @@ export default class MergeRequestStore { setState() { if (this.mergeOngoing) { this.state = 'merging'; - return; - } - - if (this.isOpen) { + } else if (this.isOpen) { this.state = getStateKey.call(this); } else { switch (this.mergeRequestState) { @@ -232,6 +242,8 @@ export default class MergeRequestStore { this.state = null; } } + + this.translateStateToMachine(); } setPaths(data) { @@ -277,7 +289,7 @@ export default class MergeRequestStore { // Security reports this.sastComparisonPath = data.sast_comparison_path; - this.secretScanningComparisonPath = data.secret_scanning_comparison_path; + this.secretDetectionComparisonPath = data.secret_detection_comparison_path; } get isNothingToMergeState() { @@ -356,4 +368,32 @@ export default class MergeRequestStore { (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) ); } + + // Because the state machine doesn't yet handle every state and transition, + // some use-cases will need to force a state that can't be reached by + // a known transition. This is undesirable long-term (as it subverts + // the intent of a state machine), but is necessary until the machine + // can handle all possible combinations. (unsafeForce) + transitionStateMachine({ transition, state, unsafeForce = false } = {}) { + if (unsafeForce && state) { + this.stateMachine.value = state; + } else { + this.stateMachine.send(transition); + } + + this.machineValue = this.stateMachine.value; + } + translateStateToMachine() { + const transition = stateToTransitionMap[this.state]; + let transitionOptions = { + state: IDLE, + unsafeForce: true, + }; + + if (transition) { + transitionOptions = { transition }; + } + + this.transitionStateMachine(transitionOptions); + } } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index 04454882666..4cb23407a74 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -1,4 +1,4 @@ -const stateToComponentMap = { +export const stateToComponentMap = { merged: 'mr-widget-merged', closed: 'mr-widget-closed', merging: 'mr-widget-merging', @@ -21,7 +21,7 @@ const stateToComponentMap = { mergeChecksFailed: 'mergeChecksFailed', }; -const statesToShowHelpWidget = [ +export const statesToShowHelpWidget = [ 'merging', 'conflicts', 'workInProgress', @@ -50,11 +50,7 @@ export const stateKey = { notAllowedToMerge: 'notAllowedToMerge', readyToMerge: 'readyToMerge', rebase: 'rebase', + merging: 'merging', merged: 'merged', mergeChecksFailed: 'mergeChecksFailed', }; - -export default { - stateToComponentMap, - statesToShowHelpWidget, -}; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js index 0c1d55ae707..4cab5e964de 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js @@ -27,6 +27,11 @@ export default { required: false, default: '', }, + hideLineNumbers: { + type: Boolean, + required: false, + default: false, + }, }, mounted() { eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT); diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index 84770dbac6f..40044e518c3 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -8,8 +8,6 @@ export default { name: 'SimpleViewer', components: { GlIcon, - SourceEditor: () => - import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'), }, mixins: [ViewerMixin, glFeatureFlagsMixin()], inject: ['blobHash'], @@ -22,9 +20,6 @@ export default { lineNumbers() { return this.content.split('\n').length; }, - refactorBlobViewerEnabled() { - return this.glFeatures.refactorBlobViewer; - }, }, mounted() { const { hash } = window.location; @@ -52,14 +47,8 @@ export default { </script> <template> <div> - <source-editor - v-if="isRawContent && refactorBlobViewerEnabled" - :value="content" - :file-name="fileName" - :editor-options="{ readOnly: true }" - /> - <div v-else class="file-content code js-syntax-highlight" :class="$options.userColorScheme"> - <div class="line-numbers"> + <div class="file-content code js-syntax-highlight" :class="$options.userColorScheme"> + <div v-if="!hideLineNumbers" class="line-numbers"> <a v-for="line in lineNumbers" :id="`L${line}`" diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue index 3c21b14894b..7563c35dfc8 100644 --- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue +++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue @@ -81,8 +81,8 @@ export default { }, }, i18n: { - fullDescription: __('Choose any color. Or you can choose one of the suggested colors below'), - shortDescription: __('Choose any color'), + fullDescription: __('Enter any color or choose one of the suggested colors below.'), + shortDescription: __('Enter any color.'), }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 7b88b36aa0f..ea507017caa 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -97,7 +97,7 @@ export default { }); }) .catch(() => { - this.previewContent = __('An error occurred while fetching markdown preview'); + this.previewContent = __('An error occurred while fetching Markdown preview'); this.isLoading = false; }); } diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue index c4dfcf93a18..014276c7e36 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue @@ -1,13 +1,11 @@ <script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; import { slugifyWithUnderscore } from '~/lib/utils/text_utility'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; export default { components: { GlAlert, - GlSprintf, - GlLink, LocalStorageSync, }, props: { @@ -15,10 +13,6 @@ export default { type: String, required: true, }, - feedbackLink: { - type: String, - required: true, - }, }, data() { return { @@ -44,19 +38,8 @@ export default { <template> <div v-show="showAlert"> <local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json /> - <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert"> - <gl-sprintf - :message=" - __( - 'Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience.', - ) - " - > - <template #featureName>{{ featureName }}</template> - <template #link="{ content }"> - <gl-link :href="feedbackLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> + <gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert"> + <slot></slot> </gl-alert> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue new file mode 100644 index 00000000000..5d0ed8b0821 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue @@ -0,0 +1,81 @@ +<script> +import { UP_KEY_CODE, DOWN_KEY_CODE, TAB_KEY_CODE } from '~/lib/utils/keycodes'; + +export default { + model: { + prop: 'index', + event: 'change', + }, + props: { + /* v-model property to manage location in list */ + index: { + type: Number, + required: true, + }, + /* Highest index that can be navigated to */ + max: { + type: Number, + required: true, + }, + /* Lowest index that can be navigated to */ + min: { + type: Number, + required: true, + }, + /* Which index to set v-model to on init */ + defaultIndex: { + type: Number, + required: true, + }, + }, + watch: { + max() { + // If the max index (list length) changes, reset the index + this.$emit('change', this.defaultIndex); + }, + }, + created() { + this.$emit('change', this.defaultIndex); + document.addEventListener('keydown', this.handleKeydown); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.handleKeydown); + }, + methods: { + handleKeydown(event) { + if (event.keyCode === DOWN_KEY_CODE) { + // Prevents moving scrollbar + event.preventDefault(); + event.stopPropagation(); + // Moves to next index + this.increment(1); + } else if (event.keyCode === UP_KEY_CODE) { + // Prevents moving scrollbar + event.preventDefault(); + event.stopPropagation(); + // Moves to previous index + this.increment(-1); + } else if (event.keyCode === TAB_KEY_CODE) { + this.$emit('tab'); + } + }, + increment(val) { + if (this.max === 0) { + return; + } + + const nextIndex = Math.max(this.min, Math.min(this.index + val, this.max)); + + // Return if the index didn't change + if (nextIndex === this.index) { + return; + } + + this.$emit('change', nextIndex); + }, + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql new file mode 100644 index 00000000000..9e9bda8ad3e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql @@ -0,0 +1,15 @@ +fragment EpicNode on Epic { + id + iid + group { + fullPath + } + title + state + reference + referencePath: reference(full: true) + webPath + webUrl + createdAt + closedAt +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql new file mode 100644 index 00000000000..4bb4b586fc9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql @@ -0,0 +1,16 @@ +#import "./epic.fragment.graphql" + +query searchEpics($fullPath: ID!, $search: String, $state: EpicState) { + group(fullPath: $fullPath) { + epics( + search: $search + state: $state + includeAncestorGroups: true + includeDescendantGroups: false + ) { + nodes { + ...EpicNode + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index d1326e96794..cee7c40aa83 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -67,6 +67,11 @@ export default { required: false, default: 'id', }, + searchBy: { + type: String, + required: false, + default: undefined, + }, }, data() { return { @@ -112,16 +117,18 @@ export default { ); }, showDefaultSuggestions() { - return this.availableDefaultSuggestions.length; + return this.availableDefaultSuggestions.length > 0; }, showRecentSuggestions() { - return this.isRecentSuggestionsEnabled && this.recentSuggestions.length && !this.searchKey; + return ( + this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey + ); }, showPreloadedSuggestions() { - return this.preloadedSuggestions.length && !this.searchKey; + return this.preloadedSuggestions.length > 0 && !this.searchKey; }, showAvailableSuggestions() { - return this.availableSuggestions.length; + return this.availableSuggestions.length > 0; }, showSuggestions() { // These conditions must match the template under `#suggestions` slot @@ -134,13 +141,19 @@ export default { this.showAvailableSuggestions ); }, + searchTerm() { + return this.searchBy && this.activeTokenValue + ? this.activeTokenValue[this.searchBy] + : undefined; + }, }, watch: { active: { immediate: true, handler(newValue) { if (!newValue && !this.suggestions.length) { - this.$emit('fetch-suggestions', this.value.data); + const search = this.searchTerm ? this.searchTerm : this.value.data; + this.$emit('fetch-suggestions', search); } }, }, @@ -148,8 +161,10 @@ export default { methods: { handleInput: debounce(function debouncedSearch({ data }) { this.searchKey = data; - if (!this.suggestionsLoading) { - this.$emit('fetch-suggestions', data); + + if (!this.suggestionsLoading && !this.activeTokenValue) { + const search = this.searchTerm ? this.searchTerm : data; + this.$emit('fetch-suggestions', search); } }, DEBOUNCE_DELAY), handleTokenValueSelected(activeTokenValue) { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue index 9f68308808e..9c2f5306654 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue @@ -1,22 +1,19 @@ <script> -import { - GlDropdownDivider, - GlFilteredSearchSuggestion, - GlFilteredSearchToken, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; -import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; +import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; +import searchEpicsQuery from '../queries/search_epics.query.graphql'; + +import BaseToken from './base_token.vue'; export default { - separator: '::&', + prefix: '&', + separator: '::', components: { - GlDropdownDivider, - GlFilteredSearchToken, + BaseToken, GlFilteredSearchSuggestion, - GlLoadingIcon, }, props: { config: { @@ -27,11 +24,15 @@ export default { type: Object, required: true, }, + active: { + type: Boolean, + required: true, + }, }, data() { return { epics: this.config.initialEpics || [], - loading: true, + loading: false, }; }, computed: { @@ -56,98 +57,73 @@ export default { } return this.defaultEpics; }, - activeEpic() { - if (this.currentValue && this.epics.length) { - // Check if current value is an epic ID. - if (typeof this.currentValue === 'number') { - return this.epics.find((epic) => epic[this.idProperty] === this.currentValue); - } - - // Current value is a string. - const [groupPath, idProperty] = this.currentValue?.split(this.$options.separator); - return this.epics.find( - (epic) => - epic.group_full_path === groupPath && - epic[this.idProperty] === parseInt(idProperty, 10), - ); - } - return null; - }, - displayText() { - return `${this.activeEpic?.title}${this.$options.separator}${this.activeEpic?.iid}`; - }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.epics.length) { - this.searchEpics({ data: this.currentValue }); - } - }, - }, }, methods: { - fetchEpicsBySearchTerm({ epicPath = '', search = '' }) { + fetchEpics(search = '') { + return this.$apollo + .query({ + query: searchEpicsQuery, + variables: { fullPath: this.config.fullPath, search }, + }) + .then(({ data }) => data.group?.epics.nodes); + }, + fetchEpicsBySearchTerm(search) { this.loading = true; - this.config - .fetchEpics({ epicPath, search }) + this.fetchEpics(search) .then((response) => { - this.epics = Array.isArray(response) ? response : response.data; + this.epics = Array.isArray(response) ? response : response?.data; }) .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) .finally(() => { this.loading = false; }); }, - searchEpics: debounce(function debouncedSearch({ data }) { - let epicPath = this.activeEpic?.web_url; - - // When user visits the page with token value already included in filters - // We don't have any information about selected token except for its - // group path and iid joined by separator, so we need to manually - // compose epic path from it. - if (data.includes?.(this.$options.separator)) { - const [groupPath, epicIid] = data.split(this.$options.separator); - epicPath = `/groups/${groupPath}/-/epics/${epicIid}`; + getActiveEpic(epics, data) { + if (data && epics.length) { + return epics.find((epic) => this.getValue(epic) === data); } - this.fetchEpicsBySearchTerm({ epicPath, search: data }); - }, DEBOUNCE_DELAY), - + return undefined; + }, getValue(epic) { - return this.config.useIdValue - ? String(epic[this.idProperty]) - : `${epic.group_full_path}${this.$options.separator}${epic[this.idProperty]}`; + return this.getEpicIdProperty(epic).toString(); + }, + displayValue(epic) { + return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${ + epic?.title + }`; + }, + getEpicIdProperty(epic) { + return getIdFromGraphQLId(epic[this.idProperty]); }, }, }; </script> <template> - <gl-filtered-search-token + <base-token :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :active="active" + :suggestions-loading="loading" + :suggestions="epics" + :get-active-token-value="getActiveEpic" + :default-suggestions="availableDefaultEpics" + :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" + search-by="title" + @fetch-suggestions="fetchEpicsBySearchTerm" v-on="$listeners" - @input="searchEpics" > - <template #view="{ inputValue }"> - {{ activeEpic ? displayText : inputValue }} + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }} </template> - <template #suggestions> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="epic in availableDefaultEpics" - :key="epic.value" - :value="epic.value" + v-for="epic in suggestions" + :key="epic.id" + :value="getValue(epic)" > - {{ epic.text }} + {{ epic.title }} </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="availableDefaultEpics.length" /> - <gl-loading-icon v-if="loading" size="sm" /> - <template v-else> - <gl-filtered-search-suggestion v-for="epic in epics" :key="epic.id" :value="getValue(epic)"> - {{ epic.title }} - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue index b2f077f5329..5955f31fc70 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -77,7 +77,7 @@ export default { }; </script> <template> - <div class="issue-assignees"> + <div> <user-avatar-link v-for="assignee in assigneesToShow" :key="assignee.id" @@ -97,10 +97,9 @@ export default { </user-avatar-link> <span v-if="numHiddenAssignees > 0" - v-gl-tooltip + v-gl-tooltip.bottom :title="assigneesCounterTooltip" class="avatar-counter" - data-placement="bottom" data-qa-selector="avatar_counter_content" >{{ assigneeCounterLabel }}</span > diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index 095d1854c8b..8aeff9257a5 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -1,6 +1,12 @@ <script> import '~/commons/bootstrap'; -import { GlIcon, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { + GlIcon, + GlTooltip, + GlTooltipDirective, + GlButton, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { sprintf } from '~/locale'; import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; @@ -22,6 +28,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + SafeHtml, }, mixins: [relatedIssuableMixin], props: { @@ -84,7 +91,7 @@ export default { /> </div> <gl-tooltip :target="() => $refs.iconElementXL"> - <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span> + <span v-safe-html="stateTitle"></span> </gl-tooltip> <gl-icon v-if="confidential" @@ -110,7 +117,7 @@ export default { class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2" > <gl-tooltip :target="() => this.$refs.iconElement"> - <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span> + <span v-safe-html="stateTitle"></span> </gl-tooltip> <span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{ itemPath 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 d6a20984ad1..ce7cbafb97d 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -1,5 +1,6 @@ <script> import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui'; +import { __, n__ } from '~/locale'; export default { components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton }, @@ -13,12 +14,26 @@ export default { type: String, required: true, }, + batchSuggestionsCount: { + type: Number, + required: false, + default: 0, + }, }, data() { return { message: null, }; }, + computed: { + dropdownText() { + if (this.batchSuggestionsCount <= 1) { + return __('Apply suggestion'); + } + + return n__('Apply %d suggestion', 'Apply %d suggestions', this.batchSuggestionsCount); + }, + }, methods: { onApply() { this.$emit('apply', this.message); @@ -29,10 +44,11 @@ export default { <template> <gl-dropdown - :text="__('Apply suggestion')" + :text="dropdownText" :disabled="disabled" boundary="window" right + lazy menu-class="gl-w-full!" data-qa-selector="apply_suggestion_dropdown" @shown="$refs.commitMessage.$el.focus()" diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 77730ada9bb..86f04c78ebe 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -254,7 +254,7 @@ export default { .then(() => $(this.$refs['markdown-preview']).renderGFM()) .catch(() => createFlash({ - message: __('Error rendering markdown preview'), + message: __('Error rendering Markdown preview'), }), ); }, diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index 9c954fce322..7d8d8c0b90e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -54,8 +54,8 @@ export default { applySuggestion(callback, message) { this.$emit('apply', { suggestionId: this.suggestion.id, callback, message }); }, - applySuggestionBatch() { - this.$emit('applyBatch'); + applySuggestionBatch(message) { + this.$emit('applyBatch', message); }, addSuggestionToBatch() { this.$emit('addToBatch', this.suggestion.id); diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 5fdef0b1a23..f9ae59567b2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -58,12 +58,19 @@ export default { isApplyingSingle: false, }; }, + computed: { isApplying() { return this.isApplyingSingle || this.isApplyingBatch; }, tooltipMessage() { - return this.canApply ? __('This also resolves this thread') : this.inapplicableReason; + if (!this.canApply) { + return this.inapplicableReason; + } + + return this.batchSuggestionsCount > 1 + ? __('This also resolves all related threads') + : __('This also resolves this thread'); }, isDisableButton() { return this.isApplying || !this.canApply; @@ -72,13 +79,30 @@ export default { if (this.isApplyingSingle || this.batchSuggestionsCount < 2) { return __('Applying suggestion...'); } + return __('Applying suggestions...'); }, isLoggedIn() { return isLoggedIn(); }, + showApplySuggestion() { + if (!this.isLoggedIn) return false; + + if (this.batchSuggestionsCount >= 1 && !this.isBatched) { + return false; + } + + return true; + }, }, methods: { + apply(message) { + if (this.batchSuggestionsCount > 1) { + this.applySuggestionBatch(message); + } else { + this.applySuggestion(message); + } + }, applySuggestion(message) { if (!this.canApply) return; this.isApplyingSingle = true; @@ -88,9 +112,9 @@ export default { applySuggestionCallback() { this.isApplyingSingle = false; }, - applySuggestionBatch() { + applySuggestionBatch(message) { if (!this.canApply) return; - this.$emit('applyBatch'); + this.$emit('applyBatch', message); }, addSuggestionToBatch() { this.$emit('addToBatch'); @@ -115,45 +139,34 @@ export default { <gl-loading-icon size="sm" class="d-flex-center mr-2" /> <span>{{ applyingSuggestionsMessage }}</span> </div> - <div v-else-if="canApply && isBatched" class="d-flex align-items-center"> - <gl-button - class="btn-inverted js-remove-from-batch-btn btn-grouped" - :disabled="isApplying" - @click="removeSuggestionFromBatch" - > - {{ __('Remove from batch') }} - </gl-button> - <gl-button - v-gl-tooltip.viewport="__('This also resolves all related threads')" - class="btn-inverted js-apply-batch-btn btn-grouped" - data-qa-selector="apply_suggestions_batch_button" - :disabled="isApplying" - variant="success" - @click="applySuggestionBatch" - > - {{ __('Apply suggestions') }} - <span class="badge badge-pill badge-pill-success"> - {{ batchSuggestionsCount }} - </span> - </gl-button> - </div> - <div v-else class="d-flex align-items-center"> - <gl-button - v-if="suggestionsCount > 1 && !isDisableButton" - class="btn-inverted js-add-to-batch-btn btn-grouped" - data-qa-selector="add_suggestion_batch_button" - :disabled="isDisableButton" - @click="addSuggestionToBatch" - > - {{ __('Add suggestion to batch') }} - </gl-button> + <div v-else-if="isLoggedIn" class="d-flex align-items-center"> + <div v-if="isBatched"> + <gl-button + class="btn-inverted js-remove-from-batch-btn btn-grouped" + :disabled="isApplying" + @click="removeSuggestionFromBatch" + > + {{ __('Remove from batch') }} + </gl-button> + </div> + <div v-else-if="!isDisableButton && suggestionsCount > 1"> + <gl-button + class="btn-inverted js-add-to-batch-btn btn-grouped" + data-qa-selector="add_suggestion_batch_button" + :disabled="isDisableButton" + @click="addSuggestionToBatch" + > + {{ __('Add suggestion to batch') }} + </gl-button> + </div> <apply-suggestion - v-if="isLoggedIn" + v-if="showApplySuggestion" v-gl-tooltip.viewport="tooltipMessage" :disabled="isDisableButton" :default-commit-message="defaultCommitMessage" + :batch-suggestions-count="batchSuggestionsCount" class="gl-ml-3" - @apply="applySuggestion" + @apply="apply" /> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 63774c6c498..e36cfb3b275 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -68,6 +68,10 @@ export default { if (this.suggestionsWatch) { this.suggestionsWatch(); } + + if (this.defaultCommitMessageWatch) { + this.defaultCommitMessageWatch(); + } }, methods: { renderSuggestions() { @@ -123,12 +127,16 @@ export default { suggestionDiff.suggestionsCount = this.suggestionsCount; }); + this.defaultCommitMessageWatch = this.$watch('defaultCommitMessage', () => { + suggestionDiff.defaultCommitMessage = this.defaultCommitMessage; + }); + suggestionDiff.$on('apply', ({ suggestionId, callback, message }) => { this.$emit('apply', { suggestionId, callback, flashContainer: this.$el, message }); }); - suggestionDiff.$on('applyBatch', () => { - this.$emit('applyBatch', { flashContainer: this.$el }); + suggestionDiff.$on('applyBatch', (message) => { + this.$emit('applyBatch', { message, flashContainer: this.$el }); }); suggestionDiff.$on('addToBatch', (suggestionId) => { diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index d6501a37a35..9ea14ed506c 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -34,12 +34,23 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, }, computed: { ...mapGetters(['getUserData']), renderedNote() { return renderMarkdown(this.note.body); }, + avatarSize() { + if (this.line) { + return 16; + } + return 40; + }, }, }; </script> @@ -50,7 +61,7 @@ export default { <user-avatar-link :link-href="getUserData.path" :img-src="getUserData.avatar_url" - :img-size="40" + :img-size="avatarSize" /> </div> <div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content"> diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js new file mode 100644 index 00000000000..9700117a3da --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js @@ -0,0 +1,34 @@ +import ProjectListItem from './project_list_item.vue'; + +export default { + component: ProjectListItem, + title: 'vue_shared/components/project_selector/project_list_item', +}; + +const Template = (args, { argTypes }) => ({ + components: { ProjectListItem }, + props: Object.keys(argTypes), + template: '<project-list-item v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + project: { + id: '1', + name: 'MyProject', + name_with_namespace: 'path / to / MyProject', + }, + selected: false, +}; + +export const SelectedProject = Template.bind({}); +SelectedProject.args = { + ...Default.args, + selected: true, +}; + +export const MatchedProject = Template.bind({}); +MatchedProject.args = { + ...Default.args, + matcher: 'proj', +}; diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue index 36d3696ec36..0bd57c84018 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { isString } from 'lodash'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; @@ -8,6 +8,7 @@ import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/def export default { name: 'ProjectListItem', components: { GlIcon, ProjectAvatar, GlButton }, + directives: { SafeHtml }, props: { project: { type: Object, @@ -58,9 +59,9 @@ export default { <span v-if="truncatedNamespace" class="text-secondary">/ </span> </div> <div + v-safe-html="highlightedProjectName" :title="project.name" class="js-project-name text-truncate" - v-html="highlightedProjectName /* eslint-disable-line vue/no-v-html */" ></div> </div> </gl-button> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue index 5c3a6852219..6538de085b0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue @@ -62,7 +62,7 @@ export default { <div> <clipboard-button v-if="!isLoading" - css-class="sidebar-collapsed-icon dont-change-state gl-rounded-0! gl-hover-bg-transparent" + css-class="sidebar-collapsed-icon js-dont-change-state gl-rounded-0! gl-hover-bg-transparent" v-bind="clipboardProps" /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 8853dc8b9e3..0ea22eb7aea 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -1,4 +1,5 @@ import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; +import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import { DropdownVariant } from '../constants'; import * as types from './mutation_types'; @@ -66,10 +67,10 @@ export default { } if (isScopedLabel(candidateLabel)) { - const scopedBase = scopedLabelKey(candidateLabel); - const currentActiveScopedLabel = state.labels.find(({ title }) => { - return title.startsWith(scopedBase) && title !== '' && title !== candidateLabel.title; - }); + const scopedKeyWithDelimiter = `${scopedLabelKey(candidateLabel)}${SCOPED_LABEL_DELIMITER}`; + const currentActiveScopedLabel = state.labels.find( + ({ title }) => title.startsWith(scopedKeyWithDelimiter) && title !== candidateLabel.title, + ); if (currentActiveScopedLabel) { currentActiveScopedLabel.set = false; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js index 00c54313292..389eb174c0e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js @@ -1,3 +1,5 @@ +export const SCOPED_LABEL_DELIMITER = '::'; + export const DropdownVariant = { Sidebar: 'sidebar', Standalone: 'standalone', 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 0fcc67c0ffa..3ee0baf8812 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 @@ -1,9 +1,9 @@ <script> import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; - +import { __, s__, sprintf } from '~/locale'; import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; -import { isDropdownVariantSidebar, isDropdownVariantEmbedded } from './utils'; +import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils'; export default { components: { @@ -48,10 +48,30 @@ export default { type: String, required: true, }, + issuableType: { + type: String, + required: true, + }, + isVisible: { + type: Boolean, + required: false, + default: false, + }, + fullPath: { + type: String, + required: true, + }, + attrWorkspacePath: { + type: String, + required: false, + default: undefined, + }, }, data() { return { showDropdownContentsCreateView: false, + localSelectedLabels: [...this.selectedLabels], + isDirty: false, }; }, computed: { @@ -64,28 +84,66 @@ export default { dropdownTitle() { return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle; }, + buttonText() { + if (!this.localSelectedLabels.length) { + return this.dropdownButtonText || __('Label'); + } else if (this.localSelectedLabels.length > 1) { + return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { + firstLabelName: this.localSelectedLabels[0].title, + remainingLabelCount: this.localSelectedLabels.length - 1, + }); + } + return this.localSelectedLabels[0].title; + }, showDropdownFooter() { - return ( - !this.showDropdownContentsCreateView && - (this.isDropdownVariantSidebar(this.variant) || - this.isDropdownVariantEmbedded(this.variant)) - ); + return !this.showDropdownContentsCreateView && !this.isStandalone; + }, + isStandalone() { + return isDropdownVariantStandalone(this.variant); }, }, - methods: { - showDropdown() { - this.$refs.dropdown.show(); + watch: { + localSelectedLabels: { + handler() { + this.isDirty = true; + }, + deep: true, + }, + isVisible(newVal) { + if (newVal) { + this.$refs.dropdown.show(); + this.isDirty = false; + } else { + this.$refs.dropdown.hide(); + this.setLabels(); + } }, + selectedLabels(newVal) { + this.localSelectedLabels = newVal; + }, + }, + methods: { toggleDropdownContentsCreateView() { this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView; }, toggleDropdownContent() { this.toggleDropdownContentsCreateView(); // Required to recalculate dropdown position as its size changes - this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate(); + if (this.$refs.dropdown?.$refs.dropdown) { + this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate(); + } + }, + setLabels() { + if (!this.isDirty) { + return; + } + this.$emit('setLabels', this.localSelectedLabels); + }, + handleDropdownHide() { + if (!isDropdownVariantSidebar(this.variant)) { + this.setLabels(); + } }, - isDropdownVariantSidebar, - isDropdownVariantEmbedded, }, }; </script> @@ -93,14 +151,16 @@ export default { <template> <gl-dropdown ref="dropdown" - :text="dropdownButtonText" + :text="buttonText" class="gl-w-full gl-mt-2" data-qa-selector="labels_dropdown_content" + @hide="handleDropdownHide" > <template #header> <div - v-if="isDropdownVariantSidebar(variant) || isDropdownVariantEmbedded(variant)" + v-if="!isStandalone" class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + data-testid="dropdown-header" > <gl-button v-if="showDropdownContentsCreateView" @@ -119,27 +179,33 @@ export default { size="small" class="dropdown-header-button gl-p-0!" icon="close" + data-testid="close-button" @click="$emit('closeDropdown')" /> </div> </template> - <component - :is="dropdownContentsView" - :selected-labels="selectedLabels" - :allow-multiselect="allowMultiselect" - @hideCreateView="toggleDropdownContentsCreateView" - @setLabels="$emit('setLabels', $event)" - /> + <template #default> + <component + :is="dropdownContentsView" + v-model="localSelectedLabels" + :selected-labels="selectedLabels" + :allow-multiselect="allowMultiselect" + :issuable-type="issuableType" + :full-path="fullPath" + :attr-workspace-path="attrWorkspacePath" + @hideCreateView="toggleDropdownContentsCreateView" + /> + </template> <template #footer> <div v-if="showDropdownFooter" data-testid="dropdown-footer"> <gl-dropdown-item v-if="allowLabelCreate" data-testid="create-label-button" - @click.native.capture.stop="toggleDropdownContent" + @click.capture.native.stop="toggleDropdownContent" > {{ footerCreateLabelTitle }} </gl-dropdown-item> - <gl-dropdown-item :href="labelsManagePath" @click.native.capture.stop> + <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop> {{ footerManageLabelTitle }} </gl-dropdown-item> </div> 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 2e31b386fdd..a2ed08e6b28 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 @@ -2,9 +2,10 @@ import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; import produce from 'immer'; import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; import { __ } from '~/locale'; +import { labelsQueries } from '~/sidebar/constants'; import createLabelMutation from './graphql/create_label.mutation.graphql'; -import projectLabelsQuery from './graphql/project_labels.query.graphql'; const errorMessage = __('Error creating label.'); @@ -18,9 +19,19 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - inject: { - projectPath: { - default: '', + props: { + issuableType: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + attrWorkspacePath: { + type: String, + required: false, + default: undefined, }, }, data() { @@ -38,6 +49,27 @@ export default { const colorsMap = gon.suggested_label_colors; return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] })); }, + mutationVariables() { + if (this.issuableType === IssuableType.Epic) { + return { + title: this.labelTitle, + color: this.selectedColor, + groupPath: this.fullPath, + }; + } + + return this.attrWorkspacePath !== undefined + ? { + title: this.labelTitle, + color: this.selectedColor, + groupPath: this.attrWorkspacePath, + } + : { + title: this.labelTitle, + color: this.selectedColor, + projectPath: this.fullPath, + }; + }, }, methods: { getColorCode(color) { @@ -51,8 +83,8 @@ export default { }, updateLabelsInCache(store, label) { const sourceData = store.readQuery({ - query: projectLabelsQuery, - variables: { fullPath: this.projectPath, searchTerm: '' }, + query: labelsQueries[this.issuableType].workspaceQuery, + variables: { fullPath: this.fullPath, searchTerm: '' }, }); const collator = new Intl.Collator('en'); @@ -63,8 +95,8 @@ export default { }); store.writeQuery({ - query: projectLabelsQuery, - variables: { fullPath: this.projectPath, searchTerm: '' }, + query: labelsQueries[this.issuableType].workspaceQuery, + variables: { fullPath: this.fullPath, searchTerm: '' }, data, }); }, @@ -75,11 +107,7 @@ export default { data: { labelCreate }, } = await this.$apollo.mutate({ mutation: createLabelMutation, - variables: { - title: this.labelTitle, - color: this.selectedColor, - projectPath: this.projectPath, - }, + variables: this.mutationVariables, update: ( store, { 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 857367a0721..e6a25362ff0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -1,12 +1,18 @@ <script> -import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlDropdownForm, + GlDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, + GlIntersectionObserver, +} from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { debounce } from 'lodash'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __ } from '~/locale'; -import projectLabelsQuery from './graphql/project_labels.query.graphql'; +import { labelsQueries } from '~/sidebar/constants'; import LabelItem from './label_item.vue'; export default { @@ -15,9 +21,12 @@ export default { GlDropdownItem, GlLoadingIcon, GlSearchBoxByType, + GlIntersectionObserver, LabelItem, }, - inject: ['projectPath'], + model: { + prop: 'localSelectedLabels', + }, props: { selectedLabels: { type: Array, @@ -27,30 +36,44 @@ export default { type: Boolean, required: true, }, + issuableType: { + type: String, + required: true, + }, + localSelectedLabels: { + type: Array, + required: true, + }, + fullPath: { + type: String, + required: true, + }, }, data() { return { searchKey: '', labels: [], - localSelectedLabels: [...this.selectedLabels], + isVisible: false, }; }, apollo: { labels: { - query: projectLabelsQuery, + query() { + return labelsQueries[this.issuableType].workspaceQuery; + }, variables() { return { - fullPath: this.projectPath, + fullPath: this.fullPath, searchTerm: this.searchKey, }; }, skip() { - return this.searchKey.length === 1; + return this.searchKey.length === 1 || !this.isVisible; }, update: (data) => data.workspace?.labels?.nodes || [], async result() { if (this.$refs.searchInput) { - await this.$nextTick(); + await this.$nextTick; this.$refs.searchInput.focusInput(); } }, @@ -64,7 +87,7 @@ export default { return this.$apollo.queries.labels.loading; }, localSelectedLabelsIds() { - return this.localSelectedLabels.map((label) => label.id); + return this.localSelectedLabels.map((label) => getIdFromGraphQLId(label.id)); }, visibleLabels() { if (this.searchKey) { @@ -82,7 +105,6 @@ export default { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, beforeDestroy() { - this.$emit('setLabels', this.localSelectedLabels); this.debouncedSearchKeyUpdate.cancel(); }, methods: { @@ -109,16 +131,21 @@ export default { } }, updateSelectedLabels(label) { + let labels; if (this.isLabelSelected(label)) { - this.localSelectedLabels = this.localSelectedLabels.filter( - ({ id }) => id !== getIdFromGraphQLId(label.id), + labels = this.localSelectedLabels.filter( + ({ id }) => id !== getIdFromGraphQLId(label.id) && id !== label.id, ); } else { - this.localSelectedLabels.push({ - ...label, - id: getIdFromGraphQLId(label.id), - }); + labels = [ + ...this.localSelectedLabels, + { + ...label, + id: getIdFromGraphQLId(label.id), + }, + ]; } + this.$emit('input', labels); }, handleLabelClick(label) { this.updateSelectedLabels(label); @@ -129,46 +156,52 @@ export default { setSearchKey(value) { this.searchKey = value; }, + onDropdownAppear() { + this.isVisible = true; + this.$refs.searchInput.focusInput(); + }, }, }; </script> <template> - <gl-dropdown-form class="labels-select-contents-list js-labels-list"> - <gl-search-box-by-type - ref="searchInput" - :value="searchKey" - :disabled="labelsFetchInProgress" - data-qa-selector="dropdown_input_field" - data-testid="dropdown-input-field" - @input="debouncedSearchKeyUpdate" - /> - <div ref="labelsListContainer" data-testid="dropdown-content"> - <gl-loading-icon - v-if="labelsFetchInProgress" - class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full" - size="md" + <gl-intersection-observer @appear="onDropdownAppear"> + <gl-dropdown-form class="labels-select-contents-list js-labels-list"> + <gl-search-box-by-type + ref="searchInput" + :value="searchKey" + :disabled="labelsFetchInProgress" + data-qa-selector="dropdown_input_field" + data-testid="dropdown-input-field" + @input="debouncedSearchKeyUpdate" /> - <template v-else> - <gl-dropdown-item - v-for="label in visibleLabels" - :key="label.id" - :is-checked="isLabelSelected(label)" - :is-check-centered="true" - :is-check-item="true" - data-testid="labels-list" - @click.native.capture.stop="handleLabelClick(label)" - > - <label-item :label="label" /> - </gl-dropdown-item> - <gl-dropdown-item - v-show="showNoMatchingResultsMessage" - class="gl-p-3 gl-text-center" - data-testid="no-results" - > - {{ __('No matching results') }} - </gl-dropdown-item> - </template> - </div> - </gl-dropdown-form> + <div ref="labelsListContainer" data-testid="dropdown-content"> + <gl-loading-icon + v-if="labelsFetchInProgress" + class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3" + size="md" + /> + <template v-else> + <gl-dropdown-item + v-for="label in visibleLabels" + :key="label.id" + :is-checked="isLabelSelected(label)" + :is-check-centered="true" + :is-check-item="true" + data-testid="labels-list" + @click.native.capture.stop="handleLabelClick(label)" + > + <label-item :label="label" /> + </gl-dropdown-item> + <gl-dropdown-item + v-show="showNoMatchingResultsMessage" + class="gl-p-3 gl-text-center" + data-testid="no-results" + > + {{ __('No matching results') }} + </gl-dropdown-item> + </template> + </div> + </gl-dropdown-form> + </gl-intersection-observer> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql new file mode 100644 index 00000000000..a2e8579486f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql @@ -0,0 +1,15 @@ +query epicLabels($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + issuable: epic(iid: $iid) { + id + labels { + nodes { + id + title + color + description + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql new file mode 100644 index 00000000000..acc9bcd2015 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql @@ -0,0 +1,12 @@ +query groupLabels($fullPath: ID!, $searchTerm: String) { + workspace: group(fullPath: $fullPath) { + labels(searchTerm: $searchTerm, onlyGroupLabels: true) { + nodes { + id + title + color + description + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 3c834770563..6bd43da2203 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 @@ -1,21 +1,18 @@ <script> -import Vue from 'vue'; -import Vuex from 'vuex'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { labelsQueries } from '~/sidebar/constants'; import { DropdownVariant } from './constants'; import DropdownContents from './dropdown_contents.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; -import issueLabelsQuery from './graphql/issue_labels.query.graphql'; import { isDropdownVariantSidebar, isDropdownVariantStandalone, isDropdownVariantEmbedded, } from './utils'; -Vue.use(Vuex); - export default { components: { DropdownValue, @@ -23,8 +20,21 @@ export default { DropdownValueCollapsed, SidebarEditableItem, }, - inject: ['iid', 'projectPath', 'allowLabelEdit'], + inject: { + allowLabelEdit: { + default: false, + }, + }, props: { + iid: { + type: String, + required: false, + default: '', + }, + fullPath: { + type: String, + required: true, + }, allowLabelRemove: { type: Boolean, required: false, @@ -90,43 +100,60 @@ export default { required: false, default: false, }, + issuableType: { + type: String, + required: true, + }, + attrWorkspacePath: { + type: String, + required: false, + default: undefined, + }, }, data() { return { contentIsOnViewport: true, - issueLabels: [], + issuableLabels: [], }; }, + computed: { + isLoading() { + return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading; + }, + }, apollo: { - issueLabels: { - query: issueLabelsQuery, + issuableLabels: { + query() { + return labelsQueries[this.issuableType].issuableQuery; + }, + skip() { + return !isDropdownVariantSidebar(this.variant); + }, variables() { return { iid: this.iid, - fullPath: this.projectPath, + fullPath: this.fullPath, }; }, update(data) { return data.workspace?.issuable?.labels.nodes || []; }, + error() { + createFlash({ message: __('Error fetching labels.') }); + }, }, }, methods: { handleDropdownClose(labels) { - if (labels.length) this.$emit('updateSelectedLabels', labels); - this.$emit('onDropdownClose'); + this.$emit('updateSelectedLabels', labels); + this.collapseEditableItem(); }, - collapseDropdown() { - this.$refs.editable.collapse(); + collapseEditableItem() { + this.$refs.editable?.collapse(); }, handleCollapsedValueClick() { this.$emit('toggleCollapse'); }, - showDropdown() { - this.$nextTick(() => { - this.$refs.dropdownContents.showDropdown(); - }); - }, isDropdownVariantSidebar, isDropdownVariantStandalone, isDropdownVariantEmbedded, @@ -145,20 +172,19 @@ export default { <template v-if="isDropdownVariantSidebar(variant)"> <dropdown-value-collapsed ref="dropdownButtonCollapsed" - :labels="issueLabels" + :labels="issuableLabels" @onValueClick="handleCollapsedValueClick" /> <sidebar-editable-item ref="editable" :title="__('Labels')" - :loading="labelsSelectInProgress" + :loading="isLoading" :can-edit="allowLabelEdit" - @open="showDropdown" > <template #collapsed> <dropdown-value :disable-labels="labelsSelectInProgress" - :selected-labels="issueLabels" + :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @@ -170,7 +196,7 @@ export default { <template #default="{ edit }"> <dropdown-value :disable-labels="labelsSelectInProgress" - :selected-labels="issueLabels" + :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @@ -180,8 +206,6 @@ export default { <slot></slot> </dropdown-value> <dropdown-contents - v-if="edit" - ref="dropdownContents" :dropdown-button-text="dropdownButtonText" :allow-multiselect="allowMultiselect" :labels-list-title="labelsListTitle" @@ -190,11 +214,30 @@ export default { :labels-create-title="labelsCreateTitle" :selected-labels="selectedLabels" :variant="variant" - @closeDropdown="collapseDropdown" + :issuable-type="issuableType" + :is-visible="edit" + :full-path="fullPath" + :attr-workspace-path="attrWorkspacePath" @setLabels="handleDropdownClose" + @closeDropdown="collapseEditableItem" /> </template> </sidebar-editable-item> </template> + <dropdown-contents + v-else + ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :dropdown-button-text="dropdownButtonText" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + :labels-create-title="labelsCreateTitle" + :selected-labels="selectedLabels" + :variant="variant" + :issuable-type="issuableType" + :full-path="fullPath" + @setLabels="handleDropdownClose" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js index d2afc02233e..294e5bd9f90 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js @@ -4,7 +4,7 @@ import TodoButton from './todo_button.vue'; export default { component: TodoButton, - title: 'vue_shared/components/todo_toggle/todo_button', + title: 'vue_shared/components/sidebar/todo_toggle/todo_button', }; const Template = (args, { argTypes }) => ({ diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue index afb1ea702fa..0a7a22ed3a8 100644 --- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue +++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue @@ -45,7 +45,7 @@ export default { data() { return { dragCounter: 0, - isDragDataValid: false, + isDragDataValid: true, }; }, computed: { diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js new file mode 100644 index 00000000000..256db2ea1ce --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js @@ -0,0 +1,5 @@ +// Types of obstacles to user deletion +export const OBSTACLE_TYPES = Object.freeze({ + oncallSchedules: 'ONCALL_SCHEDULE', + escalationPolicies: 'ESCALATION_POLICY', +}); diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js new file mode 100644 index 00000000000..d2030c14029 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js @@ -0,0 +1,37 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import { OBSTACLE_TYPES } from './constants'; +import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue'; + +export default { + component: UserDeletionObstaclesList, + title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list', +}; + +const Template = (args, { argTypes }) => ({ + components: { UserDeletionObstaclesList }, + props: Object.keys(argTypes), + template: '<user-deletion-obstacles-list v-bind="$props" v-on="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + obstacles: [ + { + type: OBSTACLE_TYPES.oncallSchedules, + name: 'APAC', + url: 'https://domain.com/group/main-application/oncall_schedules', + projectName: 'main-application', + projectUrl: 'https://domain.com/group/main-application', + }, + { + type: OBSTACLE_TYPES.escalationPolicies, + name: 'Engineering On-call', + url: 'https://domain.com/group/microservice-backend/escalation_policies', + projectName: 'Microservice Backend', + projectUrl: 'https://domain.com/group/microservice-backend', + }, + ], + userName: 'Thomspon Smith', + isCurrentUser: false, +}; diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue index e37a663ace3..1eea660d527 100644 --- a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue @@ -1,6 +1,16 @@ <script> import { GlSprintf, GlLink } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; +import { OBSTACLE_TYPES } from './constants'; + +const OBSTACLE_TEXT = { + [OBSTACLE_TYPES.oncallSchedules]: s__( + 'OnCallSchedules|On-call schedule %{obstacle} in Project %{project}', + ), + [OBSTACLE_TYPES.escalationPolicies]: s__( + 'EscalationPolicies|Escalation policy %{obstacle} in Project %{project}', + ), +}; export default { components: { @@ -8,7 +18,7 @@ export default { GlLink, }, props: { - schedules: { + obstacles: { type: Array, required: true, }, @@ -45,6 +55,15 @@ export default { ); }, }, + methods: { + textForObstacle(obstacle) { + return OBSTACLE_TEXT[obstacle.type]; + }, + urlForObstacle(obstacle) { + // Fallback to scheduleUrl for backwards compatibility + return obstacle.url || obstacle.scheduleUrl; + }, + }, }; </script> @@ -52,17 +71,15 @@ export default { <div> <p data-testid="title">{{ title }}</p> - <ul data-testid="schedules-list"> - <li v-for="(schedule, index) in schedules" :key="`${schedule.name}-${index}`"> - <gl-sprintf - :message="s__('OnCallSchedules|On-call schedule %{schedule} in Project %{project}')" - > - <template #schedule> - <gl-link :href="schedule.scheduleUrl" target="_blank">{{ schedule.name }}</gl-link> + <ul data-testid="obstacles-list"> + <li v-for="(obstacle, index) in obstacles" :key="`${obstacle.name}-${index}`"> + <gl-sprintf :message="textForObstacle(obstacle)"> + <template #obstacle> + <gl-link :href="urlForObstacle(obstacle)" target="_blank">{{ obstacle.name }}</gl-link> </template> <template #project> - <gl-link :href="schedule.projectUrl" target="_blank">{{ - schedule.projectName + <gl-link :href="obstacle.projectUrl" target="_blank">{{ + obstacle.projectName }}</gl-link> </template> </gl-sprintf> diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js new file mode 100644 index 00000000000..502302a1ef2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js @@ -0,0 +1,19 @@ +import { OBSTACLE_TYPES } from './constants'; + +const addTypeToObstacles = (obstacles, type) => { + if (!obstacles) return []; + + return obstacles?.map((obstacle) => ({ type, ...obstacle })); +}; + +// For use with user objects formatted via internal REST API. +// If the removal/deletion of a user could cause critical +// problems, return a single array containing all affected +// associations including their type. +export const parseUserDeletionObstacles = (user) => { + if (!user) return []; + + return Object.keys(OBSTACLE_TYPES).flatMap((type) => { + return addTypeToObstacles(user[type], OBSTACLE_TYPES[type]); + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 74616763f8f..05e0c3b0be3 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -93,19 +93,27 @@ export default { </div> <div class="gl-text-gray-500"> <div v-if="user.bio" class="gl-display-flex gl-mb-2"> - <gl-icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" /> + <gl-icon name="profile" class="gl-flex-shrink-0" /> <span ref="bio" class="gl-ml-2 gl-overflow-hidden">{{ user.bio }}</span> </div> <div v-if="user.workInformation" class="gl-display-flex gl-mb-2"> - <gl-icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" /> + <gl-icon name="work" class="gl-flex-shrink-0" /> <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span> </div> + <div v-if="user.location" class="gl-display-flex gl-mb-2"> + <gl-icon name="location" class="gl-flex-shrink-0" /> + <span class="gl-ml-2">{{ user.location }}</span> + </div> + <div + v-if="user.localTime && !user.bot" + class="gl-display-flex gl-mb-2" + data-testid="user-popover-local-time" + > + <gl-icon name="clock" class="gl-flex-shrink-0" /> + <span class="gl-ml-2">{{ user.localTime }}</span> + </div> </div> - <div v-if="user.location" class="js-location gl-text-gray-500 gl-display-flex"> - <gl-icon name="location" class="gl-text-gray-400 flex-shrink-0" /> - <span class="gl-ml-2">{{ user.location }}</span> - </div> - <div v-if="statusHtml" class="js-user-status gl-mt-3"> + <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status"> <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span> </div> <div v-if="user.bot" class="gl-text-blue-500"> 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 df0981aea7a..6da2d39a95a 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -92,7 +92,10 @@ export default { const handleOptions = this.needsToFork ? { href: '#modal-confirm-fork-edit', - handle: () => this.showModal('#modal-confirm-fork-edit'), + handle: () => { + this.$emit('edit', 'simple'); + this.showModal('#modal-confirm-fork-edit'); + }, } : { href: this.editUrl }; @@ -128,7 +131,10 @@ export default { const handleOptions = this.needsToFork ? { href: '#modal-confirm-fork-webide', - handle: () => this.showModal('#modal-confirm-fork-webide'), + handle: () => { + this.$emit('edit', 'ide'); + this.showModal('#modal-confirm-fork-webide'); + }, } : { href: this.webIdeUrl }; diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js index 692f2769b88..779b04dc2bd 100644 --- a/app/assets/javascripts/vue_shared/directives/validation.js +++ b/app/assets/javascripts/vue_shared/directives/validation.js @@ -1,4 +1,3 @@ -import { merge } from 'lodash'; import { s__ } from '~/locale'; /** @@ -21,8 +20,15 @@ const defaultFeedbackMap = { }, }; -const getFeedbackForElement = (feedbackMap, el) => - Object.values(feedbackMap).find((f) => f.isInvalid(el))?.message || el.validationMessage; +const getFeedbackForElement = (feedbackMap, el) => { + const field = Object.values(feedbackMap).find((f) => f.isInvalid(el)); + let elMessage = null; + if (field) { + elMessage = el.getAttribute('validation-message'); + } + + return field?.message || elMessage || el.validationMessage; +}; const focusFirstInvalidInput = (e) => { const { target: formEl } = e; @@ -68,6 +74,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa /** * Takes an object that allows to add or change custom feedback messages. + * See possibilities here: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState * * The passed in object will be merged with the built-in feedback * so it is possible to override a built-in message. @@ -75,7 +82,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa * @example * validate({ * tooLong: { - * check: el => el.validity.tooLong === true, + * isInvalid: el => el.validity.tooLong === true, * message: 'Your custom feedback' * } * }) @@ -91,7 +98,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa * @returns {{ inserted: function, update: function }} validateDirective */ export default function initValidation(customFeedbackMap = {}) { - const feedbackMap = merge(defaultFeedbackMap, customFeedbackMap); + const feedbackMap = { ...defaultFeedbackMap, ...customFeedbackMap }; const elDataMap = new WeakMap(); return { diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue index 0ff858e6afc..42272c222fc 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -100,6 +100,7 @@ export default { :loading="isLoading" :variant="variant" :category="category" + :data-qa-selector="`${feature.type}_mr_button`" @click="mutate" >{{ $options.i18n.buttonLabel }}</gl-button > diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue index ad40ea6a964..12f2bc71505 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -50,7 +50,7 @@ export default { required: false, default: '', }, - secretScanningComparisonPath: { + secretDetectionComparisonPath: { type: String, required: false, default: '', @@ -149,8 +149,8 @@ export default { this.canShowCounts = true; } - if (this.secretScanningComparisonPath && this.hasSecretDetectionReports) { - this.setSecretDetectionDiffEndpoint(this.secretScanningComparisonPath); + if (this.secretDetectionComparisonPath && this.hasSecretDetectionReports) { + this.setSecretDetectionDiffEndpoint(this.secretDetectionComparisonPath); this.fetchSecretDetectionDiff(); this.canShowCounts = true; } diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js index 4f92e181f9f..62a51abe038 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js @@ -1,3 +1,4 @@ +import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; import { fetchDiffData } from '../../utils'; import * as types from './mutation_types'; @@ -14,7 +15,7 @@ export const receiveDiffError = ({ commit }, response) => export const fetchDiff = ({ state, rootState, dispatch }) => { dispatch('requestDiff'); - return fetchDiffData(rootState, state.paths.diffEndpoint, 'sast') + return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SAST) .then((data) => { dispatch('receiveDiffSuccess', data); }) diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js index e3ae5435f5d..722dcce3075 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js @@ -1,3 +1,4 @@ +import { REPORT_TYPE_SECRET_DETECTION } from '~/vue_shared/security_reports/constants'; import { fetchDiffData } from '../../utils'; import * as types from './mutation_types'; @@ -14,7 +15,7 @@ export const receiveDiffError = ({ commit }, response) => export const fetchDiff = ({ state, rootState, dispatch }) => { dispatch('requestDiff'); - return fetchDiffData(rootState, state.paths.diffEndpoint, 'secret_detection') + return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SECRET_DETECTION) .then((data) => { dispatch('receiveDiffSuccess', data); }) diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index fa5ab590232..8f3b5b3b7cc 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -1,5 +1,4 @@ @import './pages/branches'; -@import './pages/ci_projects'; @import './pages/clusters'; @import './pages/commits'; @import './pages/deploy_keys'; @@ -10,7 +9,6 @@ @import './pages/groups'; @import './pages/help'; @import './pages/issuable'; -@import './pages/issues/issue_count_badge'; @import './pages/issues'; @import './pages/labels'; @import './pages/login'; diff --git a/app/assets/stylesheets/application_dark.scss b/app/assets/stylesheets/application_dark.scss index dae0cd72a8f..f1d7df8c5ed 100644 --- a/app/assets/stylesheets/application_dark.scss +++ b/app/assets/stylesheets/application_dark.scss @@ -2,74 +2,4 @@ @import './application'; -@import './themes/theme_helper'; - -body.gl-dark { - @include gitlab-theme( - $gray-900, - $gray-400, - $gray-500, - $gray-800, - $gray-900, - $white - ); - - .logo-text svg { - fill: var(--gl-text-color); - } - - .navbar-gitlab { - background-color: var(--gray-50); - box-shadow: 0 1px 0 0 var(--gray-100); - - .navbar-sub-nav, - .navbar-nav { - li { - > a:hover, - > a:focus, - > button:hover, - > button:focus { - color: var(--gl-text-color); - background-color: var(--gray-200); - } - } - - li.active, - li.dropdown.show { - > a, - > button { - color: var(--gl-text-color); - background-color: var(--gray-200); - } - } - } - - .header-search { - background-color: var(--gray-100) !important; - box-shadow: inset 0 0 0 1px var(--border-color) !important; - - &:active, - &:hover { - background-color: var(--gray-100) !important; - box-shadow: inset 0 0 0 1px var(--blue-200) !important; - } - } - - .search { - form { - background-color: var(--gray-100); - box-shadow: inset 0 0 0 1px var(--border-color); - - &:active, - &:hover { - background-color: var(--gray-100); - box-shadow: inset 0 0 0 1px var(--blue-200); - } - } - } - } - - .md :not(pre.code) > code { - background-color: $gray-200; - } -} +@import './themes/dark_mode_overrides'; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index c4f292dd05d..27ddff181c5 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -112,7 +112,7 @@ code { border-radius: $border-radius-default; .code > &, - .build-trace & { + .build-log & { background-color: inherit; padding: unset; } diff --git a/app/assets/stylesheets/components/batch_comments/review_bar.scss b/app/assets/stylesheets/components/batch_comments/review_bar.scss index bcd06974813..6f5c5c5a080 100644 --- a/app/assets/stylesheets/components/batch_comments/review_bar.scss +++ b/app/assets/stylesheets/components/batch_comments/review_bar.scss @@ -38,59 +38,6 @@ margin: 0 auto; } -.review-preview-dropdown { - .review-preview-item.menu-item { - display: flex; - flex-wrap: wrap; - padding: 8px 16px; - cursor: pointer; - - &:not(.is-last) { - border-bottom: 1px solid $list-border; - } - } - - .dropdown-menu { - top: auto; - bottom: 36px; - - &.show { - max-height: 400px; - - @include media-breakpoint-down(xs) { - width: calc(100vw - 32px); - } - } - } - - .dropdown-content { - max-height: 300px; - } - - .dropdown-title { - padding: $grid-size 25px $gl-padding; - margin-bottom: 0; - } - - .dropdown-footer { - margin-top: 0; - } - - .dropdown-menu-close { - top: 6px; - } -} - -.review-preview-dropdown-toggle { - svg.s16 { - width: 15px; - height: 15px; - margin-top: -1px; - top: 3px; - margin-left: 4px; - } -} - .review-preview-item-header { display: flex; align-items: center; diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index a013d971efb..7f498b79d33 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -1,9 +1,13 @@ .ProseMirror { + max-height: 55vh; + overflow-y: auto; + td, th, li, dd, - dt { + dt, + summary { :first-child { margin-bottom: 0 !important; } @@ -37,6 +41,7 @@ } } + .dl-content { width: 100%; @@ -50,6 +55,38 @@ } } } + + .details-toggle-icon { + cursor: pointer; + z-index: 1; + + &::before { + content: 'â–¶'; + display: inline-block; + width: $gl-spacing-scale-4; + } + + &.is-open::before { + content: 'â–¼'; + } + } + + .details-content { + width: calc(100% - #{$gl-spacing-scale-4}); + + > li { + list-style-type: none; + margin-left: $gl-spacing-scale-2; + } + + > :not(:first-child) { + display: none; + } + + &.is-open > :not(:first-child) { + display: inherit; + } + } } .table-creator-grid-item { @@ -70,3 +107,17 @@ @include gl-white-space-nowrap; } + + +.content-editor-color-chip::after { + content: ' '; + display: inline-block; + align-items: center; + width: 11px; + height: 11px; + border-radius: 3px; + margin-left: 4px; + margin-top: -2px; + border: 1px solid $black-transparent; + background-color: var(--gl-color-chip-color); +} diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss index 579a86a94a4..a3cbdb9ae86 100644 --- a/app/assets/stylesheets/components/design_management/design.scss +++ b/app/assets/stylesheets/components/design_management/design.scss @@ -70,11 +70,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); } } -.design-presentation-wrapper { - top: 0; - left: 0; -} - .design-scaler-wrapper { bottom: 0; left: 50%; diff --git a/app/assets/stylesheets/components/design_management/design_version_dropdown.scss b/app/assets/stylesheets/components/design_management/design_version_dropdown.scss deleted file mode 100644 index f79d672e238..00000000000 --- a/app/assets/stylesheets/components/design_management/design_version_dropdown.scss +++ /dev/null @@ -1,3 +0,0 @@ -.design-version-dropdown > button { - background: inherit; -} diff --git a/app/assets/stylesheets/components/project_list_item.scss b/app/assets/stylesheets/components/project_list_item.scss deleted file mode 100644 index 8e7c2c4398c..00000000000 --- a/app/assets/stylesheets/components/project_list_item.scss +++ /dev/null @@ -1,24 +0,0 @@ -.project-list-item { - &:not(:disabled):not(.disabled) { - &:focus, - &:active, - &:focus:active { - outline: none; - box-shadow: none; - } - } -} - -// When housed inside a modal, the edge of each item -// should extend to the edge of the modal. -.modal-body { - .project-list-item { - border-radius: 0; - margin-left: -$gl-padding; - margin-right: -$gl-padding; - - .project-namespace-name-container { - overflow: hidden; - } - } -} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 804cc205279..06a8694eb3d 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -9,7 +9,6 @@ @import 'framework/animations'; @import 'framework/vue_transitions'; -@import 'framework/banner'; @import 'framework/blocks'; @import 'framework/buttons'; @import 'framework/badges'; diff --git a/app/assets/stylesheets/framework/banner.scss b/app/assets/stylesheets/framework/banner.scss deleted file mode 100644 index 71bbab2065d..00000000000 --- a/app/assets/stylesheets/framework/banner.scss +++ /dev/null @@ -1,40 +0,0 @@ -.banner-callout { - display: flex; - position: relative; - align-items: start; - - .banner-close { - position: absolute; - top: 10px; - right: 10px; - opacity: 1; - - .dismiss-icon { - color: $gl-text-color; - font-size: $gl-font-size; - } - } - - .banner-graphic { - margin: 0 $gl-padding $gl-padding 0; - } - - &.banner-non-empty-state { - border-bottom: 1px solid $border-color; - } - - @include media-breakpoint-down(xs) { - justify-content: center; - flex-direction: column; - align-items: center; - - .banner-title, - .banner-buttons { - text-align: center; - } - - .banner-graphic { - margin-left: $gl-padding; - } - } -} diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index a0682eabf01..549289450a4 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -1,9 +1,3 @@ -.centered-light-block { - text-align: center; - color: $gl-text-color; - margin: 20px; -} - .nothing-here-block { text-align: center; padding: 16px; @@ -83,22 +77,6 @@ > p:last-child { margin-bottom: 0; } - - .block-controls { - display: flex; - justify-content: flex-end; - flex: 1; - - .control { - float: left; - margin-left: 10px; - } - } - - &.build-content { - background-color: $white; - border-top: 0; - } } .sub-header-block { @@ -169,31 +147,6 @@ } } - &.groups-cover-block { - background: $white; - border-bottom: 1px solid $border-color; - text-align: left; - padding: 24px 0; - - .group-info { - .cover-title { - margin-top: 9px; - } - - p { - margin-bottom: 0; - } - } - - @include media-breakpoint-down(xs) { - text-align: center; - - .avatar { - float: none; - } - } - } - &.user-cover-block { padding: 24px 0 0; @@ -214,19 +167,6 @@ margin-right: auto; } } - - .group-info { - h1 { - display: inline; - font-weight: $gl-font-weight-normal; - font-size: 24px; - color: $gl-text-color; - } - } -} - -.block-connector { - margin-top: -1px; } .content-block { @@ -322,7 +262,7 @@ display: inline-block; } - .btn { + .btn:not(.split-content-button):not(.dropdown-toggle-split) { margin: $gl-padding-8 $gl-padding-4; @include media-breakpoint-down(xs) { @@ -332,10 +272,6 @@ } } -.flex-right { - margin-left: auto; -} - .code-block { white-space: pre; overflow-x: auto; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index ceccec8c5cb..e458dfd5316 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -247,13 +247,6 @@ } } -.btn-terminal { - svg { - height: 14px; - width: $default-icon-size; - } -} - .btn-lg { padding: 12px 20px; } @@ -281,12 +274,6 @@ } } -.btn-align-content { - display: flex; - justify-content: center; - align-items: center; -} - .btn-group { &.btn-grouped { @include btn-with-margin; @@ -347,16 +334,6 @@ } } -.btn-static { - background-color: $gray-light !important; - border: 1px solid $border-gray-normal; - cursor: default; - - &:active { - box-shadow: inset 0 0 0 $white; - } -} - .btn-inverted { &-secondary { @include btn-outline($white, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800); diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index a7ce19ffc69..354d2737894 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -175,10 +175,6 @@ p.time { text-shadow: none; } -.thin-area { - height: 150px; -} - // Fix issue with notes & lists creating a bunch of bottom borders. li.note { img { max-width: 100%; } @@ -298,10 +294,6 @@ img.emoji { margin: 0; } -.space-right { - margin-right: 10px; -} - .alert { margin-bottom: $gl-padding; } @@ -363,14 +355,6 @@ img.emoji { } } -.outline-0 { - outline: 0; - - &:focus { - outline: 0; - } -} - /** COMMON CLASSES **/ /** 🚨 Do not use these classes — they are deprecated and being removed. 🚨 diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index f5002a342b6..fa1892903a3 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -140,7 +140,7 @@ @include gl-border-none; .avatar.s32 { - @extend .rect-avatar.s32; + border-radius: $border-radius-default; box-shadow: $avatar-box-shadow; } } diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index 568182ad796..23dc16b7e7f 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -1007,6 +1007,27 @@ table.code { } } +// Notes tweaks for the Changes tab ONLY +.diff-tr { + .timeline-discussion-body { + clear: left; + + .note-body { + margin-top: 0 !important; + } + } + + .timeline-entry img.avatar { + margin-top: -2px; + margin-right: $gl-padding-8; + } + + // tiny adjustment to vertical align with the note header text + .discussion-collapsible .timeline-icon { + padding-top: 2px; + } +} + .files:not([data-can-create-note]) .frame { cursor: auto; } @@ -1125,7 +1146,7 @@ table.code { } .discussion-collapsible { - margin: 0 $gl-padding $gl-padding 71px; + margin: 0 $gl-padding $gl-padding; .notes { border-radius: $border-radius-default; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index b05fbfaae6c..7f960e3da51 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -76,6 +76,7 @@ } .dropdown-toggle, +.dropdown-menu-toggle, .confidential-merge-request-fork-group .dropdown-toggle { padding: 6px 8px 6px 10px; background-color: $white; @@ -131,7 +132,6 @@ // This is double classed to solve a specificity issue with the gitlab ui buttons .dropdown-menu-toggle.dropdown-menu-toggle { - @extend .dropdown-toggle; justify-content: flex-start; overflow: hidden; padding-right: 25px; @@ -425,21 +425,10 @@ } } -.dropdown-menu-drop-up { - top: auto; - bottom: 100%; -} - .dropdown-menu-large { width: 340px; } -.dropdown-menu-no-wrap { - a { - white-space: normal; - } -} - .dropdown-menu-full-width { width: 100%; } @@ -662,13 +651,6 @@ padding-right: 10px; } -.dropdown-due-date-footer { - padding-top: 0; - margin-left: 10px; - margin-right: 10px; - border-top: 0; -} - .dropdown-footer-list { font-size: 14px; @@ -742,24 +724,6 @@ } } -.dropdown-menu-due-date { - .dropdown-content { - max-height: 230px; - } - - .pika-single { - position: relative !important; - top: 0 !important; - border: 0; - box-shadow: none; - } - - .pika-lendar { - margin-top: -5px; - margin-bottom: 0; - } -} - .dropdown-menu-inner-title { display: block; color: $gl-text-color; diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index c366bf80093..2a46e50f0da 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -196,14 +196,6 @@ label { } } -@include media-breakpoint-down(xs) { - .remember-me { - .remember-me-checkbox { - margin-top: 0; - } - } -} - .input-icon-wrapper, .select-wrapper { position: relative; diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss index d48a5116677..f77f64f1d76 100644 --- a/app/assets/stylesheets/framework/job_log.scss +++ b/app/assets/stylesheets/framework/job_log.scss @@ -5,10 +5,10 @@ font-size: 13px; word-break: break-all; word-wrap: break-word; - color: color-yiq($builds-trace-bg); + color: color-yiq($builds-log-bg); border-radius: $border-radius-small; min-height: 42px; - background-color: $builds-trace-bg; + background-color: $builds-log-bg; } .log-line { @@ -42,10 +42,6 @@ } } -.log-duration-badge { - background: $gray-300; -} - .loader-animation { @include build-loader-animation; } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 7315bce1ed9..ef294635641 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -153,6 +153,10 @@ vertical-align: middle; margin-bottom: 3px; } + + .dropdown-chevron { + margin-bottom: 0; + } } @include media-breakpoint-down(xs) { diff --git a/app/assets/stylesheets/framework/media_object.scss b/app/assets/stylesheets/framework/media_object.scss index 89c561479cc..b573052c14a 100644 --- a/app/assets/stylesheets/framework/media_object.scss +++ b/app/assets/stylesheets/framework/media_object.scss @@ -6,7 +6,3 @@ .media-body { flex: 1; } - -.media-body-wrap { - flex-grow: 1; -} diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index fcf86680bb3..33f7aa4dba1 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -239,7 +239,7 @@ /* * Mixin that handles the container for the job logs (CI/CD and kubernetes pod logs) */ -@mixin build-trace($background: $black) { +@mixin build-log($background: $black) { background: $background; color: $gray-darkest; white-space: pre; @@ -253,13 +253,13 @@ display: block; } - &.build-trace-rounded { + &.build-log-rounded { border-radius: $gl-border-radius-base; } } // Used in EE for Web Terminal -@mixin build-trace-bar($height) { +@mixin build-log-bar($height) { height: $height; min-height: $height; background: var(--gray-50, $gray-50); @@ -268,8 +268,8 @@ padding: $grid-size; } -@mixin build-trace-top-bar($height) { - @include build-trace-bar($height); +@mixin build-log-top-bar($height) { + @include build-log-bar($height); position: -webkit-sticky; position: sticky; diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 48a18e0d145..c9b17f5d5c4 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -1,13 +1,3 @@ -.modal-xl { - max-width: 98%; -} - -.modal-1040 { - @include media-breakpoint-up(xl) { - max-width: 1040px; - } -} - .modal-header { background-color: $modal-body-bg; @@ -111,30 +101,3 @@ body.modal-open { } } } - -.recaptcha-modal .recaptcha-form { - display: inline-block; - - .recaptcha { - margin: 0; - } -} - -.issues-import-modal, -.issuable-export-modal { - .modal-body { - padding: 0; - - .modal-subheader { - justify-content: flex-start; - align-items: center; - border-bottom: 1px solid $modal-border-color; - padding: 14px; - } - - .modal-text { - padding: $gl-padding-24 $gl-padding; - min-height: $modal-body-height; - } - } -} diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss index d9c93fed1c4..e5b2b853363 100644 --- a/app/assets/stylesheets/framework/panels.scss +++ b/app/assets/stylesheets/framework/panels.scss @@ -39,10 +39,6 @@ } } -.card-empty-heading { - border-bottom: 0; -} - .card-body { padding: $gl-padding; diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 06eebb95438..685f1f413e6 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -1,6 +1,6 @@ // For tabbed navigation links, scrolling tabs, etc. For all top/main navigation, // please check nav.scss -.nav-links:not(.quick-links) { +.nav-links { display: flex; padding: 0; margin: 0; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index d8ce6826fc1..900cf9fa4db 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -50,18 +50,6 @@ } } -.namespace-result { - .namespace-kind { - color: $gray-300; - font-weight: $gl-font-weight-normal; - } - - .namespace-path { - margin-left: 10px; - font-weight: $gl-font-weight-bold; - } -} - .ajax-users-dropdown { min-width: 250px !important; } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 6b3201ba2b0..6c7fc25f2d9 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -16,21 +16,6 @@ transition: padding $sidebar-transition-duration; } -.nav-header-btn { - padding: 10px $gl-sidebar-padding; - color: inherit; - transition-duration: 0.3s; - position: absolute; - top: 0; - cursor: pointer; - - &:hover, - &:focus { - color: $white; - text-decoration: none; - } -} - .right-sidebar-collapsed { padding-right: 0; diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index 47184804353..c59e70c80df 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -4,11 +4,6 @@ font-weight: $gl-font-weight-bold; } - .snippet-filename { - color: $gl-text-color-secondary; - font-weight: normal; - } - .snippet-info { color: $gl-text-color-secondary; } diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss index 25868061d04..953c42219a9 100644 --- a/app/assets/stylesheets/framework/sortable.scss +++ b/app/assets/stylesheets/framework/sortable.scss @@ -36,61 +36,6 @@ } } -.related-issues-list-item { - .card-body, - .issuable-info-container { - padding: $gl-padding-4 $gl-padding-4 $gl-padding-4 $gl-padding; - - .block-truncated { - padding: $gl-padding-8 0; - line-height: $gl-btn-line-height; - } - - @include media-breakpoint-down(md) { - padding-left: $gl-padding; - - .block-truncated { - flex-direction: column-reverse; - padding: $gl-padding-4 0; - - .text-secondary { - margin-top: $gl-padding-4; - } - - .issue-token-title-text { - display: block; - } - } - - .issue-item-remove-button { - align-self: baseline; - } - } - - @include media-breakpoint-only(md) { - .block-truncated .issue-token-title-text { - white-space: nowrap; - } - - .issue-item-remove-button { - align-self: center; - } - } - - @include media-breakpoint-down(sm) { - padding-left: $gl-padding-8; - - .block-truncated .issue-token-title-text { - white-space: normal; - } - } - } - - &.is-dragging { - padding: 0; - } -} - .is-dragging { // Important because plugin sets inline CSS opacity: 1 !important; diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index 92405c00c5e..c6bc8fa0eac 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -23,6 +23,7 @@ table { @include gl-text-gray-500; } + .md &:not(.code), &.table { margin-bottom: $gl-padding; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index aeb3bb2286f..cb36c4e5767 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -75,6 +75,15 @@ details { margin-bottom: $gl-padding; + + > *:not(summary) { + margin-left: $gl-spacing-scale-5; + } + } + + summary > * { + display: inline-block; + margin-bottom: 0; } // Single code lines should wrap @@ -160,8 +169,6 @@ } table:not(.code) { - @extend .table; - @extend .table-bordered; margin: 16px 0; color: $gl-text-color; border: 0; @@ -172,9 +179,11 @@ tbody { background-color: $white; - td { - border-color: $gray-100; - } + } + + td, + th { + border: 1px solid $border-color; } tr { @@ -478,6 +487,7 @@ font-size: larger; } + figcaption, .small { font-size: smaller; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 099dfa28b9f..026aeeb1e8e 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -697,7 +697,7 @@ $blame-blue: #254e77; /* * Builds */ -$builds-trace-bg: #111; +$builds-log-bg: #111; $job-log-highlight-height: 18px; $job-log-line-padding: 55px; $job-line-number-width: 50px; @@ -759,7 +759,6 @@ $help-shortcut-header-color: #333; */ $issues-today-bg: #f3fff2 !default; $issues-today-border: #e1e8d5 !default; -$compare-display-color: #888; /* * Label diff --git a/app/assets/stylesheets/framework/zen.scss b/app/assets/stylesheets/framework/zen.scss index 62abf4a7683..10df532e334 100644 --- a/app/assets/stylesheets/framework/zen.scss +++ b/app/assets/stylesheets/framework/zen.scss @@ -40,15 +40,6 @@ border: 0; } -.zen-control-full { - color: $gl-text-color-secondary; - - &:hover { - color: $blue-600; - text-decoration: none; - } -} - .zen-control-leave { display: none; color: $gl-text-color; diff --git a/app/assets/stylesheets/page_bundles/_ide_mixins.scss b/app/assets/stylesheets/page_bundles/_ide_mixins.scss index 48b8a7230b1..bbc47c5cd5d 100644 --- a/app/assets/stylesheets/page_bundles/_ide_mixins.scss +++ b/app/assets/stylesheets/page_bundles/_ide_mixins.scss @@ -4,7 +4,7 @@ height: 100%; .top-bar { - @include build-trace-bar(35px); + @include build-log-bar(35px); top: 0; font-size: 12px; diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss index 7336d555f79..25a565ce2ba 100644 --- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss @@ -44,7 +44,7 @@ background-color: var(--ide-background, $badge-bg); } - .nav-links:not(.quick-links) li:not(.md-header-toolbar) a, + .nav-links li:not(.md-header-toolbar) a, .gl-tabs-nav li a, .dropdown-menu-inner-content, .file-row .file-row-icon svg, @@ -52,7 +52,7 @@ color: var(--ide-text-color-secondary, $gl-text-color-secondary); } - .nav-links:not(.quick-links) li:not(.md-header-toolbar), + .nav-links li:not(.md-header-toolbar), .gl-tabs-nav li { &:hover a, &.active a, @@ -148,7 +148,7 @@ .md blockquote, .md table:not(.code) tbody td, .md table:not(.code) tr th, - .nav-links:not(.quick-links), + .nav-links, .gl-tabs-nav, .common-note-form .md-area.is-focused .nav-links { border-color: var(--ide-border-color-alt, $white-dark); @@ -259,6 +259,7 @@ .dropdown-menu-toggle { border-color: var(--ide-btn-default-border, $gray-darkest); + background-color: var(--ide-input-background, transparent); &:hover, &:focus { @@ -310,7 +311,7 @@ border-color: var(--ide-background, $border-color); background-color: var(--ide-dropdown-background, $white); - .nav-links:not(.quick-links) { + .nav-links { background-color: var(--ide-dropdown-hover-background, $white); border-color: var(--ide-dropdown-hover-background, $border-color); } diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index ec41909beec..ed62e055427 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -1,8 +1,8 @@ @import 'mixins_and_variables_and_functions'; .build-page { - .build-trace { - @include build-trace(); + .build-log { + @include build-log(); } .archived-job { @@ -18,7 +18,7 @@ } .top-bar { - @include build-trace-top-bar(50px); + @include build-log-top-bar(50px); &.has-archived-block { top: $header-height + 28px; diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 28354b83856..7d1230b0225 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -109,3 +109,12 @@ } } } + +// TODO: Move to GitLab UI +.mr-extenson-scrim { + background: linear-gradient(to bottom, rgba($gray-light, 0), rgba($gray-light, 1)); + + .gl-dark & { + background: linear-gradient(to bottom, rgba(#333, 0), rgba(#333, 1)); + } +} diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index 206c2eb09d0..c8b1b6cf9aa 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -44,14 +44,14 @@ line-height: initial; } - .build-trace-row td { + .build-log-row td { border-top: 0; border-bottom-width: 1px; border-bottom-style: solid; padding-top: 0; } - .build-trace { + .build-log { width: 100%; text-align: left; margin-top: $gl-padding; @@ -93,7 +93,7 @@ } .build-state, - .build-trace-row { + .build-log-row { > td:last-child { padding-right: 0; } @@ -108,12 +108,12 @@ margin-top: 2 * $gl-padding; } - .build-trace-container { + .build-log-container { padding-top: $gl-padding; padding-bottom: $gl-padding; } - .build-trace { + .build-log { margin-bottom: 0; margin-top: 0; } @@ -221,8 +221,8 @@ } .test-reports-table { - .build-trace { - @include build-trace(); + .build-log { + @include build-log(); } } diff --git a/app/assets/stylesheets/page_bundles/signup.scss b/app/assets/stylesheets/page_bundles/signup.scss index 57e5d2411d1..4fc671dace8 100644 --- a/app/assets/stylesheets/page_bundles/signup.scss +++ b/app/assets/stylesheets/page_bundles/signup.scss @@ -26,14 +26,6 @@ } } - .omniauth-btn { - width: 48%; - - @include media-breakpoint-down(md) { - width: 100%; - } - } - .decline-page { width: 350px; } diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss index 1e6567189be..c64e159c648 100644 --- a/app/assets/stylesheets/page_bundles/wiki.scss +++ b/app/assets/stylesheets/page_bundles/wiki.scss @@ -152,5 +152,5 @@ ul.wiki-pages-list.content-list { } .wiki-form .markdown-area { - max-height: none; + max-height: 55vh; } diff --git a/app/assets/stylesheets/pages/ci_projects.scss b/app/assets/stylesheets/pages/ci_projects.scss deleted file mode 100644 index fbe1f3081a0..00000000000 --- a/app/assets/stylesheets/pages/ci_projects.scss +++ /dev/null @@ -1,54 +0,0 @@ -.ci-body { - .project-title { - margin: 0; - color: $common-gray-dark; - font-size: 20px; - line-height: 1.5; - } - - .builds, - .projects-table { - .light { - border-color: $border-color; - } - - th, - td { - padding: 10px $gl-padding; - } - - td { - color: $gl-text-color; - vertical-align: middle !important; - - a { - font-weight: $gl-font-weight-normal; - text-decoration: none; - } - } - } - - .commit-info { - .attr-name { - margin-right: 5px; - } - - pre.commit-message { - background: none; - padding: 0; - border: 0; - margin: 20px 0; - border-radius: 0; - } - } - - .loading { - font-size: 20px; - } - - .ci-charts { - fieldset { - margin-bottom: 16px; - } - } -} diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index d233adbf3d2..de27ca2e5e8 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -1,88 +1,4 @@ -.edit-cluster-form { - .clipboard-addon { - background-color: $white; - } -} - -.cluster-application-row { - background: $gray-lighter; - - &.cluster-application-installed { - background: none; - } - - .settings-message { - padding: $gl-vert-padding $gl-padding-8; - } -} - -@media (min-width: map-get($grid-breakpoints, md)) { - .cluster-application-list { - border: 1px solid $border-color; - border-radius: $border-radius-default; - } - - .cluster-application-row { - border-bottom: 1px solid $border-color; - padding: $gl-padding; - - &:last-child { - border-bottom: 0; - border-bottom-left-radius: calc(#{$border-radius-default} - 1px); - border-bottom-right-radius: calc(#{$border-radius-default} - 1px); - } - } -} - -.cluster-application-logo { - border: 3px solid $white; - box-shadow: 0 0 0 1px $gray-normal; - - &.avatar:hover { - border-color: $white; - } -} - -.cluster-application-warning { - font-weight: bold; - text-align: center; - padding: $gl-padding; - border-bottom: 1px solid $white-normal; - - .svg-container { - display: inline-block; - vertical-align: middle; - margin-right: $gl-padding-8; - width: 40px; - height: 40px; - } -} - -.cluster-application-description { - flex: 1; -} - -.cluster-application-disabled { - opacity: 0.5; -} - -.clusters-dropdown-menu { - max-width: 100%; -} - -.clusters-error-alert { - width: 100%; -} - .clusters-container { - .nav-bar-right { - padding: $gl-padding-top $gl-padding; - } - - .card { - margin-bottom: $gl-vert-padding; - } - .empty-state .svg-content img { width: 145px; } @@ -100,71 +16,4 @@ @include gl-flex-wrap; } } - - .top-area .nav-controls > .btn.btn-add-cluster { - margin-right: 0; - } - - .clusters-table { - background-color: $gray-light; - padding: $gl-padding-8; - } - - .badge-light { - background-color: $white-normal; - } - - .gl-responsive-table-row { - padding: $gl-padding; - border: 0; - - &.table-row-header { - // stylelint-disable-next-line - background-color: none; - border: 0; - font-weight: bold; - color: $gray-500; - } - } -} - -.cluster-warning { - @include alert-variant(theme-color-level('warning', $alert-bg-level), theme-color-level('warning', $alert-border-level), theme-color-level('warning', $alert-color-level)); -} - -.gcp-signup-offer { - border-left-color: $blue-500; - - svg { - fill: $blue-500; - vertical-align: middle; - } - - .gcp-signup-offer--content { - display: flex; - - h4 { - font-size: 16px; - line-height: 24px; - } - - .gcp-signup-offer--icon { - align-self: flex-start; - } - } -} - -.cluster-deployments-warning { - color: $orange-500; -} - -.badge.pods-badge { - color: $black; - font-weight: $gl-font-weight-bold; -} - -.cluster-status-indicator { - &.disabled { - background-color: $gray-400; - } } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index bc4dbf695cf..7f35b8fab43 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -82,11 +82,6 @@ } } -.commits-compare-switch { - float: left; - margin-right: 9px; -} - .commit-header { padding: 5px 10px; background-color: $gray-light; diff --git a/app/assets/stylesheets/pages/environment_logs.scss b/app/assets/stylesheets/pages/environment_logs.scss index 03993e5321d..f8f40076142 100644 --- a/app/assets/stylesheets/pages/environment_logs.scss +++ b/app/assets/stylesheets/pages/environment_logs.scss @@ -40,8 +40,8 @@ height: 100%; } - .build-trace { - @include build-trace($black); + .build-log { + @include build-log($black); } .gl-infinite-scroll-legend { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 94912b1c641..c597d2dd8da 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -220,21 +220,12 @@ } .cross-project-reference { - color: inherit; - span { - white-space: nowrap; width: 85%; - overflow: hidden; - position: relative; - display: inline-block; - text-overflow: ellipsis; } button { - float: right; padding: 1px 5px; - background-color: $gray-light; } } @@ -563,35 +554,17 @@ } } -.participants-list { - display: flex; - flex-wrap: wrap; -} - -.user-list { - display: flex; - flex-wrap: wrap; -} - .participants-author { - display: inline-block; - padding: 0 $gl-padding-8 $gl-padding-8 0; - &:nth-of-type(7n) { padding-right: 0; } - .author-link { - display: block; - } - .avatar.avatar-inline { margin: 0; } } .user-item { - display: inline-block; padding: 5px; flex-basis: 20%; @@ -673,40 +646,40 @@ .issuable-info-container { flex: 1; display: flex; + } - .issuable-main-info { - flex: 1 auto; - margin-right: 10px; - min-width: 0; + .issuable-main-info { + flex: 1 auto; + margin-right: 10px; + min-width: 0; - .issue-weight-icon, - .issue-estimate-icon { - vertical-align: sub; - } + .issue-weight-icon, + .issue-estimate-icon { + vertical-align: sub; } + } - .issuable-meta { - display: flex; - flex-direction: column; - align-items: flex-end; - flex: 1 0 auto; - - .controls { - margin-bottom: 2px; - line-height: 20px; - padding: 0; - } + .issuable-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + flex: 1 0 auto; - .issue-updated-at { - line-height: 20px; - } + .controls { + margin-bottom: 2px; + line-height: 20px; + padding: 0; } - @include media-breakpoint-down(xs) { - .issuable-meta { - .controls li { - margin-right: 0; - } + .issue-updated-at { + line-height: 20px; + } + } + + @include media-breakpoint-down(xs) { + .issuable-meta { + .controls li { + margin-right: 0; } } } @@ -773,38 +746,15 @@ } } -.add-issuable-form-input-token-list { - display: flex; - flex-wrap: wrap; - align-items: baseline; - list-style: none; - margin-bottom: 0; - padding-left: 0; -} - -.add-issuable-form-token-list-item { - max-width: 100%; - margin-bottom: $gl-vert-padding; - margin-right: 5px; -} - -.add-issuable-form-input-list-item { - flex: 1; - min-width: 200px; - margin-bottom: $gl-vert-padding; -} - -.add-issuable-form-input { - width: 100%; - border: 0; - - &:focus { - outline: none; +.add-issuable-form-input-wrapper { + &.focus { + border-color: $blue-300; + box-shadow: 0 0 4px $dropdown-input-focus-shadow; } -} -.add-issuable-form-actions { - margin-top: $gl-padding; + .gl-show-field-errors &.form-control:not(textarea) { + height: auto; + } } .time-tracker { @@ -839,18 +789,7 @@ } .compare-display-container { - display: flex; - justify-content: space-between; - margin-top: 5px; - - .compare-display { - font-size: 13px; - color: $compare-display-color; - - .compare-value { - color: $gl-text-color; - } - } + font-size: 13px; } .time-tracking-help-state { @@ -938,22 +877,6 @@ vertical-align: sub; } -.suggestion-confidential { - color: $orange-500; -} - -.suggestion-state-open { - color: $green-500; -} - -.suggestion-state-closed { - color: $blue-500; -} - -.suggestion-help-hover { - cursor: help; -} - .suggestion-footer { font-size: 12px; line-height: 15px; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 461d6a29b3a..880231f5644 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -1,42 +1,62 @@ -.issue-realtime-pre-pulse { - opacity: 0; -} +.issue-token-link { + &[href] { + color: $blue-600; + } -.issue-realtime-trigger-pulse { - transition: opacity $fade-in-duration linear; - opacity: 1; + &:hover, + &:focus { + outline: none; + text-decoration: none; + } } -.check-all-holder { - line-height: 36px; - float: left; - margin-right: 15px; -} +.issue-token-reference { + margin-right: 1px; + background-color: $gray-lighter; + transition: background $general-hover-transition-duration $general-hover-transition-curve, color $general-hover-transition-duration $general-hover-transition-curve; -form.edit-issue { - margin: 0; + .issue-token:hover &, + .issue-token-link:focus > & { + background-color: $gray-normal; + color: $blue-800; + text-decoration: none; + } } -ul.related-merge-requests > li { - display: flex; - align-items: center; +.issue-token-title { + background-color: $gray-normal; + transition: background $general-hover-transition-duration $general-hover-transition-curve; - .merge-request-id { - flex-shrink: 0; + .issue-token:hover &, + .issue-token-link:focus > & { + background-color: $border-gray-normal; } +} - .merge-request-info { - margin-left: 5px; - } +.issue-token-remove-button { + background-color: $gray-normal; + transition: background $general-hover-transition-duration $general-hover-transition-curve; - gl-emoji { - font-size: 1em; + &:hover, + &:focus, + .issue-token:hover &, + .issue-token-link:focus + & { + background-color: $border-gray-normal; + outline: none; } } -.related-branches-title { - font-size: 16px; - font-weight: $gl-font-weight-bold; +.issue-realtime-pre-pulse { + opacity: 0; +} + +.issue-realtime-trigger-pulse { + transition: opacity $fade-in-duration linear; + opacity: 1; +} + +ul.related-merge-requests > li gl-emoji { + font-size: 1em; } .merge-request-status { @@ -92,35 +112,12 @@ ul.related-merge-requests > li { } } -.issues-footer { - padding-top: $gl-padding; - padding-bottom: 37px; -} - -.issues-nav-controls, -.new-branch-col { - font-size: 0; -} - .issues-nav-controls { .btn-group:empty { display: none; } } -.email-modal-input-group { - margin-bottom: 10px; - - .form-control { - background-color: $white; - } - - .btn { - background-color: $gray-light; - border: 1px solid $border-gray-normal; - } -} - .recaptcha { margin-bottom: 30px; } diff --git a/app/assets/stylesheets/pages/issues/issue_count_badge.scss b/app/assets/stylesheets/pages/issues/issue_count_badge.scss deleted file mode 100644 index f2283e02ad2..00000000000 --- a/app/assets/stylesheets/pages/issues/issue_count_badge.scss +++ /dev/null @@ -1,10 +0,0 @@ -.issue-count-badge, -.mr-count-badge { - padding: 5px $gl-padding-8; -} - -.issue-count-badge-count, -.mr-count-badge-count { - display: inline-flex; - align-items: center; -} diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 773935f4c76..71ddbf175e9 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -99,11 +99,6 @@ padding: 0; border: 0; background: none; - margin-bottom: $gl-padding; - } - - .omniauth-btn { - width: 100%; } } @@ -206,6 +201,12 @@ padding: 0; height: 100%; + &.with-system-header { + .login-page-broadcast { + margin-top: $system-header-height + $header-height; + } + } + // Fixes footer container to bottom of viewport body { // offset height of fixed header + 1 to avoid scroll diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 071a5be073f..cec8d8a29cc 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -474,31 +474,6 @@ $tabs-holder-z-index: 250; } } } - - .merge-request-labels { - display: inline-block; - } -} - -.merge-request-angle { - text-align: center; - margin: 0 auto; - font-size: 2em; - line-height: 1.1; -} - -// hide mr close link for inline diff comment form -.diff-file .close-mr-link, -.diff-file .reopen-mr-link { - display: none; -} - -.mr-links { - padding-left: $gl-padding-8 + $status-icon-size + $gl-btn-padding; - - &:last-child { - padding-bottom: $gl-padding; - } } .mr-info-list { @@ -649,10 +624,6 @@ $tabs-holder-z-index: 250; } } -.target-branch-select-dropdown-container { - position: relative; -} - .assign-to-me-link { padding-left: 12px; white-space: nowrap; @@ -667,12 +638,6 @@ $tabs-holder-z-index: 250; } } -.merged-buttons { - .btn { - float: left; - } -} - .mr-version-controls { position: relative; z-index: $tabs-holder-z-index + 10; @@ -1040,3 +1005,17 @@ $tabs-holder-z-index: 250; margin-bottom: 1px; } } + +.mr-widget-extension-icon::before { + @include gl-content-empty; + @include gl-absolute; + @include gl-left-0; + @include gl-top-0; + @include gl-opacity-3; + @include gl-border-solid; + @include gl-border-4; + @include gl-rounded-full; + + width: 24px; + height: 24px; +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 3c0f10eb5cb..1c408f6d985 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -24,12 +24,6 @@ margin: $gl-padding 0 0; } - .note-preview-holder { - > p { - overflow-x: auto; - } - } - img { max-width: 100%; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 17bc40b4dec..04da75b586f 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -125,18 +125,17 @@ $system-note-svg-size: 16px; } } + .timeline-discussion-body { + margin-top: -$gl-padding-8; + } + .discussion { display: block; position: relative; .timeline-discussion-body { - margin-top: -$gl-padding-8; overflow-x: auto; overflow-y: hidden; - - .note-body { - margin-top: $gl-padding-8; - } } .diff-content { @@ -586,17 +585,47 @@ $system-note-svg-size: 16px; .note-header { display: flex; justify-content: space-between; + flex-wrap: wrap; + + > .note-header-info, + > .note-actions { + flex-grow: 1; + flex-shrink: 1; + } +} + +.note { + @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { + .note-header { + .note-actions { + flex-wrap: wrap; + margin-bottom: $gl-padding-12; + + > :first-child { + margin-left: 0; + } + } + } + + .note-header-author-name { + display: block; + } + } } .note-header-info { min-width: 0; - padding-bottom: $gl-padding-8; &.discussion { padding-bottom: 0; } } +.note-header-info, +.note-actions { + padding-bottom: $gl-padding-8; +} + .system-note .note-header-info { padding-bottom: 0; } @@ -667,7 +696,8 @@ $system-note-svg-size: 16px; .note-actions { align-self: flex-start; - flex-shrink: 0; + justify-content: flex-end; + flex-shrink: 1; display: inline-flex; align-items: center; margin-left: 10px; diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss index 298de33888d..2fd2757cf08 100644 --- a/app/assets/stylesheets/pages/notifications.scss +++ b/app/assets/stylesheets/pages/notifications.scss @@ -4,11 +4,6 @@ width: 100%; } - .notification-form { - display: block; - } - - .notifications-btn, .btn-group { width: 100%; } diff --git a/app/assets/stylesheets/pages/pages.scss b/app/assets/stylesheets/pages/pages.scss index 93caa345f8a..ebaf875ad8f 100644 --- a/app/assets/stylesheets/pages/pages.scss +++ b/app/assets/stylesheets/pages/pages.scss @@ -55,16 +55,4 @@ border-bottom-right-radius: $border-radius-default; border-top-right-radius: $border-radius-default; } - - &.floating-status-badge { - position: absolute; - right: $gl-padding-24; - bottom: $gl-padding-4; - margin-bottom: 0; - } -} - -.form-control.has-floating-status-badge { - position: relative; - padding-right: 120px; } diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index de9e0c6f705..af9f10c9a26 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -1,9 +1,3 @@ -.profile-avatar-form-option { - hr { - margin: 10px 0; - } -} - .avatar-image { margin-bottom: $grid-size; @@ -23,41 +17,6 @@ display: inline-block; } -.private-tokens-reset div.reset-action:not(:first-child) { - padding-top: 15px; -} - -.oauth-buttons { - .btn-group { - margin-right: 10px; - } - - .btn { - line-height: 40px; - height: 42px; - padding: 0 12px; - - img { - width: 32px; - height: 32px; - } - } -} - -// Profile > Account > Two Factor Authentication -.two-factor-new { - .manual-instructions { - h3 { - margin-top: 0; - } - - // Slightly increase the size of the details so they're easier to read - dl { - font-size: 1.1em; - } - } -} - .account-well { padding: 10px; background-color: $gray-light; @@ -191,10 +150,6 @@ } } -.personal-access-tokens-never-expires-label { - color: $note-disabled-comment-color; -} - .created-personal-access-token-container { .btn-clipboard { border: 1px solid $border-color; @@ -266,8 +221,7 @@ } } -table.u2f-registrations, -.webauthn-registrations { +table.u2f-registrations { th:not(:last-child), td:not(:last-child) { border-right: solid 1px transparent; diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss index 12386fa66ec..b583d40de79 100644 --- a/app/assets/stylesheets/pages/profiles/preferences.scss +++ b/app/assets/stylesheets/pages/profiles/preferences.scss @@ -1,19 +1,3 @@ -.multi-file-editor-options { - label { - margin-right: 20px; - text-align: center; - } - - .preview { - font-size: 0; - - img { - border: 1px solid $border-color-settings; - border-radius: 4px; - } - } -} - .application-theme { $ui-dark-bg: #2e2e2e; $ui-light-bg: #dfdfdf; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index c330e0709a6..6b4d7c2520c 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -622,12 +622,6 @@ pre.light-well { } } - .star-button { - .icon { - top: 0; - } - } - .icon-container { @include media-breakpoint-down(xs) { margin-right: $gl-padding-8; @@ -938,16 +932,6 @@ pre.light-well { } } -.project-ci-linter { - .ci-editor { - height: 400px; - } - - .ci-template pre { - white-space: pre-wrap; - } -} - .project-badge { opacity: 0.9; diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index a3b6cbdff25..71cbd7d9613 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -96,11 +96,6 @@ padding: $gl-padding-8; } -.prometheus-graph-embed { - border: 1px solid $border-color; - border-radius: $border-radius-default; -} - .alert-current-setting { max-width: 240px; @@ -110,233 +105,6 @@ } } -.prometheus-graph-cursor { - position: absolute; - background: $gray-400; - width: 1px; -} - -.prometheus-graph-flag { - display: block; - min-width: 160px; - border: 0; - box-shadow: 0 1px 4px 0 $black-transparent; - - h5 { - padding: 0; - margin: 0; - font-size: 14px; - line-height: 1.2; - } - - .deploy-meta-content { - border-bottom: 1px solid $white-dark; - - svg { - height: 15px; - vertical-align: bottom; - } - } - - &.popover { - padding: 0; - - &.left { - left: auto; - right: 0; - margin-right: 10px; - - > .arrow { - right: -14px; - border-left-color: $border-color; - } - - > .arrow::after { - border-top: 6px solid transparent; - border-bottom: 6px solid transparent; - border-left: 4px solid $gray-10; - } - - .arrow-shadow { - right: -3px; - box-shadow: 1px 0 9px 0 $black-transparent; - } - } - - &.right { - left: 0; - right: auto; - margin-left: 10px; - - > .arrow { - left: -7px; - border-right-color: $border-color; - } - - > .arrow::after { - border-top: 6px solid transparent; - border-bottom: 6px solid transparent; - border-right: 4px solid $gray-10; - } - - .arrow-shadow { - left: -3px; - box-shadow: 1px 0 8px 0 $black-transparent; - } - } - - > .arrow { - top: 10px; - margin: 0; - } - - .arrow-shadow { - content: ''; - position: absolute; - width: 7px; - height: 7px; - background-color: transparent; - transform: rotate(45deg); - top: 13px; - } - - > .popover-title, - > .popover-content, - > .popover-header, - > .popover-body { - padding: 8px; - white-space: nowrap; - position: relative; - } - - > .popover-title { - background-color: $gray-10; - border-radius: $border-radius-default $border-radius-default 0 0; - } - } - - strong { - font-weight: 600; - } -} - -.prometheus-table { - border-collapse: collapse; - padding: 0; - margin: 0; - - td { - vertical-align: middle; - - + td { - padding-left: 8px; - vertical-align: top; - } - } - - .legend-metric-title { - font-size: 12px; - vertical-align: middle; - } -} - -.prometheus-svg-container { - position: relative; - height: 0; - width: 100%; - padding: 0; - padding-bottom: 100%; - - .text-metric-usage { - fill: $black; - font-weight: $gl-font-weight-normal; - font-size: 12px; - } - - > svg { - position: absolute; - height: 100%; - width: 100%; - left: 0; - top: 0; - - text { - fill: $gl-text-color; - stroke-width: 0; - } - - .text-metric-bold { - font-weight: $gl-font-weight-bold; - } - - .label-axis-text { - fill: $black; - font-weight: $gl-font-weight-normal; - font-size: 10px; - } - - .legend-axis-text { - fill: $black; - } - - .tick { - > line { - stroke: $gray-darker; - } - - > text { - fill: $gray-400; - font-size: 10px; - } - } - - .y-label-text, - .x-label-text { - fill: $gray-darkest; - } - - .axis-tick { - stroke: $gray-darker; - } - - .deploy-info-text { - dominant-baseline: text-before-edge; - font-size: 12px; - } - - .deploy-info-text-link { - font-family: $monospace-font; - fill: $blue-600; - - &:hover { - fill: $blue-800; - } - } - - @include media-breakpoint-down(sm) { - .label-axis-text, - .text-metric-usage, - .legend-axis-text { - font-size: 8px; - } - - .tick > text { - font-size: 8px; - } - } - } -} - -.prometheus-table-row-highlight { - background-color: $gray-100; -} - -.prometheus-graph-overlay { - fill: none; - opacity: 0; - pointer-events: all; -} - .prometheus-panel-builder { .preview-date-time-picker { // same as in .dropdown-menu-toggle diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index aa9ebfe2968..37e272cfff7 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -154,17 +154,6 @@ } } -.token-token-container { - #impersonation-token-token { - width: 80%; - display: inline; - } - - .btn-clipboard { - margin-left: 5px; - } -} - .visibility-level-setting { .form-check { margin-bottom: 10px; @@ -203,22 +192,6 @@ } } -.initialize-with-readme-setting { - .form-check { - margin-bottom: 10px; - - .option-title { - font-weight: $gl-font-weight-normal; - display: inline-block; - color: $gl-text-color; - } - - .option-description { - color: $project-option-descr-color; - } - } -} - .nested-settings { padding-left: 20px; } @@ -326,10 +299,6 @@ } } -.personal-access-tokens-never-expires-label { - color: $note-disabled-comment-color; -} - .created-deploy-token-container { .deploy-token-field { width: 90%; diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 5765156f26c..33c66648718 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -223,11 +223,6 @@ min-height: 200px; } -.upload-link { - font-weight: $gl-font-weight-normal; - color: $blue-600; -} - .repo-charts { .sub-header { margin: 20px 0; diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index b7958cdf4a3..d436c328921 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -5,20 +5,17 @@ body.gl-dark { --gray-50: #303030; --gray-100: #404040; + --gray-900: #fafafa; --gray-950: #fff; --green-100: #0d532a; --green-400: #108548; --green-700: #91d4a8; --blue-400: #1f75cb; --orange-400: #ab6100; - --purple-100: #2f2a6b; --gl-text-color: #fafafa; --border-color: #4f4f4f; --black: #fff; } -.nav-sidebar li.active { - box-shadow: none; -} :root { --white: #333; } @@ -198,22 +195,6 @@ h1 { .dropdown { position: relative; } -.dropdown-menu-toggle { - white-space: nowrap; -} -.dropdown-menu-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0.3em solid; - border-right: 0.3em solid transparent; - border-bottom: 0; - border-left: 0.3em solid transparent; -} -.dropdown-menu-toggle:empty::after { - margin-left: 0; -} .dropdown-menu { position: absolute; top: 100%; @@ -331,6 +312,9 @@ h1 { padding-left: 0.6em; border-radius: 10rem; } +.bg-transparent { + background-color: transparent !important; +} .rounded-circle { border-radius: 50% !important; } @@ -375,6 +359,20 @@ h1 { .m-auto { margin: auto !important; } +.gl-badge { + display: inline-flex; + align-items: center; + font-size: 0.75rem; + font-weight: 400; + line-height: 1rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.gl-button .gl-badge { + top: 0; +} .gl-form-input, .gl-form-input.form-control { background-color: #333; @@ -466,9 +464,6 @@ a { .hide { display: none; } -.dropdown-menu-toggle::after { - display: none; -} .badge:not(.gl-badge) { padding: 4px 5px; font-size: 12px; @@ -548,7 +543,7 @@ body { border-radius: 0.25rem; white-space: nowrap; } -.no-outline.dropdown-menu-toggle { +.dropdown-menu-toggle.no-outline { outline: 0; } .dropdown-menu-toggle.dropdown-menu-toggle { @@ -875,6 +870,12 @@ input { .navbar-nav .badge.badge-pill:not(.merge-request-badge).todos-count { background-color: var(--blue-400, #1f75cb); } +.title-container .canary-badge .badge, +.navbar-nav .canary-badge .badge { + font-size: 12px; + line-height: 16px; + padding: 0 0.5rem; +} @media (max-width: 575.98px) { .navbar-gitlab .container-fluid { font-size: 18px; @@ -1280,6 +1281,7 @@ input { a .avatar-container.rect-avatar .avatar.s32 { + border-radius: 4px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); } .sidebar-top-level-items { @@ -1304,6 +1306,7 @@ input { a .avatar-container.rect-avatar .avatar.s32 { + border-radius: 4px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); } .sidebar-top-level-items > li .badge.badge-pill { @@ -1601,19 +1604,98 @@ svg.s16 { .rect-avatar.s16 { border-radius: 2px; } -.rect-avatar.s32, -.nav-sidebar-inner-scroll - > div.context-header - a - .avatar-container.rect-avatar - .avatar.s32, -.sidebar-top-level-items - .context-header - a - .avatar-container.rect-avatar - .avatar.s32 { +.rect-avatar.s32 { border-radius: 4px; } +body.gl-dark { + --gray-10: #1f1f1f; + --gray-50: #303030; + --gray-100: #404040; + --gray-200: #525252; + --gray-300: #5e5e5e; + --gray-400: #868686; + --gray-500: #999; + --gray-600: #bfbfbf; + --gray-700: #dbdbdb; + --gray-800: #f0f0f0; + --gray-900: #fafafa; + --gray-950: #fff; + --green-50: #0a4020; + --green-100: #0d532a; + --green-200: #24663b; + --green-300: #217645; + --green-400: #108548; + --green-500: #2da160; + --green-600: #52b87a; + --green-700: #91d4a8; + --green-800: #c3e6cd; + --green-900: #ecf4ee; + --green-950: #f1fdf6; + --blue-50: #033464; + --blue-100: #064787; + --blue-200: #0b5cad; + --blue-300: #1068bf; + --blue-400: #1f75cb; + --blue-500: #428fdc; + --blue-600: #63a6e9; + --blue-700: #9dc7f1; + --blue-800: #cbe2f9; + --blue-900: #e9f3fc; + --blue-950: #f2f9ff; + --orange-50: #5c2900; + --orange-100: #703800; + --orange-200: #8f4700; + --orange-300: #9e5400; + --orange-400: #ab6100; + --orange-500: #c17d10; + --orange-600: #d99530; + --orange-700: #e9be74; + --orange-800: #f5d9a8; + --orange-900: #fdf1dd; + --orange-950: #fff4e1; + --red-50: #660e00; + --red-100: #8d1300; + --red-200: #ae1800; + --red-300: #c91c00; + --red-400: #dd2b0e; + --red-500: #ec5941; + --red-600: #f57f6c; + --red-700: #fcb5aa; + --red-800: #fdd4cd; + --red-900: #fcf1ef; + --red-950: #fff4f3; + --indigo-50: #1a1a40; + --indigo-100: #292961; + --indigo-200: #393982; + --indigo-300: #4b4ba3; + --indigo-400: #5b5bbd; + --indigo-500: #6666c4; + --indigo-600: #7c7ccc; + --indigo-700: #a6a6de; + --indigo-800: #d1d1f0; + --indigo-900: #ebebfa; + --indigo-950: #f7f7ff; + --indigo-900-alpha-008: rgba(235, 235, 250, 0.08); + --purple-50: #232150; + --purple-100: #2f2a6b; + --purple-200: #453894; + --purple-300: #5943b6; + --purple-400: #694cc0; + --purple-500: #7b58cf; + --purple-600: #9475db; + --purple-700: #ac93e6; + --purple-800: #cbbbf2; + --purple-900: #e1d8f9; + --purple-950: #f4f0ff; + --gl-text-color: #fafafa; + --border-color: #4f4f4f; + --white: #333; + --black: #fff; + --svg-status-bg: #333; +} +.nav-sidebar li.active { + box-shadow: none; +} body.gl-dark .navbar-gitlab { background-color: #fafafa; } @@ -1703,8 +1785,8 @@ body.gl-dark .nav-sidebar li.active > a { body.gl-dark .nav-sidebar .fly-out-top-item a, body.gl-dark .nav-sidebar .fly-out-top-item.active a, body.gl-dark .nav-sidebar .fly-out-top-item .fly-out-top-item-container { - background-color: var(--purple-100, #e1d8f9); - color: var(--black, #333); + background-color: var(--gray-100, #303030); + color: var(--gray-900, #fafafa); } body.gl-dark .logo-text svg { fill: var(--gl-text-color); @@ -1823,9 +1905,6 @@ body.gl-dark { --black: #fff; --svg-status-bg: #333; } -.nav-sidebar li.active { - box-shadow: none; -} .tab-width-8 { -moz-tab-size: 8; tab-size: 8; @@ -1841,9 +1920,18 @@ body.gl-dark { white-space: nowrap; width: 1px; } +.gl-bg-green-500 { + background-color: #2da160; +} .gl-border-none\! { border-style: none !important; } +.gl-rounded-pill { + border-radius: 0.75rem; +} +.gl-text-white { + color: #333; +} .gl-display-none { display: none; } @@ -1862,6 +1950,10 @@ body.gl-dark { .gl-pr-2 { padding-right: 0.25rem; } +.gl-py-1 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} .gl-ml-3 { margin-left: 0.5rem; } diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 2c79b819899..40026c95a15 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -178,22 +178,6 @@ h1 { .dropdown { position: relative; } -.dropdown-menu-toggle { - white-space: nowrap; -} -.dropdown-menu-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0.3em solid; - border-right: 0.3em solid transparent; - border-bottom: 0; - border-left: 0.3em solid transparent; -} -.dropdown-menu-toggle:empty::after { - margin-left: 0; -} .dropdown-menu { position: absolute; top: 100%; @@ -311,6 +295,9 @@ h1 { padding-left: 0.6em; border-radius: 10rem; } +.bg-transparent { + background-color: transparent !important; +} .rounded-circle { border-radius: 50% !important; } @@ -355,6 +342,20 @@ h1 { .m-auto { margin: auto !important; } +.gl-badge { + display: inline-flex; + align-items: center; + font-size: 0.75rem; + font-weight: 400; + line-height: 1rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.gl-button .gl-badge { + top: 0; +} .gl-form-input, .gl-form-input.form-control { background-color: #fff; @@ -446,9 +447,6 @@ a { .hide { display: none; } -.dropdown-menu-toggle::after { - display: none; -} .badge:not(.gl-badge) { padding: 4px 5px; font-size: 12px; @@ -528,7 +526,7 @@ body { border-radius: 0.25rem; white-space: nowrap; } -.no-outline.dropdown-menu-toggle { +.dropdown-menu-toggle.no-outline { outline: 0; } .dropdown-menu-toggle.dropdown-menu-toggle { @@ -855,6 +853,12 @@ input { .navbar-nav .badge.badge-pill:not(.merge-request-badge).todos-count { background-color: var(--blue-400, #428fdc); } +.title-container .canary-badge .badge, +.navbar-nav .canary-badge .badge { + font-size: 12px; + line-height: 16px; + padding: 0 0.5rem; +} @media (max-width: 575.98px) { .navbar-gitlab .container-fluid { font-size: 18px; @@ -1260,6 +1264,7 @@ input { a .avatar-container.rect-avatar .avatar.s32 { + border-radius: 4px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); } .sidebar-top-level-items { @@ -1284,6 +1289,7 @@ input { a .avatar-container.rect-avatar .avatar.s32 { + border-radius: 4px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); } .sidebar-top-level-items > li .badge.badge-pill { @@ -1581,17 +1587,7 @@ svg.s16 { .rect-avatar.s16 { border-radius: 2px; } -.rect-avatar.s32, -.nav-sidebar-inner-scroll - > div.context-header - a - .avatar-container.rect-avatar - .avatar.s32, -.sidebar-top-level-items - .context-header - a - .avatar-container.rect-avatar - .avatar.s32 { +.rect-avatar.s32 { border-radius: 4px; } @@ -1610,9 +1606,18 @@ svg.s16 { white-space: nowrap; width: 1px; } +.gl-bg-green-500 { + background-color: #108548; +} .gl-border-none\! { border-style: none !important; } +.gl-rounded-pill { + border-radius: 0.75rem; +} +.gl-text-white { + color: #fff; +} .gl-display-none { display: none; } @@ -1631,6 +1636,10 @@ svg.s16 { .gl-pr-2 { padding-right: 0.25rem; } +.gl-py-1 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} .gl-ml-3 { margin-left: 0.5rem; } diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index 013ad3fac87..8d7531d6c9c 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -258,21 +258,6 @@ fieldset:disabled a.btn { align-items: center; justify-content: space-between; } -.d-block { - display: block !important; -} -.d-flex { - display: flex !important; -} -.flex-wrap { - flex-wrap: wrap !important; -} -.justify-content-between { - justify-content: space-between !important; -} -.align-items-center { - align-items: center !important; -} .fixed-top { position: fixed; top: 0; @@ -280,9 +265,6 @@ fieldset:disabled a.btn { left: 0; z-index: 1030; } -.ml-2 { - margin-left: 0.5rem !important; -} .mt-3 { margin-top: 1rem !important; } @@ -349,6 +331,15 @@ fieldset:disabled a.btn { font-size: 0.875rem; border-radius: 0.25rem; } +.gl-button.gl-button .gl-button-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-top: 1px; + padding-bottom: 1px; + margin-top: -1px; + margin-bottom: -1px; +} .gl-button.gl-button .gl-button-icon { height: 1rem; width: 1rem; @@ -637,10 +628,6 @@ svg { padding: 0; border: 0; background: none; - margin-bottom: 16px; -} -.login-page .omniauth-container .omniauth-btn { - width: 100%; } .login-page .new-session-tabs { display: flex; @@ -771,21 +758,18 @@ svg { .gl-align-items-center { align-items: center; } +.gl-flex-wrap { + flex-wrap: wrap; +} .gl-w-full { width: 100%; } -.gl-p-2 { - padding: 0.25rem; -} .gl-p-4 { padding: 0.75rem; } .gl-mt-2 { margin-top: 0.25rem; } -.gl-mb-2 { - margin-bottom: 0.25rem; -} .gl-mb-3 { margin-bottom: 0.5rem; } @@ -797,8 +781,8 @@ svg { margin-top: 0; } } -.gl-text-left { - text-align: left; +.gl-font-weight-bold { + font-weight: 600; } @import "startup/cloaking"; diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index f12b2ee2591..c79816e3579 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -202,7 +202,8 @@ body.gl-dark { &.btn-info, &.btn-success, &.btn-danger, - &.btn-warning { + &.btn-warning, + &.btn-confirm { &-tertiary { mix-blend-mode: screen; } @@ -256,53 +257,3 @@ $line-removed-dark: $red-200; $well-expand-item: $gray-200; $well-inner-border: $gray-200; - -// Misc component overrides that should live elsewhere -.gl-label { - filter: brightness(0.9) contrast(1.1); - - // This applies to the gl-label markups - // rendered and cached in the backend (labels_helper.rb) - &.gl-label-scoped { - .gl-label-text-scoped, - .gl-label-close { - color: $gray-900; - } - } -} - -// white-ish text for light labels -.gl-label-text-light.gl-label-text-light { - color: $gray-900; -} - -.gl-label-text-dark.gl-label-text-dark { - color: $gray-10; -} - -// This applies to "gl-labels" from "gitlab-ui" -.gl-label.gl-label-scoped.gl-label-text-dark, -.gl-label.gl-label-scoped.gl-label-text-light { - .gl-label-text-scoped, - .gl-label-close { - color: $gray-900; - } -} - -// duplicated class as the original .atwho-view style is added later -.atwho-view.atwho-view { - background-color: $white; - color: $gray-900; - border-color: $gray-800; -} - -.nav-sidebar { - li.active { - box-shadow: none; - } - - .sidebar-sub-level-items.fly-out-list { - box-shadow: none; - border: 1px solid $border-color; - } -} diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss new file mode 100644 index 00000000000..b77048174c9 --- /dev/null +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -0,0 +1,116 @@ +@import './themes/dark'; +@import 'page_bundles/mixins_and_variables_and_functions'; +@import './themes/theme_helper'; + +// Some hacks and overrides for things that don't properly support dark mode +.gl-label { + filter: brightness(0.9) contrast(1.1); + + // This applies to the gl-label markups + // rendered and cached in the backend (labels_helper.rb) + &.gl-label-scoped { + .gl-label-text-scoped, + .gl-label-close { + color: $gray-900; + } + } +} + +// white-ish text for light labels +.gl-label-text-light.gl-label-text-light { + color: $gray-900; +} + +.gl-label-text-dark.gl-label-text-dark { + color: $gray-10; +} + +// This applies to "gl-labels" from "gitlab-ui" +.gl-label.gl-label-scoped.gl-label-text-dark, +.gl-label.gl-label-scoped.gl-label-text-light { + .gl-label-text-scoped, + .gl-label-close { + color: $gray-900; + } +} + +// duplicated class as the original .atwho-view style is added later +.atwho-view.atwho-view { + background-color: $white; + color: $gray-900; + border-color: $gray-800; +} + +.nav-sidebar { + li.active { + box-shadow: none; + } + + .sidebar-sub-level-items.fly-out-list { + box-shadow: none; + border: 1px solid $border-color; + } +} + +body.gl-dark { + @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-800, $gray-900, $white); + + .logo-text svg { + fill: var(--gl-text-color); + } + + .navbar-gitlab { + background-color: var(--gray-50); + box-shadow: 0 1px 0 0 var(--gray-100); + + .navbar-sub-nav, + .navbar-nav { + li { + > a:hover, + > a:focus, + > button:hover, + > button:focus { + color: var(--gl-text-color); + background-color: var(--gray-200); + } + } + + li.active, + li.dropdown.show { + > a, + > button { + color: var(--gl-text-color); + background-color: var(--gray-200); + } + } + } + + .header-search { + background-color: var(--gray-100) !important; + box-shadow: inset 0 0 0 1px var(--border-color) !important; + + &:active, + &:hover { + background-color: var(--gray-100) !important; + box-shadow: inset 0 0 0 1px var(--blue-200) !important; + } + } + + .search { + form { + background-color: var(--gray-100); + box-shadow: inset 0 0 0 1px var(--border-color); + + &:active, + &:hover { + background-color: var(--gray-100); + box-shadow: inset 0 0 0 1px var(--blue-200); + } + } + } + } + + .md :not(pre.code) > code { + background-color: $gray-200; + } +} diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index a9e8b238d78..1332686a906 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -212,8 +212,8 @@ a:hover, &.active a, .fly-out-top-item-container { - background-color: var(--purple-100, $purple-900); - color: var(--black, $white); + background-color: var(--gray-100, $gray-50); + color: var(--gray-900, $gray-900); } } } diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index ba24e3e619b..d12ccfc7423 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -15,7 +15,14 @@ class Admin::DashboardController < Admin::ApplicationController @groups = Group.order_id_desc.with_route.limit(10) @notices = Gitlab::ConfigChecker::PumaRuggedChecker.check @notices += Gitlab::ConfigChecker::ExternalDatabaseChecker.check - @redis_versions = [Gitlab::Redis::Queues, Gitlab::Redis::SharedState, Gitlab::Redis::Cache, Gitlab::Redis::TraceChunks].map(&:version).uniq + @redis_versions = [ + Gitlab::Redis::Queues, + Gitlab::Redis::SharedState, + Gitlab::Redis::Cache, + Gitlab::Redis::TraceChunks, + Gitlab::Redis::RateLimiting, + Gitlab::Redis::Sessions + ].map(&:version).uniq end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/instance_review_controller.rb b/app/controllers/admin/instance_review_controller.rb index 88ca2c88aab..5567ffbdc84 100644 --- a/app/controllers/admin/instance_review_controller.rb +++ b/app/controllers/admin/instance_review_controller.rb @@ -3,7 +3,7 @@ class Admin::InstanceReviewController < Admin::ApplicationController feature_category :devops_reports def index - redirect_to("#{::Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL}/instance_review?#{instance_review_params}") + redirect_to("#{Gitlab::SubscriptionPortal.subscriptions_instance_review_url}?#{instance_review_params}") end def instance_review_params diff --git a/app/controllers/admin/serverless/domains_controller.rb b/app/controllers/admin/serverless/domains_controller.rb deleted file mode 100644 index 49cd9f7a36d..00000000000 --- a/app/controllers/admin/serverless/domains_controller.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -class Admin::Serverless::DomainsController < Admin::ApplicationController - before_action :check_feature_flag - before_action :domain, only: [:update, :verify, :destroy] - - feature_category :serverless - - def index - @domain = PagesDomain.instance_serverless.first_or_initialize - end - - def create - if PagesDomain.instance_serverless.exists? - return redirect_to admin_serverless_domains_path, notice: _('An instance-level serverless domain already exists.') - end - - @domain = PagesDomain.instance_serverless.create(create_params) - - if @domain.persisted? - redirect_to admin_serverless_domains_path, notice: _('Domain was successfully created.') - else - render 'index' - end - end - - def update - if domain.update(update_params) - redirect_to admin_serverless_domains_path, notice: _('Domain was successfully updated.') - else - render 'index' - end - end - - def destroy - if domain.serverless_domain_clusters.exists? - return redirect_to admin_serverless_domains_path, - status: :conflict, - notice: _('Domain cannot be deleted while associated to one or more clusters.') - end - - domain.destroy! - - redirect_to admin_serverless_domains_path, - status: :found, - notice: _('Domain was successfully deleted.') - end - - def verify - result = VerifyPagesDomainService.new(domain).execute - - if result[:status] == :success - flash[:notice] = _('Successfully verified domain ownership') - else - flash[:alert] = _('Failed to verify domain ownership') - end - - redirect_to admin_serverless_domains_path - end - - private - - def domain - @domain = PagesDomain.instance_serverless.find(params[:id]) - end - - def check_feature_flag - render_404 unless Feature.enabled?(:serverless_domain) - end - - def update_params - params.require(:pages_domain).permit(:user_provided_certificate, :user_provided_key) - end - - def create_params - params.require(:pages_domain).permit(:domain, :user_provided_certificate, :user_provided_key) - end -end diff --git a/app/controllers/admin/topics/avatars_controller.rb b/app/controllers/admin/topics/avatars_controller.rb new file mode 100644 index 00000000000..7acdec424b4 --- /dev/null +++ b/app/controllers/admin/topics/avatars_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Admin::Topics::AvatarsController < Admin::ApplicationController + feature_category :projects + + def destroy + @topic = Projects::Topic.find(params[:topic_id]) + + @topic.remove_avatar! + @topic.save + + redirect_to edit_admin_topic_path(@topic), status: :found + end +end diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb new file mode 100644 index 00000000000..ccc38ba7cd5 --- /dev/null +++ b/app/controllers/admin/topics_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class Admin::TopicsController < Admin::ApplicationController + include SendFileUpload + include PreviewMarkdown + + before_action :topic, only: [:edit, :update] + + feature_category :projects + + def index + @topics = Projects::TopicsFinder.new(params: params.permit(:search)).execute.page(params[:page]).without_count + end + + def new + @topic = Projects::Topic.new + end + + def edit + end + + def create + @topic = Projects::Topic.new(topic_params) + + if @topic.save + redirect_to edit_admin_topic_path(@topic), notice: _('Topic %{topic_name} was successfully created.') % { topic_name: @topic.name } + else + render "new" + end + end + + def update + if @topic.update(topic_params) + redirect_to edit_admin_topic_path(@topic), notice: _('Topic was successfully updated.') + else + render "edit" + end + end + + private + + def topic + @topic ||= Projects::Topic.find(params[:id]) + end + + def topic_params + params.require(:projects_topic).permit(allowed_topic_params) + end + + def allowed_topic_params + [ + :avatar, + :description, + :name + ] + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a83458f3260..b22167a3952 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,7 +21,7 @@ class ApplicationController < ActionController::Base include Impersonation include Gitlab::Logging::CloudflareHelper include Gitlab::Utils::StrongMemoize - include ::Gitlab::WithFeatureCategory + include ::Gitlab::EndpointAttributes include FlocOptOut before_action :authenticate_user!, except: [:route_not_found] @@ -70,6 +70,10 @@ class ApplicationController < ActionController::Base # concerns due to caching private data. DEFAULT_GITLAB_CACHE_CONTROL = "#{ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL}, no-store" + def self.endpoint_id_for_action(action_name) + "#{self.name}##{action_name}" + end + rescue_from Encoding::CompatibilityError do |exception| log_exception(exception) render "errors/encoding", layout: "errors", status: :internal_server_error @@ -104,6 +108,12 @@ class ApplicationController < ActionController::Base head :forbidden, retry_after: Gitlab::Auth::UniqueIpsLimiter.config.unique_ips_limit_time_window end + rescue_from RateLimitedService::RateLimitedError do |e| + e.log_request(request, current_user) + response.headers.merge!(e.headers) + render plain: e.message, status: :too_many_requests + end + def redirect_back_or_default(default: root_path, options: {}) redirect_back(fallback_location: default, **options) end @@ -131,6 +141,14 @@ class ApplicationController < ActionController::Base end end + def feature_category + self.class.feature_category_for_action(action_name).to_s + end + + def urgency + self.class.urgency_for_action(action_name) + end + protected def workhorse_excluded_content_types @@ -457,7 +475,7 @@ class ApplicationController < ActionController::Base user: -> { context_user }, project: -> { @project if @project&.persisted? }, namespace: -> { @group if @group&.persisted? }, - caller_id: caller_id, + caller_id: self.class.endpoint_id_for_action(action_name), remote_ip: request.ip, feature_category: feature_category ) @@ -543,14 +561,6 @@ class ApplicationController < ActionController::Base auth_user if strong_memoized?(:auth_user) end - def caller_id - "#{self.class.name}##{action_name}" - end - - def feature_category - self.class.feature_category_for_action(action_name).to_s - end - def required_signup_info return unless current_user return unless current_user.role_required? diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb index d076c62c707..35c1f358a77 100644 --- a/app/controllers/concerns/group_tree.rb +++ b/app/controllers/concerns/group_tree.rb @@ -38,8 +38,13 @@ module GroupTree # # Pagination needs to be applied before loading the ancestors to # make sure ancestors are not cut off by pagination. - Gitlab::ObjectHierarchy.new(Group.where(id: filtered_groups.select(:id))) - .base_and_ancestors + filtered_groups_relation = Group.where(id: filtered_groups.select(:id)) + + if Feature.enabled?(:linear_group_tree_ancestor_scopes, current_user, default_enabled: :yaml) + filtered_groups_relation.self_and_ancestors + else + Gitlab::ObjectHierarchy.new(filtered_groups_relation).base_and_ancestors + end end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 7ee680db7f9..e1e662a1968 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -158,8 +158,10 @@ module IssuableActions discussions = Discussion.build_collection(notes, issuable) - if issuable.is_a?(MergeRequest) && Feature.enabled?(:merge_request_discussion_cache, issuable.target_project, default_enabled: :yaml) - render_cached(discussions, with: discussion_serializer, context: self) + if issuable.is_a?(MergeRequest) + cache_context = [current_user&.cache_key, project.team.human_max_access(current_user&.id)].join(':') + + render_cached(discussions, with: discussion_serializer, cache_context: -> (_) { cache_context }, context: self) else render json: discussion_serializer.represent(discussions, context: self) end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 2d8168af2e3..c2ee735a2b5 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -62,7 +62,7 @@ module NotesActions json.merge!(note_json(@note)) end - if @note.errors.present? && @note.errors.keys != [:commands_only] + if @note.errors.present? && @note.errors.attribute_names != [:commands_only] render json: json, status: :unprocessable_entity else render json: json diff --git a/app/controllers/concerns/one_trust_csp.rb b/app/controllers/concerns/one_trust_csp.rb new file mode 100644 index 00000000000..4e98ec586ca --- /dev/null +++ b/app/controllers/concerns/one_trust_csp.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module OneTrustCSP + extend ActiveSupport::Concern + + included do + content_security_policy do |policy| + next if policy.directives.blank? + + default_script_src = policy.directives['script-src'] || policy.directives['default-src'] + script_src_values = Array.wrap(default_script_src) | ["'unsafe-eval'", 'https://cdn.cookielaw.org https://*.onetrust.com'] + policy.script_src(*script_src_values) + + default_connect_src = policy.directives['connect-src'] || policy.directives['default-src'] + connect_src_values = Array.wrap(default_connect_src) | ['https://cdn.cookielaw.org'] + policy.connect_src(*connect_src_values) + end + end +end diff --git a/app/controllers/concerns/registry/connection_errors_handler.rb b/app/controllers/concerns/registry/connection_errors_handler.rb new file mode 100644 index 00000000000..2b24f3b5b31 --- /dev/null +++ b/app/controllers/concerns/registry/connection_errors_handler.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Registry + module ConnectionErrorsHandler + extend ActiveSupport::Concern + + included do + rescue_from ContainerRegistry::Path::InvalidRegistryPathError, with: :invalid_registry_path + rescue_from Faraday::Error, with: :connection_error + + before_action :ping_container_registry + end + + private + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + # These instance variables are only read by a view helper to pass + # them to the frontend + # See app/views/projects/registry/repositories/index.html.haml + # app/views/groups/registry/repositories/index.html.haml + def invalid_registry_path + @invalid_path_error = true + + render :index + end + + def connection_error + @connection_error = true + + render :index + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + + def ping_container_registry + ContainerRegistry::Client.registry_info + end + end +end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 74ad78ff4c1..d861ef646f8 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -36,7 +36,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def starred @projects = load_projects(params.merge(starred: true)) - .includes(:forked_from_project, :topics, :topics_acts_as_taggable) + .includes(:forked_from_project, :topics) @groups = [] diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 515fbd7b482..0722a712b5c 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class GraphqlController < ApplicationController + extend ::Gitlab::Utils::Override + # Unauthenticated users have access to the API for public data skip_before_action :authenticate_user! @@ -35,6 +37,7 @@ class GraphqlController < ApplicationController # callback execution order here around_action :sessionless_bypass_admin_mode!, if: :sessionless_user? + # The default feature category is overridden to read from request feature_category :not_owned def execute @@ -64,6 +67,11 @@ class GraphqlController < ApplicationController render_error(exception.message, status: :unprocessable_entity) end + override :feature_category + def feature_category + ::Gitlab::FeatureCategories.default.from_request(request) || super + end + private def disallow_mutations_for_get diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 60708c13b85..e8e6a7e5c1a 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -11,6 +11,7 @@ class Groups::BoardsController < Groups::ApplicationController push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml) push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml) + push_frontend_feature_flag(:labels_widget, group, default_enabled: :yaml) end feature_category :boards diff --git a/app/controllers/groups/dependency_proxy/application_controller.rb b/app/controllers/groups/dependency_proxy/application_controller.rb index fd9db41f748..18a6ff93e15 100644 --- a/app/controllers/groups/dependency_proxy/application_controller.rb +++ b/app/controllers/groups/dependency_proxy/application_controller.rb @@ -21,8 +21,14 @@ module Groups authenticate_with_http_token do |token, _| @authentication_result = EMPTY_AUTH_RESULT - found_user = user_from_token(token) - sign_in(found_user) if found_user.is_a?(User) + user_or_deploy_token = ::DependencyProxy::AuthTokenService.user_or_deploy_token_from_jwt(token) + + if user_or_deploy_token.is_a?(User) + @authentication_result = Gitlab::Auth::Result.new(user_or_deploy_token, nil, :user, []) + sign_in(user_or_deploy_token) + elsif user_or_deploy_token.is_a?(DeployToken) + @authentication_result = Gitlab::Auth::Result.new(user_or_deploy_token, nil, :deploy_token, []) + end end request_bearer_token! unless authenticated_user @@ -39,28 +45,6 @@ module Groups response.headers['WWW-Authenticate'] = ::DependencyProxy::Registry.authenticate_header render plain: '', status: :unauthorized end - - def user_from_token(token) - token_payload = ::DependencyProxy::AuthTokenService.decoded_token_payload(token) - - if token_payload['user_id'] - token_user = User.find(token_payload['user_id']) - return unless token_user - - @authentication_result = Gitlab::Auth::Result.new(token_user, nil, :user, []) - return token_user - elsif token_payload['deploy_token'] - deploy_token = DeployToken.active.find_by_token(token_payload['deploy_token']) - return unless deploy_token - - @authentication_result = Gitlab::Auth::Result.new(deploy_token, nil, :deploy_token, []) - return deploy_token - end - - nil - rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature - nil - end end end end diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb index f7dc552bd3e..e19b8ae35f8 100644 --- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb +++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb @@ -5,11 +5,15 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy include DependencyProxy::GroupAccess include SendFileUpload include ::PackagesHelper # for event tracking + include WorkhorseRequest before_action :ensure_group - before_action :ensure_token_granted! + before_action :ensure_token_granted!, only: [:blob, :manifest] before_action :ensure_feature_enabled! + before_action :verify_workhorse_api!, only: [:authorize_upload_blob, :upload_blob] + skip_before_action :verify_authenticity_token, only: [:authorize_upload_blob, :upload_blob] + attr_reader :token feature_category :dependency_proxy @@ -38,6 +42,8 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy end def blob + return blob_via_workhorse if Feature.enabled?(:dependency_proxy_workhorse, group, default_enabled: :yaml) + result = DependencyProxy::FindOrCreateBlobService .new(group, image, token, params[:sha]).execute @@ -50,11 +56,47 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy end end + def authorize_upload_blob + set_workhorse_internal_api_content_type + + render json: DependencyProxy::FileUploader.workhorse_authorize(has_length: false) + end + + def upload_blob + @group.dependency_proxy_blobs.create!( + file_name: blob_file_name, + file: params[:file], + size: params[:file].size + ) + + event_name = tracking_event_name(object_type: :blob, from_cache: false) + track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user) + + head :ok + end + private + def blob_via_workhorse + blob = @group.dependency_proxy_blobs.find_by_file_name(blob_file_name) + + if blob.present? + event_name = tracking_event_name(object_type: :blob, from_cache: true) + track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user) + + send_upload(blob.file) + else + send_dependency(token, DependencyProxy::Registry.blob_url(image, params[:sha]), blob_file_name) + end + end + + def blob_file_name + @blob_file_name ||= params[:sha].sub('sha256:', '') + '.gz' + end + def group strong_memoize(:group) do - Group.find_by_full_path(params[:group_id], follow_redirects: request.get?) + Group.find_by_full_path(params[:group_id], follow_redirects: true) end end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 9b8d5cfe476..6e59f159636 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -25,19 +25,15 @@ class Groups::GroupMembersController < Groups::ApplicationController def index @sort = params[:sort].presence || sort_value_name - @members = GroupMembersFinder - .new(@group, current_user, params: filter_params) - .execute(include_relations: requested_relations) - if can?(current_user, :admin_group_member, @group) @skip_groups = @group.related_group_ids - @invited_members = @members.invite + @invited_members = invited_members @invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present? @invited_members = present_invited_members(@invited_members) end - @members = present_group_members(@members.non_invite) + @members = present_group_members(non_invited_members) @requesters = present_members( AccessRequestsFinder.new(@group).execute(current_user) @@ -51,6 +47,20 @@ class Groups::GroupMembersController < Groups::ApplicationController private + def group_members + @group_members ||= GroupMembersFinder + .new(@group, current_user, params: filter_params) + .execute(include_relations: requested_relations) + end + + def invited_members + group_members.invite.with_invited_user_state + end + + def non_invited_members + group_members.non_invite + end + def present_invited_members(invited_members) present_members(invited_members .page(params[:invited_members_page]) diff --git a/app/controllers/groups/packages_controller.rb b/app/controllers/groups/packages_controller.rb index 47f1816cc4c..d02a8262948 100644 --- a/app/controllers/groups/packages_controller.rb +++ b/app/controllers/groups/packages_controller.rb @@ -6,6 +6,10 @@ module Groups feature_category :package_registry + before_action do + push_frontend_feature_flag(:package_list_apollo, default_enabled: :yaml) + end + private def verify_packages_enabled! diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index 3aaaf6ade6b..549a148bfb8 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -3,6 +3,7 @@ module Groups module Registry class RepositoriesController < Groups::ApplicationController include PackagesHelper + include ::Registry::ConnectionErrorsHandler before_action :verify_container_registry_enabled! before_action :authorize_read_container_image! diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index f37c08da22a..5c21c7b023c 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -10,8 +10,10 @@ class Groups::RunnersController < Groups::ApplicationController feature_category :runner def index - finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }) - @group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000) + ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') do + finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }) + @group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000) + end end def runner_list_group_view_vue_ui_enabled @@ -61,9 +63,11 @@ class Groups::RunnersController < Groups::ApplicationController private def runner - @runner ||= Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }).execute - .except(:limit, :offset) - .find(params[:id]) + ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') do + @runner ||= Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }).execute + .except(:limit, :offset) + .find(params[:id]) + end end def runner_params diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index a290ef9b5e7..e125385f841 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -23,6 +23,11 @@ module Groups @group_runners = runners_finder.execute.page(params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE) @sort = runners_finder.sort_key + + # Allow sql generated by the two relations above, @all_group_runners and @group_runners + ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') do + render + end end def update diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 99b0b775217..071378f266e 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -16,6 +16,8 @@ class HealthController < ActionController::Base Gitlab::HealthChecks::Redis::QueuesCheck, Gitlab::HealthChecks::Redis::SharedStateCheck, Gitlab::HealthChecks::Redis::TraceChunksCheck, + Gitlab::HealthChecks::Redis::RateLimitingCheck, + Gitlab::HealthChecks::Redis::SessionsCheck, Gitlab::HealthChecks::GitalyCheck ].freeze diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index a1fb74cf277..0ad7478584f 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -72,7 +72,6 @@ class HelpController < ApplicationController end def redirect_to_documentation_website? - return false unless Feature.enabled?(:help_page_documentation_redirect) return false unless Gitlab::UrlSanitizer.valid_web?(documentation_url) true diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index da936215ad4..bec26cb547d 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -22,13 +22,16 @@ class Import::BulkImportsController < ApplicationController def status respond_to do |format| format.json do - data = importable_data + data = ::BulkImports::GetImportableDataService.new(params, query_params, credentials).execute pagination_headers.each do |header| - response.set_header(header, data.headers[header]) + response.set_header(header, data[:response].headers[header]) end - render json: { importable_data: serialized_data(data.parsed_response) } + json_response = { importable_data: serialized_data(data[:response].parsed_response) } + json_response[:version_validation] = data[:version_validation] + + render json: json_response end format.html do @source_url = session[url_key] @@ -37,7 +40,7 @@ class Import::BulkImportsController < ApplicationController end def create - response = BulkImportService.new(current_user, create_params, credentials).execute + response = ::BulkImports::CreateService.new(current_user, create_params, credentials).execute if response.success? render json: response.payload.to_json(only: [:id]) @@ -66,10 +69,6 @@ class Import::BulkImportsController < ApplicationController @serializer ||= BaseSerializer.new(current_user: current_user) end - def importable_data - client.get('groups', query_params) - end - # Default query string params used to fetch groups from GitLab source instance # # top_level_only: fetch only top level groups (subgroups are fetched during import itself) @@ -85,15 +84,6 @@ class Import::BulkImportsController < ApplicationController query_params end - def client - @client ||= BulkImports::Clients::HTTP.new( - url: session[url_key], - token: session[access_token_key], - per_page: params[:per_page], - page: params[:page] - ) - end - def configure_params params.permit(access_token_key, url_key) end diff --git a/app/controllers/import/url_controller.rb b/app/controllers/import/url_controller.rb new file mode 100644 index 00000000000..4e4b6ad125e --- /dev/null +++ b/app/controllers/import/url_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Import::UrlController < ApplicationController + feature_category :importers + + def validate + result = Import::ValidateRemoteGitEndpointService.new(validate_params).execute + if result.success? + render json: { success: true } + else + render json: { success: false, message: result.message } + end + end + + private + + def validate_params + params.permit(:user, :password, :url) + end +end diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb index 74fac6ff9bb..e96242c7052 100644 --- a/app/controllers/jira_connect/app_descriptor_controller.rb +++ b/app/controllers/jira_connect/app_descriptor_controller.rb @@ -32,6 +32,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController apiVersion: 1, apiMigrations: { 'context-qsh': true, + 'signed-install': signed_install_active?, gdpr: true } } diff --git a/app/controllers/jira_connect/application_controller.rb b/app/controllers/jira_connect/application_controller.rb index 352e78d6255..ecb23c326fe 100644 --- a/app/controllers/jira_connect/application_controller.rb +++ b/app/controllers/jira_connect/application_controller.rb @@ -74,4 +74,8 @@ class JiraConnect::ApplicationController < ApplicationController params[:jwt] || request.headers['Authorization']&.split(' ', 2)&.last end end + + def signed_install_active? + Feature.enabled?(:jira_connect_asymmetric_jwt) + end end diff --git a/app/controllers/jira_connect/events_controller.rb b/app/controllers/jira_connect/events_controller.rb index fe66e742c44..76ac15f7631 100644 --- a/app/controllers/jira_connect/events_controller.rb +++ b/app/controllers/jira_connect/events_controller.rb @@ -3,13 +3,18 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController # See https://developer.atlassian.com/cloud/jira/software/app-descriptor/#lifecycle - skip_before_action :verify_atlassian_jwt!, only: :installed - before_action :verify_qsh_claim!, only: :uninstalled + skip_before_action :verify_atlassian_jwt! + before_action :verify_asymmetric_atlassian_jwt!, if: :signed_install_active? + + before_action :verify_atlassian_jwt!, only: :uninstalled, unless: :signed_install_active? + before_action :verify_qsh_claim!, only: :uninstalled, unless: :signed_install_active? def installed - return head :ok if atlassian_jwt_valid? + return head :ok if !signed_install_active? && atlassian_jwt_valid? + + return head :ok if current_jira_installation - installation = JiraConnectInstallation.new(install_params) + installation = JiraConnectInstallation.new(event_params) if installation.save head :ok @@ -28,7 +33,23 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController private - def install_params + def event_params params.permit(:clientKey, :sharedSecret, :baseUrl).transform_keys(&:underscore) end + + def verify_asymmetric_atlassian_jwt! + asymmetric_jwt = Atlassian::JiraConnect::AsymmetricJwt.new(auth_token, jwt_verification_claims) + + return head :unauthorized unless asymmetric_jwt.valid? + + @current_jira_installation = JiraConnectInstallation.find_by_client_key(asymmetric_jwt.iss_claim) + end + + def jwt_verification_claims + { + aud: jira_connect_base_url(protocol: 'https'), + iss: event_params[:client_key], + qsh: Atlassian::Jwt.create_query_string_hash(request.url, request.method, jira_connect_base_url) + } + end end diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index a0c307a0a03..d3dea2ce159 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -7,6 +7,7 @@ class MetricsController < ActionController::Base def index response = if Gitlab::Metrics.prometheus_metrics_enabled? + Gitlab::Metrics::RailsSlis.initialize_request_slis_if_needed! metrics_service.metrics_text else help_page = help_page_url('administration/monitoring/prometheus/gitlab_metrics', diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index c8c2dd1c7d6..5eb0f80ddc9 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -15,17 +15,11 @@ class Profiles::PasswordsController < Profiles::ApplicationController end def create - unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password]) + unless @user.password_automatically_set || @user.valid_password?(user_params[:password]) redirect_to new_profile_password_path, alert: _('You must provide a valid current password') return end - password_attributes = { - password: user_params[:password], - password_confirmation: user_params[:password_confirmation], - password_automatically_set: false - } - result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute if result[:status] == :success @@ -41,12 +35,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController end def update - password_attributes = user_params.select do |key, value| - %w(password password_confirmation).include?(key.to_s) - end - password_attributes[:password_automatically_set] = false - - unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password]) + unless @user.password_automatically_set || @user.valid_password?(user_params[:password]) handle_invalid_current_password_attempt! redirect_to edit_profile_password_path, alert: _('You must provide a valid current password') @@ -94,6 +83,14 @@ class Profiles::PasswordsController < Profiles::ApplicationController end def user_params - params.require(:user).permit(:current_password, :password, :password_confirmation) + params.require(:user).permit(:password, :new_password, :password_confirmation) + end + + def password_attributes + { + password: user_params[:new_password], + password_confirmation: user_params[:password_confirmation], + password_automatically_set: false + } end end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index de22a0e47d5..e0b5d6be155 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -237,8 +237,6 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController end def ensure_verified_primary_email - return unless Feature.enabled?(:ensure_verified_primary_email_for_2fa, default_enabled: :yaml) - unless current_user.two_factor_enabled? || current_user.primary_email_verified? redirect_to profile_emails_path, notice: s_('You need to verify your primary email first before enabling Two-Factor Authentication.') end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 29ae268ef67..69257081cc9 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -18,7 +18,7 @@ class ProfilesController < Profiles::ApplicationController def update respond_to do |format| - result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute + result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute(check_password: true) if result[:status] == :success message = s_("Profiles|Profile was successfully updated") @@ -129,6 +129,7 @@ class ProfilesController < Profiles::ApplicationController :job_title, :pronouns, :pronunciation, + :validation_password, status: [:emoji, :message, :availability] ] end diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb index db5d91308db..95b403faf55 100644 --- a/app/controllers/projects/alerting/notifications_controller.rb +++ b/app/controllers/projects/alerting/notifications_controller.rb @@ -3,6 +3,8 @@ module Projects module Alerting class NotificationsController < Projects::ApplicationController + include ActionController::HttpAuthentication::Basic + respond_to :json skip_before_action :verify_authenticity_token @@ -27,9 +29,19 @@ module Projects end def extract_alert_manager_token(request) + extract_bearer_token(request) || extract_basic_auth_token(request) + end + + def extract_bearer_token(request) Doorkeeper::OAuth::Token.from_bearer_authorization(request) end + def extract_basic_auth_token(request) + _username, token = user_name_and_password(request) + + token + end + def notify_service notify_service_class.new(project, notification_payload) end diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index f75ab5cdbf2..0cd59c136e5 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -24,7 +24,10 @@ class Projects::BadgesController < Projects::ApplicationController .new(project, params[:ref], opts: { job: params[:job], key_text: params[:key_text], - key_width: params[:key_width] + key_width: params[:key_width], + min_good: params[:min_good], + min_acceptable: params[:min_acceptable], + min_medium: params[:min_medium] }) render_badge coverage_report diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index acf6b6116b8..17fd28ee06a 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -43,6 +43,7 @@ class Projects::BlobController < Projects::ApplicationController before_action do push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) + push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml) push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 316582f3994..834e4baa7dd 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -11,6 +11,7 @@ class Projects::BoardsController < Projects::ApplicationController push_frontend_feature_flag(:issue_boards_filtered_search, project, default_enabled: :yaml) push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml) + push_frontend_feature_flag(:labels_widget, project, default_enabled: :yaml) end feature_category :boards diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 3be10559e80..b75effc52d1 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -33,6 +33,11 @@ class Projects::BranchesController < Projects::ApplicationController Gitlab::GitalyClient.allow_n_plus_1_calls do render end + rescue Gitlab::Git::CommandError => e + Gitlab::ErrorTracking.track_exception(e) + + @gitaly_unavailable = true + render end format.json do branches = BranchesFinder.new(@repository, params).execute diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb index fee216da492..b2b5e096105 100644 --- a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb +++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb @@ -4,7 +4,7 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati before_action :authorize_read_build_report_results! before_action :validate_param_type! - feature_category :continuous_integration + feature_category :code_testing def index respond_to do |format| diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 550877548e1..22cd247644d 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -3,8 +3,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController before_action :check_can_collaborate! before_action do - push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml) - push_frontend_feature_flag(:pipeline_editor_drawer, @project, default_enabled: :yaml) + push_frontend_feature_flag(:pipeline_editor_mini_graph, @project, default_enabled: :yaml) push_frontend_feature_flag(:schema_linting, @project, default_enabled: :yaml) end diff --git a/app/controllers/projects/cluster_agents_controller.rb b/app/controllers/projects/cluster_agents_controller.rb new file mode 100644 index 00000000000..e7fbe93131d --- /dev/null +++ b/app/controllers/projects/cluster_agents_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Projects::ClusterAgentsController < Projects::ApplicationController + before_action :authorize_can_read_cluster_agent! + + feature_category :kubernetes_management + + def show + @agent_name = params[:name] + end + + private + + def authorize_can_read_cluster_agent! + return if can?(current_user, :admin_cluster, project) + + access_denied! + end +end diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb new file mode 100644 index 00000000000..d185457aeb3 --- /dev/null +++ b/app/controllers/projects/google_cloud_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Projects::GoogleCloudController < Projects::ApplicationController + before_action :authorize_can_manage_google_cloud_deployments! + + feature_category :release_orchestration + + def index + end + + private + + def authorize_can_manage_google_cloud_deployments! + access_denied! unless can?(current_user, :manage_project_google_cloud, project) + end +end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index f885ff9b45b..fd508d5f127 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -37,7 +37,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_download_code!, only: [:related_branches] # Limit the amount of issues created per minute - before_action :create_rate_limit, only: [:create] + before_action :create_rate_limit, only: [:create], if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) } before_action do push_frontend_feature_flag(:tribute_autocomplete, @project) diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 778623a05c6..994be5c2b5c 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -44,7 +44,7 @@ class Projects::JobsController < Projects::ApplicationController render json: BuildSerializer .new(project: @project, current_user: @current_user) - .represent(@build, {}, BuildDetailsEntity) + .represent(@build.present(current_user: current_user), {}, BuildDetailsEntity) end end end @@ -120,7 +120,7 @@ class Projects::JobsController < Projects::ApplicationController def status render json: BuildSerializer .new(project: @project, current_user: @current_user) - .represent_status(@build) + .represent_status(@build.present(current_user: current_user)) end def erase @@ -225,7 +225,6 @@ class Projects::JobsController < Projects::ApplicationController def find_job_as_build @build = project.builds.find(params[:id]) - .present(current_user: current_user) end def find_job_as_processable diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index cb68aaf4583..46df514abcb 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -37,10 +37,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:core_security_mr_widget_counts, @project) push_frontend_feature_flag(:paginated_notes, @project, default_enabled: :yaml) push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml) - push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml) push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml) push_frontend_feature_flag(:diffs_virtual_scrolling, project, default_enabled: :yaml) push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml) + push_frontend_feature_flag(:mr_changes_fluid_layout, project, default_enabled: :yaml) # Usage data feature flags push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml) @@ -192,15 +192,17 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo Gitlab::PollingInterval.set_header(response, interval: 10_000) - render json: { - pipelines: PipelineSerializer - .new(project: @project, current_user: @current_user) - .with_pagination(request, response) - .represent(@pipelines), - count: { - all: @pipelines.count + ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891') do + render json: { + pipelines: PipelineSerializer + .new(project: @project, current_user: @current_user) + .with_pagination(request, response) + .represent(@pipelines), + count: { + all: @pipelines.count + } } - } + end end def sast_reports diff --git a/app/controllers/projects/packages/packages_controller.rb b/app/controllers/projects/packages/packages_controller.rb index 5de71466c10..dd7c2ad3cbd 100644 --- a/app/controllers/projects/packages/packages_controller.rb +++ b/app/controllers/projects/packages/packages_controller.rb @@ -7,6 +7,10 @@ module Projects feature_category :package_registry + before_action do + push_frontend_feature_flag(:package_list_apollo, default_enabled: :yaml) + end + def show @package = project.packages.find(params[:id]) end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index b979276437c..e8074f7d793 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -19,16 +19,12 @@ class Projects::ProjectMembersController < Projects::ApplicationController @group_links = @project.project_group_links @group_links = @group_links.search(params[:search_groups]) if params[:search_groups].present? - project_members = MembersFinder - .new(@project, current_user, params: filter_params) - .execute(include_relations: requested_relations) - if can?(current_user, :admin_project_member, @project) - @invited_members = present_members(project_members.invite) + @invited_members = present_members(invited_members) @requesters = present_members(AccessRequestsFinder.new(@project).execute(current_user)) end - @project_members = present_members(project_members.non_invite.page(params[:page])) + @project_members = present_members(non_invited_members.page(params[:page])) @project_member = @project.project_members.new end @@ -55,6 +51,20 @@ class Projects::ProjectMembersController < Projects::ApplicationController private + def members + @members ||= MembersFinder + .new(@project, current_user, params: filter_params) + .execute(include_relations: requested_relations) + end + + def invited_members + members.invite.with_invited_user_state + end + + def non_invited_members + members.non_invite + end + def filter_params params.permit(:search).merge(sort: @sort) end diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 8acebd89033..ad3b2bc98e7 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -4,6 +4,7 @@ module Projects module Registry class RepositoriesController < ::Projects::Registry::ApplicationController include PackagesHelper + include ::Registry::ConnectionErrorsHandler before_action :authorize_update_container_image!, only: [:destroy] @@ -48,8 +49,6 @@ module Projects repository.save! if repository.has_tags? end end - rescue ContainerRegistry::Path::InvalidRegistryPathError - @character_error = true end end end diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb index 19de157357a..444f4783a19 100644 --- a/app/controllers/projects/security/configuration_controller.rb +++ b/app/controllers/projects/security/configuration_controller.rb @@ -5,7 +5,7 @@ module Projects class ConfigurationController < Projects::ApplicationController include SecurityAndCompliancePermissions - feature_category :static_application_security_testing + feature_category :static_application_security_testing, [:show] def show render_403 unless can?(current_user, :read_security_configuration, project) diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb index 4168880001c..3fc379a135a 100644 --- a/app/controllers/projects/serverless/functions_controller.rb +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -5,7 +5,7 @@ module Projects class FunctionsController < Projects::ApplicationController before_action :authorize_read_cluster! - feature_category :serverless + feature_category :not_owned def index respond_to do |format| diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 960c0beb244..3033dac8246 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -25,6 +25,11 @@ module Projects @project.triggers, current_user: current_user, project: @project ).to_json end + + # @assignable_runners is using ci_owned_runners + ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336436') do + render + end end def update diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 94b0473e1f3..02d36c3353d 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -18,17 +18,21 @@ class Projects::TagsController < Projects::ApplicationController params[:sort] = params[:sort].presence || sort_value_recently_updated @sort = params[:sort] - @tags = TagsFinder.new(@repository, params).execute - @tags = Kaminari.paginate_array(@tags).page(params[:page]) + @tags, @tags_loading_error = TagsFinder.new(@repository, params).execute + + @tags = Kaminari.paginate_array(@tags).page(params[:page]) tag_names = @tags.map(&:name) @tags_pipelines = @project.ci_pipelines.latest_successful_for_refs(tag_names) + @releases = project.releases.where(tag: tag_names) @tag_pipeline_statuses = Ci::CommitStatusesFinder.new(@project, @repository, current_user, @tags).execute respond_to do |format| - format.html - format.atom { render layout: 'xml.atom' } + status = @tags_loading_error ? :service_unavailable : :ok + + format.html { render status: status } + format.atom { render layout: 'xml.atom', status: status } end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 6fd4c632dd3..a76d45411dd 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -16,7 +16,9 @@ class Projects::TreeController < Projects::ApplicationController before_action :authorize_edit_tree!, only: [:create_dir] before_action do + push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml) push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml) + push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml) end feature_category :source_code_management diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb index 179c7fc8db1..103e1cc596a 100644 --- a/app/controllers/projects/usage_quotas_controller.rb +++ b/app/controllers/projects/usage_quotas_controller.rb @@ -9,6 +9,7 @@ class Projects::UsageQuotasController < Projects::ApplicationController feature_category :utilization def index + @hide_search_settings = true @storage_app_data = { project_path: @project.full_path, usage_quotas_help_page_path: help_page_path('user/usage_quotas'), diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index de4e51a3a2f..26da0436dd8 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -33,9 +33,12 @@ class ProjectsController < Projects::ApplicationController before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export] before_action do + push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml) push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) + push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml) push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml) push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml) + push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml) end layout :determine_layout @@ -72,6 +75,13 @@ class ProjectsController < Projects::ApplicationController @project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute if @project.saved? + experiment(:new_project_sast_enabled, user: current_user).track(:created, + property: active_new_project_tab, + checked: Gitlab::Utils.to_boolean(project_params[:initialize_with_sast]), + project: @project, + namespace: @project.namespace + ) + redirect_to( project_path(@project, custom_import_params), notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name } @@ -283,9 +293,9 @@ class ProjectsController < Projects::ApplicationController end if find_tags && @repository.tag_count.nonzero? - tags = TagsFinder.new(@repository, params).execute.take(100).map(&:name) + tags, _ = TagsFinder.new(@repository, params).execute - options['Tags'] = tags + options['Tags'] = tags.take(100).map(&:name) end # If reference is commit id - we should add it to branch/tag selectbox @@ -435,6 +445,7 @@ class ProjectsController < Projects::ApplicationController :template_name, :template_project_id, :merge_method, + :initialize_with_sast, :initialize_with_readme, :autoclose_referenced_issues, :suggestion_commit_message, diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index fe800de5dd8..450c12a233b 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -5,6 +5,7 @@ class RegistrationsController < Devise::RegistrationsController include AcceptsPendingInvitations include RecaptchaHelper include InvisibleCaptchaOnSignup + include OneTrustCSP layout 'devise' @@ -45,6 +46,11 @@ class RegistrationsController < Devise::RegistrationsController end def destroy + if current_user.required_terms_not_accepted? + redirect_to profile_account_path, status: :see_other, alert: s_('Profiles|You must accept the Terms of Service in order to perform this action.') + return + end + if destroy_confirmation_valid? current_user.delete_async(deleted_by: current_user) session.try(:destroy) diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index e51bfe6a37e..c3c6a51239d 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -11,6 +11,9 @@ module Repositories rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404_with_exception rescue_from Gitlab::GitAccessProject::CreationError, with: :render_422_with_exception rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503_with_exception + rescue_from GRPC::Unavailable do |e| + render_503_with_exception(e, message: 'The git server, Gitaly, is not available at this time. Please contact your administrator.') + end # GET /foo/bar.git/info/refs?service=git-upload-pack (git pull) # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) @@ -71,8 +74,8 @@ module Repositories render plain: exception.message, status: :unprocessable_entity end - def render_503_with_exception(exception) - render plain: exception.message, status: :service_unavailable + def render_503_with_exception(exception, message: nil) + render plain: message || exception.message, status: :service_unavailable end def update_fetch_statistics diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 5f1b3750e41..0a18559fc81 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -12,6 +12,7 @@ class SearchController < ApplicationController around_action :allow_gitaly_ref_name_caching before_action :block_anonymous_global_searches, :check_scope_global_search_enabled, except: :opensearch + before_action :strip_surrounding_whitespace_from_search, except: :opensearch skip_before_action :authenticate_user! requires_cross_project_access if: -> do search_term_present = params[:search].present? || params[:term].present? @@ -23,6 +24,7 @@ class SearchController < ApplicationController layout 'search' feature_category :global_search + urgency :high, [:opensearch] def show @project = search_service.project @@ -196,6 +198,10 @@ class SearchController < ApplicationController def count_action_name? action_name.to_sym == :count end + + def strip_surrounding_whitespace_from_search + %i(term search).each { |param| params[param]&.strip! } + end end SearchController.prepend_mod_with('SearchController') diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 4fcf82c605b..bbd7e5d5725 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -9,6 +9,7 @@ class SessionsController < Devise::SessionsController include RendersLdapServers include KnownSignIn include Gitlab::Utils::StrongMemoize + include OneTrustCSP skip_before_action :check_two_factor_requirement, only: [:destroy] skip_before_action :check_password_expiration, only: [:destroy] diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index d040ac7f76c..d7eb3ccd274 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -13,6 +13,7 @@ class UploadsController < ApplicationController "group" => Group, "appearance" => Appearance, "personal_snippet" => PersonalSnippet, + "projects/topic" => Projects::Topic, nil => PersonalSnippet }.freeze @@ -54,6 +55,8 @@ class UploadsController < ApplicationController !secret? || can?(current_user, :update_user, model) when Appearance true + when Projects::Topic + true else permission = "read_#{model.class.underscore}".to_sym @@ -85,7 +88,7 @@ class UploadsController < ApplicationController def cache_settings case model - when User, Appearance + when User, Appearance, Projects::Topic [5.minutes, { public: true, must_revalidate: false }] when Project, Group [5.minutes, { private: true, must_revalidate: true }] diff --git a/app/experiments/new_project_sast_enabled_experiment.rb b/app/experiments/new_project_sast_enabled_experiment.rb new file mode 100644 index 00000000000..1ab86d70134 --- /dev/null +++ b/app/experiments/new_project_sast_enabled_experiment.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class NewProjectSastEnabledExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass + def publish(_result = nil) + super + + publish_to_database + end + + def candidate_behavior + end + + def free_indicator_behavior + end +end diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb index 5d794c0903a..9476c30f525 100644 --- a/app/finders/ci/pipelines_for_merge_request_finder.rb +++ b/app/finders/ci/pipelines_for_merge_request_finder.rb @@ -5,6 +5,8 @@ module Ci class PipelinesForMergeRequestFinder include Gitlab::Utils::StrongMemoize + COMMITS_LIMIT = 100 + def initialize(merge_request, current_user) @merge_request = merge_request @current_user = current_user @@ -12,7 +14,7 @@ module Ci attr_reader :merge_request, :current_user - delegate :commit_shas, :target_project, :source_project, :source_branch, to: :merge_request + delegate :recent_diff_head_shas, :commit_shas, :target_project, :source_project, :source_branch, to: :merge_request # Fetch all pipelines that the user can read. def execute @@ -35,7 +37,7 @@ module Ci pipelines = if merge_request.persisted? - pipelines_using_cte + all_pipelines_for_merge_request else triggered_for_branch.for_sha(commit_shas) end @@ -79,6 +81,17 @@ module Ci pipelines.joins(shas_table) # rubocop: disable CodeReuse/ActiveRecord end + def all_pipelines_for_merge_request + if Feature.enabled?(:decomposed_ci_query_in_pipelines_for_merge_request_finder, target_project, default_enabled: :yaml) + pipelines_for_merge_request = triggered_by_merge_request + pipelines_for_branch = triggered_for_branch.for_sha(recent_diff_head_shas(COMMITS_LIMIT)) + + Ci::Pipeline.from_union([pipelines_for_merge_request, pipelines_for_branch]) + else + pipelines_using_cte + end + end + # NOTE: this method returns only parent merge request pipelines. # Child merge request pipelines have a different source. def triggered_by_merge_request diff --git a/app/finders/clusters/agents_finder.rb b/app/finders/clusters/agents_finder.rb new file mode 100644 index 00000000000..136bbf16981 --- /dev/null +++ b/app/finders/clusters/agents_finder.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Clusters + class AgentsFinder + def initialize(project, current_user, params: {}) + @project = project + @current_user = current_user + @params = params + end + + def execute + return ::Clusters::Agent.none unless can_read_cluster_agents? + + agents = project.cluster_agents + agents = agents.with_name(params[:name]) if params[:name].present? + + agents.ordered_by_name + end + + private + + attr_reader :project, :current_user, :params + + def can_read_cluster_agents? + current_user.can?(:read_cluster, project) + end + end +end diff --git a/app/finders/concerns/packages/finder_helper.rb b/app/finders/concerns/packages/finder_helper.rb index d2784a1d270..0ae99782cd3 100644 --- a/app/finders/concerns/packages/finder_helper.rb +++ b/app/finders/concerns/packages/finder_helper.rb @@ -54,6 +54,12 @@ module Packages packages.search_by_name(params[:package_name]) end + def filter_by_exact_package_name(packages) + return packages unless params[:package_name].present? + + packages.with_name(params[:package_name]) + end + def filter_by_package_version(packages) return packages unless params[:package_version].present? diff --git a/app/finders/error_tracking/errors_finder.rb b/app/finders/error_tracking/errors_finder.rb index d83a0c487e6..c361d6e2fc2 100644 --- a/app/finders/error_tracking/errors_finder.rb +++ b/app/finders/error_tracking/errors_finder.rb @@ -15,8 +15,7 @@ module ErrorTracking collection = by_status(collection) collection = sort(collection) - # Limit collection until pagination implemented. - limit(collection) + collection.keyset_paginate(cursor: params[:cursor], per_page: limit) end private @@ -39,9 +38,9 @@ module ErrorTracking params[:sort] ? collection.sort_by_attribute(params[:sort]) : collection.order_id_desc end - def limit(collection) + def limit # Restrict the maximum limit at 100 records. - collection.limit([(params[:limit] || 20).to_i, 100].min) + [(params[:limit] || 20).to_i, 100].min end end end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index cf706a8f98e..7b0cd17a761 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -194,8 +194,7 @@ class IssuableFinder def use_cte_for_search? strong_memoize(:use_cte_for_search) do next false unless search - # Only simple unsorted & simple sorts can use CTE - next false if params[:sort].present? && !params[:sort].in?(klass.simple_sorts.keys) + next false unless default_or_simple_sort? attempt_group_search_optimizations? || attempt_project_search_optimizations? end @@ -244,6 +243,10 @@ class IssuableFinder klass.all end + def default_or_simple_sort? + params[:sort].blank? || params[:sort].to_s.in?(klass.simple_sorts.keys) + end + def attempt_group_search_optimizations? params[:attempt_group_search_optimizations] end diff --git a/app/finders/issuables/label_filter.rb b/app/finders/issuables/label_filter.rb index 2bbc963aa90..f4712fa6879 100644 --- a/app/finders/issuables/label_filter.rb +++ b/app/finders/issuables/label_filter.rb @@ -89,17 +89,25 @@ module Issuables end # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def find_label_ids(label_names) - group_labels = Label - .where(project_id: nil) - .where(title: label_names) - .where(group_id: root_namespace.self_and_descendant_ids) + find_label_ids_uncached(label_names) + end + # Avoid repeating label queries times when the finder is instantiated multiple times during the request. + request_cache(:find_label_ids) { root_namespace.id } - project_labels = Label - .where(group_id: nil) - .where(title: label_names) - .where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids)) + # This returns an array of label IDs per label name. It is possible for a label name + # to have multiple IDs because we allow labels with the same name if they are on a different + # project or group. + # + # For example, if we pass in `['bug', 'feature']`, this will return something like: + # `[ [1, 2], [3] ]` + # + # rubocop: disable CodeReuse/ActiveRecord + def find_label_ids_uncached(label_names) + return [] if label_names.empty? + + group_labels = group_labels_for_root_namespace.where(title: label_names) + project_labels = project_labels_for_root_namespace.where(title: label_names) Label .from_union([group_labels, project_labels], remove_duplicates: false) @@ -109,8 +117,18 @@ module Issuables .values .map { |labels| labels.map(&:last) } end - # Avoid repeating label queries times when the finder is instantiated multiple times during the request. - request_cache(:find_label_ids) { root_namespace.id } + # rubocop: enable CodeReuse/ActiveRecord + + # rubocop: disable CodeReuse/ActiveRecord + def group_labels_for_root_namespace + Label.where(project_id: nil).where(group_id: root_namespace.self_and_descendant_ids) + end + # rubocop: enable CodeReuse/ActiveRecord + + # rubocop: disable CodeReuse/ActiveRecord + def project_labels_for_root_namespace + Label.where(group_id: nil).where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids)) + end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord @@ -153,3 +171,5 @@ module Issuables end end end + +Issuables::LabelFilter.prepend_mod diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index abf0c180d6b..21a19aa22a1 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -91,6 +91,12 @@ class IssuesFinder < IssuableFinder by_issue_types(issues) end + # Negates all params found in `negatable_params` + def filter_negated_items(items) + issues = super + by_negated_issue_types(issues) + end + def by_confidential(items) return items if params[:confidential].nil? @@ -122,6 +128,13 @@ class IssuesFinder < IssuableFinder items.with_issue_type(params[:issue_types]) end + + def by_negated_issue_types(items) + issue_type_params = Array(not_params[:issue_types]).map(&:to_s) & WorkItem::Type.base_types.keys + return items if issue_type_params.blank? + + items.without_issue_type(issue_type_params) + end end IssuesFinder.prepend_mod_with('IssuesFinder') diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index ea101cf1dcd..0faafa6df9c 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -70,11 +70,16 @@ class MembersFinder end def project_invited_groups - invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy - .new(project.invited_groups) - .base_and_ancestors - .public_or_visible_to_user(current_user) - .select(:id) + invited_groups_and_ancestors = if ::Feature.enabled?(:linear_members_finder_ancestor_scopes, current_user, default_enabled: :yaml) + project.invited_groups + .self_and_ancestors + else + Gitlab::ObjectHierarchy + .new(project.invited_groups) + .base_and_ancestors + end + + invited_groups_ids_including_ancestors = invited_groups_and_ancestors.public_or_visible_to_user(current_user).select(:id) GroupMember.with_source_id(invited_groups_ids_including_ancestors).non_minimal_access end diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb index e753fa4d455..3ac5f00d518 100644 --- a/app/finders/packages/group_packages_finder.rb +++ b/app/finders/packages/group_packages_finder.rb @@ -4,7 +4,7 @@ module Packages class GroupPackagesFinder include ::Packages::FinderHelper - def initialize(current_user, group, params = { exclude_subgroups: false, order_by: 'created_at', sort: 'asc' }) + def initialize(current_user, group, params = { exclude_subgroups: false, exact_name: false, order_by: 'created_at', sort: 'asc' }) @current_user = current_user @group = group @params = params @@ -30,7 +30,7 @@ module Packages packages = filter_with_version(packages) packages = filter_by_package_type(packages) - packages = filter_by_package_name(packages) + packages = (params[:exact_name] ? filter_by_exact_package_name(packages) : filter_by_package_name(packages)) packages = filter_by_package_version(packages) installable_only ? packages.installable : filter_by_status(packages) end diff --git a/app/finders/projects/members/effective_access_level_finder.rb b/app/finders/projects/members/effective_access_level_finder.rb index c1e3842a9e4..d238679f2fb 100644 --- a/app/finders/projects/members/effective_access_level_finder.rb +++ b/app/finders/projects/members/effective_access_level_finder.rb @@ -99,7 +99,7 @@ module Projects end def include_membership_from_project_group_shares? - project.allowed_to_share_with_group? && project.project_group_links.any? + !project.namespace.share_with_group_lock && project.project_group_links.any? end # methods for `select` options diff --git a/app/finders/projects/topics_finder.rb b/app/finders/projects/topics_finder.rb new file mode 100644 index 00000000000..7c3abc27cf7 --- /dev/null +++ b/app/finders/projects/topics_finder.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Used to filter project topics by a set of params +# +# Arguments: +# params: +# search: string +module Projects + class TopicsFinder + def initialize(params: {}) + @params = params + end + + def execute + topics = Projects::Topic.order_by_total_projects_count + by_search(topics) + end + + private + + attr_reader :current_user, :params + + def by_search(topics) + return topics unless params[:search].present? + + topics.search(params[:search]).reorder_by_similarity(params[:search]) + end + end +end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 5537058cc79..7245bb36ac9 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -182,8 +182,8 @@ class ProjectsFinder < UnionFinder def by_topics(items) return items unless params[:topic].present? - topics = params[:topic].instance_of?(String) ? params[:topic].strip.split(/\s*,\s*/) : params[:topic] - topics.each do |topic| + topics = params[:topic].instance_of?(String) ? params[:topic].split(',') : params[:topic] + topics.map(&:strip).uniq.reject(&:empty?).each do |topic| items = items.with_topic(topic) end diff --git a/app/finders/tags_finder.rb b/app/finders/tags_finder.rb index d9848d027cf..0ccbbdc1b87 100644 --- a/app/finders/tags_finder.rb +++ b/app/finders/tags_finder.rb @@ -7,6 +7,9 @@ class TagsFinder < GitRefsFinder def execute tags = repository.tags_sorted_by(sort) - by_search(tags) + + [by_search(tags), nil] + rescue Gitlab::Git::CommandError => e + [[], e] end end diff --git a/app/graphql/mutations/ci/runner/delete.rb b/app/graphql/mutations/ci/runner/delete.rb index 8d9a5f15505..88dc426398b 100644 --- a/app/graphql/mutations/ci/runner/delete.rb +++ b/app/graphql/mutations/ci/runner/delete.rb @@ -28,7 +28,7 @@ module Mutations def authenticate_delete_runner!(runner) return if current_user.can_admin_all_resources? - "Runner #{runner.to_global_id} associated with more than one project" if runner.projects.count > 1 + "Runner #{runner.to_global_id} associated with more than one project" if runner.runner_projects.count > 1 end def find_object(id) diff --git a/app/graphql/mutations/clusters/agent_tokens/create.rb b/app/graphql/mutations/clusters/agent_tokens/create.rb new file mode 100644 index 00000000000..07bf2536065 --- /dev/null +++ b/app/graphql/mutations/clusters/agent_tokens/create.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Mutations + module Clusters + module AgentTokens + class Create < BaseMutation + graphql_name 'ClusterAgentTokenCreate' + + authorize :create_cluster + + ClusterAgentID = ::Types::GlobalIDType[::Clusters::Agent] + + argument :cluster_agent_id, + ClusterAgentID, + required: true, + description: 'Global ID of the cluster agent that will be associated with the new token.' + + argument :description, + GraphQL::Types::String, + required: false, + description: 'Description of the token.' + + argument :name, + GraphQL::Types::String, + required: true, + description: 'Name of the token.' + + field :secret, + GraphQL::Types::String, + null: true, + description: "Token secret value. Make sure you save it - you won't be able to access it again." + + field :token, + Types::Clusters::AgentTokenType, + null: true, + description: 'Token created after mutation.' + + def resolve(args) + cluster_agent = authorized_find!(id: args[:cluster_agent_id]) + + result = ::Clusters::AgentTokens::CreateService + .new( + container: cluster_agent.project, + current_user: current_user, + params: args.merge(agent_id: cluster_agent.id) + ) + .execute + + payload = result.payload + + { + secret: payload[:secret], + token: payload[:token], + errors: Array.wrap(result.message) + } + end + + private + + def find_object(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ClusterAgentID.coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end + end +end diff --git a/app/graphql/mutations/clusters/agent_tokens/delete.rb b/app/graphql/mutations/clusters/agent_tokens/delete.rb new file mode 100644 index 00000000000..603b6b30910 --- /dev/null +++ b/app/graphql/mutations/clusters/agent_tokens/delete.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Mutations + module Clusters + module AgentTokens + class Delete < BaseMutation + graphql_name 'ClusterAgentTokenDelete' + + authorize :admin_cluster + + TokenID = ::Types::GlobalIDType[::Clusters::AgentToken] + + argument :id, TokenID, + required: true, + description: 'Global ID of the cluster agent token that will be deleted.' + + def resolve(id:) + token = authorized_find!(id: id) + token.destroy + + { errors: errors_on_object(token) } + end + + private + + def find_object(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = TokenID.coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end + end +end diff --git a/app/graphql/mutations/clusters/agents/create.rb b/app/graphql/mutations/clusters/agents/create.rb new file mode 100644 index 00000000000..0896cc7b203 --- /dev/null +++ b/app/graphql/mutations/clusters/agents/create.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module Clusters + module Agents + class Create < BaseMutation + include FindsProject + + authorize :create_cluster + + graphql_name 'CreateClusterAgent' + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Full path of the associated project for this cluster agent.' + + argument :name, GraphQL::Types::String, + required: true, + description: 'Name of the cluster agent.' + + field :cluster_agent, + Types::Clusters::AgentType, + null: true, + description: 'Cluster agent created after mutation.' + + def resolve(project_path:, name:) + project = authorized_find!(project_path) + result = ::Clusters::Agents::CreateService.new(project, current_user).execute(name: name) + + { + cluster_agent: result[:cluster_agent], + errors: Array.wrap(result[:message]) + } + end + end + end + end +end diff --git a/app/graphql/mutations/clusters/agents/delete.rb b/app/graphql/mutations/clusters/agents/delete.rb new file mode 100644 index 00000000000..9ada1f31f60 --- /dev/null +++ b/app/graphql/mutations/clusters/agents/delete.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module Clusters + module Agents + class Delete < BaseMutation + graphql_name 'ClusterAgentDelete' + + authorize :admin_cluster + + AgentID = ::Types::GlobalIDType[::Clusters::Agent] + + argument :id, AgentID, + required: true, + description: 'Global ID of the cluster agent that will be deleted.' + + def resolve(id:) + cluster_agent = authorized_find!(id: id) + result = ::Clusters::Agents::DeleteService + .new(container: cluster_agent.project, current_user: current_user) + .execute(cluster_agent) + + { + errors: Array.wrap(result.message) + } + end + + private + + def find_object(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = AgentID.coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end + end +end diff --git a/app/graphql/mutations/customer_relations/contacts/create.rb b/app/graphql/mutations/customer_relations/contacts/create.rb new file mode 100644 index 00000000000..77b4864468b --- /dev/null +++ b/app/graphql/mutations/customer_relations/contacts/create.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Mutations + module CustomerRelations + module Contacts + class Create < BaseMutation + include ResolvesIds + include Gitlab::Graphql::Authorize::AuthorizeResource + + graphql_name 'CustomerRelationsContactCreate' + + field :contact, + Types::CustomerRelations::ContactType, + null: true, + description: 'Contact after the mutation.' + + argument :group_id, ::Types::GlobalIDType[::Group], + required: true, + description: 'Group for the contact.' + + argument :organization_id, ::Types::GlobalIDType[::CustomerRelations::Organization], + required: false, + description: 'Organization for the contact.' + + argument :first_name, GraphQL::Types::String, + required: true, + description: 'First name of the contact.' + + argument :last_name, GraphQL::Types::String, + required: true, + description: 'Last name of the contact.' + + argument :phone, GraphQL::Types::String, + required: false, + description: 'Phone number of the contact.' + + argument :email, GraphQL::Types::String, + required: false, + description: 'Email address of the contact.' + + argument :description, GraphQL::Types::String, + required: false, + description: 'Description of or notes for the contact.' + + authorize :admin_contact + + def resolve(args) + group = authorized_find!(id: args[:group_id]) + + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml) + + set_organization!(args) + result = ::CustomerRelations::Contacts::CreateService.new(group: group, current_user: current_user, params: args).execute + { contact: result.payload, errors: result.errors } + end + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::Group) + end + + def set_organization!(args) + return unless args[:organization_id] + + args[:organization_id] = resolve_ids(args[:organization_id], ::Types::GlobalIDType[::CustomerRelations::Organization])[0] + end + end + end + end +end diff --git a/app/graphql/mutations/customer_relations/contacts/update.rb b/app/graphql/mutations/customer_relations/contacts/update.rb new file mode 100644 index 00000000000..e9e7c9b6abd --- /dev/null +++ b/app/graphql/mutations/customer_relations/contacts/update.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Mutations + module CustomerRelations + module Contacts + class Update < Mutations::BaseMutation + include ResolvesIds + + graphql_name 'CustomerRelationsContactUpdate' + + authorize :admin_contact + + field :contact, + Types::CustomerRelations::ContactType, + null: true, + description: 'Contact after the mutation.' + + argument :id, ::Types::GlobalIDType[::CustomerRelations::Contact], + required: true, + description: 'Global ID of the contact.' + + argument :organization_id, ::Types::GlobalIDType[::CustomerRelations::Organization], + required: false, + description: 'Organization of the contact.' + + argument :first_name, GraphQL::Types::String, + required: false, + description: 'First name of the contact.' + + argument :last_name, GraphQL::Types::String, + required: false, + description: 'Last name of the contact.' + + argument :phone, GraphQL::Types::String, + required: false, + description: 'Phone number of the contact.' + + argument :email, GraphQL::Types::String, + required: false, + description: 'Email address of the contact.' + + argument :description, GraphQL::Types::String, + required: false, + description: 'Description of or notes for the contact.' + + def resolve(args) + contact = ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(args.delete(:id), expected_type: ::CustomerRelations::Contact)) + raise_resource_not_available_error! unless contact + + group = contact.group + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml) + + authorize!(group) + + result = ::CustomerRelations::Contacts::UpdateService.new(group: group, current_user: current_user, params: args).execute(contact) + { contact: result.payload, errors: result.errors } + end + end + end + end +end diff --git a/app/graphql/mutations/customer_relations/organizations/create.rb b/app/graphql/mutations/customer_relations/organizations/create.rb index 3fa7b0327ca..bb02e1f7346 100644 --- a/app/graphql/mutations/customer_relations/organizations/create.rb +++ b/app/graphql/mutations/customer_relations/organizations/create.rb @@ -31,7 +31,7 @@ module Mutations argument :description, GraphQL::Types::String, required: false, - description: 'Description or notes for the organization.' + description: 'Description of or notes for the organization.' authorize :admin_organization diff --git a/app/graphql/mutations/customer_relations/organizations/update.rb b/app/graphql/mutations/customer_relations/organizations/update.rb index c6ae62193f9..d8eb55d77e9 100644 --- a/app/graphql/mutations/customer_relations/organizations/update.rb +++ b/app/graphql/mutations/customer_relations/organizations/update.rb @@ -32,7 +32,7 @@ module Mutations argument :description, GraphQL::Types::String, required: false, - description: 'Description or notes for the organization.' + description: 'Description of or notes for the organization.' def resolve(args) organization = ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(args.delete(:id), expected_type: ::CustomerRelations::Organization)) diff --git a/app/graphql/mutations/dependency_proxy/group_settings/update.rb b/app/graphql/mutations/dependency_proxy/group_settings/update.rb new file mode 100644 index 00000000000..d10e43cde29 --- /dev/null +++ b/app/graphql/mutations/dependency_proxy/group_settings/update.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Mutations + module DependencyProxy + module GroupSettings + class Update < Mutations::BaseMutation + include Mutations::ResolvesGroup + + graphql_name 'UpdateDependencyProxySettings' + + authorize :admin_dependency_proxy + + argument :group_path, + GraphQL::Types::ID, + required: true, + description: 'Group path for the group dependency proxy.' + + argument :enabled, + GraphQL::Types::Boolean, + required: false, + description: copy_field_description(Types::DependencyProxy::ImageTtlGroupPolicyType, :enabled) + + field :dependency_proxy_setting, + Types::DependencyProxy::GroupSettingType, + null: true, + description: 'Group dependency proxy settings after mutation.' + + def resolve(group_path:, **args) + group = authorized_find!(group_path: group_path) + + result = ::DependencyProxy::GroupSettings::UpdateService + .new(container: group, current_user: current_user, params: args) + .execute + + { + dependency_proxy_setting: result.payload[:dependency_proxy_setting], + errors: result.errors + } + end + + private + + def find_object(group_path:) + resolve_group(full_path: group_path) + end + end + end + end +end diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb index 32f96f1bfe6..70a8f539ccf 100644 --- a/app/graphql/mutations/issues/create.rb +++ b/app/graphql/mutations/issues/create.rb @@ -71,7 +71,7 @@ module Mutations def resolve(project_path:, **attributes) project = authorized_find!(project_path) - params = build_create_issue_params(attributes.merge(author_id: current_user.id)) + params = build_create_issue_params(attributes.merge(author_id: current_user.id), project) spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) issue = ::Issues::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute @@ -88,7 +88,8 @@ module Mutations private - def build_create_issue_params(params) + # _project argument is unused here, but it is necessary on the EE version of the method + def build_create_issue_params(params, _project) params[:milestone_id] &&= params[:milestone_id]&.model_id params[:assignee_ids] &&= params[:assignee_ids].map { |assignee_id| assignee_id&.model_id } params[:label_ids] &&= params[:label_ids].map { |label_id| label_id&.model_id } diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb index 7c85dd8fb9b..d70acdf7ca0 100644 --- a/app/graphql/resolvers/board_list_issues_resolver.rb +++ b/app/graphql/resolvers/board_list_issues_resolver.rb @@ -18,11 +18,8 @@ module Resolvers filter_params = filters.merge(board_id: list.board.id, id: list.id) service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params) - pagination_connections = Gitlab::Graphql::Pagination::Keyset::Connection.new(service.execute) - ::Boards::Issues::ListService.initialize_relative_positions(list.board, current_user, pagination_connections.items) - - pagination_connections + service.execute end # https://gitlab.com/gitlab-org/gitlab/-/issues/235681 diff --git a/app/graphql/resolvers/board_list_resolver.rb b/app/graphql/resolvers/board_list_resolver.rb new file mode 100644 index 00000000000..d853846b674 --- /dev/null +++ b/app/graphql/resolvers/board_list_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Resolvers + class BoardListResolver < BaseResolver.single + include Gitlab::Graphql::Authorize::AuthorizeResource + include BoardItemFilterable + + type Types::BoardListType, null: true + description 'Find an issue board list.' + + authorize :read_issue_board_list + + argument :id, Types::GlobalIDType[List], + required: true, + description: 'Global ID of the list.' + + argument :issue_filters, Types::Boards::BoardIssueInputType, + required: false, + description: 'Filters applied when getting issue metadata in the board list.' + + def resolve(id: nil, issue_filters: {}) + context.scoped_set!(:issue_filters, item_filters(issue_filters)) + + Gitlab::Graphql::Lazy.with_value(find_list(id: id)) do |list| + list if authorized_resource?(list) + end + end + + private + + def find_list(id:) + GitlabSchema.object_from_id(id, expected_type: ::List) + end + end +end diff --git a/app/graphql/resolvers/clusters/agent_tokens_resolver.rb b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb new file mode 100644 index 00000000000..5ae19700fd5 --- /dev/null +++ b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module Clusters + class AgentTokensResolver < BaseResolver + type Types::Clusters::AgentTokenType, null: true + + alias_method :agent, :object + + delegate :project, to: :agent + + def resolve(**args) + return ::Clusters::AgentToken.none unless can_read_agent_tokens? + + agent.last_used_agent_tokens + end + + private + + def can_read_agent_tokens? + current_user.can?(:admin_cluster, project) + end + end + end +end diff --git a/app/graphql/resolvers/clusters/agents_resolver.rb b/app/graphql/resolvers/clusters/agents_resolver.rb new file mode 100644 index 00000000000..9b8cea52e3b --- /dev/null +++ b/app/graphql/resolvers/clusters/agents_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Resolvers + module Clusters + class AgentsResolver < BaseResolver + include LooksAhead + + type Types::Clusters::AgentType.connection_type, null: true + + extras [:lookahead] + + when_single do + argument :name, GraphQL::Types::String, + required: true, + description: 'Name of the cluster agent.' + end + + alias_method :project, :object + + def resolve_with_lookahead(**args) + apply_lookahead( + ::Clusters::AgentsFinder + .new(project, current_user, params: args) + .execute + ) + end + + private + + def preloads + { tokens: :last_used_agent_tokens } + end + end + end +end diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb index 9de36b5b7d1..855877110e5 100644 --- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb +++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb @@ -4,6 +4,7 @@ module IssueResolverArguments extend ActiveSupport::Concern prepended do + include SearchArguments include LooksAhead argument :iid, GraphQL::Types::String, @@ -49,9 +50,6 @@ module IssueResolverArguments argument :closed_after, Types::TimeType, required: false, description: 'Issues closed after this date.' - argument :search, GraphQL::Types::String, - required: false, - description: 'Search query for issue title or description.' argument :types, [Types::IssueTypeEnum], as: :issue_types, description: 'Filter issues by the given issue types.', @@ -62,6 +60,10 @@ module IssueResolverArguments argument :my_reaction_emoji, GraphQL::Types::String, required: false, description: 'Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported.' + argument :confidential, + GraphQL::Types::Boolean, + required: false, + description: 'Filter for confidential issues. If "false", excludes confidential issues. If "true", returns only confidential issues.' argument :not, Types::Issues::NegatedIssueFilterInputType, description: 'Negated arguments.', prepare: ->(negated_args, ctx) { negated_args.to_h }, @@ -91,6 +93,7 @@ module IssueResolverArguments params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args) params_not_mutually_exclusive(args, mutually_exclusive_milestone_args) params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args) + validate_anonymous_search_access! if args[:search].present? super end diff --git a/app/graphql/resolvers/concerns/search_arguments.rb b/app/graphql/resolvers/concerns/search_arguments.rb new file mode 100644 index 00000000000..7f480f9d0b6 --- /dev/null +++ b/app/graphql/resolvers/concerns/search_arguments.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module SearchArguments + extend ActiveSupport::Concern + + included do + argument :search, GraphQL::Types::String, + required: false, + description: 'Search query for title or description.' + end + + def validate_anonymous_search_access! + return if current_user.present? || Feature.disabled?(:disable_anonymous_search, type: :ops) + + raise ::Gitlab::Graphql::Errors::ArgumentError, + "User must be authenticated to include the `search` argument." + end +end diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 47e4e3c0b32..b556964ae0c 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -47,7 +47,8 @@ module Resolvers alert_management_alert: [:alert_management_alert], labels: [:labels], assignees: [:assignees], - timelogs: [:timelogs] + timelogs: [:timelogs], + customer_relations_contacts: { customer_relations_contacts: [:group] } } end diff --git a/app/graphql/resolvers/kas/agent_configurations_resolver.rb b/app/graphql/resolvers/kas/agent_configurations_resolver.rb new file mode 100644 index 00000000000..238dae0bf12 --- /dev/null +++ b/app/graphql/resolvers/kas/agent_configurations_resolver.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Resolvers + module Kas + class AgentConfigurationsResolver < BaseResolver + type Types::Kas::AgentConfigurationType, null: true + + # Calls Gitaly via KAS + calls_gitaly! + + alias_method :project, :object + + def resolve + return [] unless can_read_agent_configuration? + + kas_client.list_agent_config_files(project: project) + rescue GRPC::BadStatus => e + raise Gitlab::Graphql::Errors::ResourceNotAvailable, e.class.name + end + + private + + def can_read_agent_configuration? + current_user.can?(:admin_cluster, project) + end + + def kas_client + @kas_client ||= Gitlab::Kas::Client.new + end + end + end +end diff --git a/app/graphql/resolvers/kas/agent_connections_resolver.rb b/app/graphql/resolvers/kas/agent_connections_resolver.rb new file mode 100644 index 00000000000..8b7c4003598 --- /dev/null +++ b/app/graphql/resolvers/kas/agent_connections_resolver.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Resolvers + module Kas + class AgentConnectionsResolver < BaseResolver + type Types::Kas::AgentConnectionType, null: true + + alias_method :agent, :object + + delegate :project, to: :agent + + def resolve + return [] unless can_read_connected_agents? + + BatchLoader::GraphQL.for(agent.id).batch(key: project, default_value: []) do |agent_ids, loader| + agents = get_connected_agents.group_by(&:agent_id).slice(*agent_ids) + + agents.each do |agent_id, connections| + loader.call(agent_id, connections) + end + end + end + + private + + def can_read_connected_agents? + current_user.can?(:admin_cluster, project) + end + + def get_connected_agents + kas_client.get_connected_agents(project: project) + rescue GRPC::BadStatus => e + raise Gitlab::Graphql::Errors::ResourceNotAvailable, e.class.name + end + + def kas_client + @kas_client ||= Gitlab::Kas::Client.new + end + end + end +end diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb index ce4b6ac6b0c..5acd7f95606 100644 --- a/app/graphql/resolvers/project_pipeline_resolver.rb +++ b/app/graphql/resolvers/project_pipeline_resolver.rb @@ -2,6 +2,8 @@ module Resolvers class ProjectPipelineResolver < BaseResolver + include LooksAhead + type ::Types::Ci::PipelineType, null: true alias_method :project, :object @@ -14,7 +16,7 @@ module Resolvers required: false, description: 'SHA of the Pipeline. For example, "dyd0f15ay83993f5ab66k927w28673882x99100b".' - def ready?(iid: nil, sha: nil) + def ready?(iid: nil, sha: nil, **args) unless iid.present? ^ sha.present? raise Gitlab::Graphql::Errors::ArgumentError, 'Provide one of an IID or SHA' end @@ -22,18 +24,21 @@ module Resolvers super end - def resolve(iid: nil, sha: nil) + # the preloads are defined on ee/app/graphql/ee/resolvers/project_pipeline_resolver.rb + def resolve(iid: nil, sha: nil, **args) + self.lookahead = args.delete(:lookahead) + if iid - BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader, args| + BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader| finder = ::Ci::PipelinesFinder.new(project, current_user, iids: iids) - finder.execute.each { |pipeline| loader.call(pipeline.iid.to_s, pipeline) } + apply_lookahead(finder.execute).each { |pipeline| loader.call(pipeline.iid.to_s, pipeline) } end else - BatchLoader::GraphQL.for(sha).batch(key: project) do |shas, loader, args| + BatchLoader::GraphQL.for(sha).batch(key: project) do |shas, loader| finder = ::Ci::PipelinesFinder.new(project, current_user, sha: shas) - finder.execute.each { |pipeline| loader.call(pipeline.sha.to_s, pipeline) } + apply_lookahead(finder.execute).each { |pipeline| loader.call(pipeline.sha.to_s, pipeline) } end end end diff --git a/app/graphql/resolvers/project_pipelines_resolver.rb b/app/graphql/resolvers/project_pipelines_resolver.rb index 0171473a77f..5a1e92efc96 100644 --- a/app/graphql/resolvers/project_pipelines_resolver.rb +++ b/app/graphql/resolvers/project_pipelines_resolver.rb @@ -26,3 +26,5 @@ module Resolvers end end # rubocop: enable Graphql/ResolverType + +Resolvers::ProjectPipelinesResolver.prepend_mod diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 9c27f0f8138..93e17ea6dfc 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -9,7 +9,6 @@ module Types DEFAULT_COMPLEXITY = 1 attr_reader :deprecation, :doc_reference - attr_writer :max_page_size # Can be removed with :performance_roadmap feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/337198 def initialize(**kwargs, &block) @calls_gitaly = !!kwargs.delete(:calls_gitaly) @@ -21,6 +20,7 @@ module Types @feature_flag = kwargs[:feature_flag] kwargs = check_feature_flag(kwargs) @deprecation = gitlab_deprecation(kwargs) + after_connection_extensions = kwargs.delete(:late_extensions) || [] super(**kwargs, &block) @@ -28,6 +28,8 @@ module Types extension ::Gitlab::Graphql::CallsGitaly::FieldExtension if Gitlab.dev_or_test_env? extension ::Gitlab::Graphql::Present::FieldExtension extension ::Gitlab::Graphql::Authorize::ConnectionFilterExtension + + after_connection_extensions.each { extension _1 } if after_connection_extensions.any? end def may_call_gitaly? diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb index 762e03973d9..8c67803e39e 100644 --- a/app/graphql/types/board_list_type.rb +++ b/app/graphql/types/board_list_type.rb @@ -10,8 +10,10 @@ module Types alias_method :list, :object - field :id, GraphQL::Types::ID, null: false, + field :id, GraphQL::Types::ID, + null: false, description: 'ID (global ID) of the list.' + field :title, GraphQL::Types::String, null: false, description: 'Title of the list.' field :list_type, GraphQL::Types::String, null: false, @@ -27,6 +29,7 @@ module Types field :issues, ::Types::IssueType.connection_type, null: true, description: 'Board issues.', + late_extensions: [Gitlab::Graphql::Board::IssuesConnectionExtension], resolver: ::Resolvers::BoardListIssuesResolver def issues_count @@ -46,6 +49,16 @@ module Types .metadata end end + + # board lists have a data dependency on label - so we batch load them here + def title + BatchLoader::GraphQL.for(object).batch do |lists, callback| + ActiveRecord::Associations::Preloader.new.preload(lists, :label) # rubocop: disable CodeReuse/ActiveRecord + + # all list titles are preloaded at this point + lists.each { |list| callback.call(list, list.title) } + end + end end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/runner_status_enum.rb b/app/graphql/types/ci/runner_status_enum.rb index ad69175e44a..8501ce20204 100644 --- a/app/graphql/types/ci/runner_status_enum.rb +++ b/app/graphql/types/ci/runner_status_enum.rb @@ -6,8 +6,21 @@ module Types graphql_name 'CiRunnerStatus' ::Ci::Runner::AVAILABLE_STATUSES.each do |status| + description = case status + when 'active' + "A runner that is not paused." + when 'online' + "A runner that contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}." + when 'offline' + "A runner that has not contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}." + when 'not_connected' + "A runner that has never contacted this instance." + else + "A runner that is #{status.to_s.tr('_', ' ')}." + end + value status.to_s.upcase, - description: "A runner that is #{status.to_s.tr('_', ' ')}.", + description: description, value: status.to_sym end end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index e2c8070af0c..9bf98aa7e86 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -3,10 +3,13 @@ module Types module Ci class RunnerType < BaseObject + edge_type_class(RunnerWebUrlEdge) graphql_name 'CiRunner' authorize :read_runner present_using ::Ci::RunnerPresenter + expose_permissions Types::PermissionTypes::Ci::Runner + JOB_COUNT_LIMIT = 1000 alias_method :runner, :object @@ -46,12 +49,18 @@ module Types description: 'Number of projects that the runner is associated with.' field :job_count, GraphQL::Types::Int, null: true, description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)." + field :admin_url, GraphQL::Types::String, null: true, + description: 'Admin URL of the runner. Only available for adminstrators.' def job_count # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT runner.builds.limit(JOB_COUNT_LIMIT + 1).count end + def admin_url + Gitlab::Routing.url_helpers.admin_runner_url(runner) if can_admin_runners? + end + # rubocop: disable CodeReuse/ActiveRecord def project_count BatchLoader::GraphQL.for(runner.id).batch(key: :runner_project_count) do |ids, loader, args| @@ -68,6 +77,12 @@ module Types end end # rubocop: enable CodeReuse/ActiveRecord + + private + + def can_admin_runners? + context[:current_user]&.can_admin_all_resources? + end end end end diff --git a/app/graphql/types/ci/runner_web_url_edge.rb b/app/graphql/types/ci/runner_web_url_edge.rb new file mode 100644 index 00000000000..3b9fdfd1571 --- /dev/null +++ b/app/graphql/types/ci/runner_web_url_edge.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class RunnerWebUrlEdge < GraphQL::Types::Relay::BaseEdge + include FindClosest + + field :web_url, GraphQL::Types::String, null: true, + description: 'Web URL of the runner. The value depends on where you put this field in the query. You can use it for projects or groups.', + extras: [:parent] + + def initialize(node, connection) + super + + @runner = node.node + end + + def web_url(parent:) + owner = closest_parent([::Types::ProjectType, ::Types::GroupType], parent) + + case owner + when ::Group + Gitlab::Routing.url_helpers.group_runner_url(owner, @runner) + when ::Project + Gitlab::Routing.url_helpers.project_runner_url(owner, @runner) + end + end + end + end +end diff --git a/app/graphql/types/clusters/agent_token_type.rb b/app/graphql/types/clusters/agent_token_type.rb new file mode 100644 index 00000000000..94c5fc46a5d --- /dev/null +++ b/app/graphql/types/clusters/agent_token_type.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Types + module Clusters + class AgentTokenType < BaseObject + graphql_name 'ClusterAgentToken' + + authorize :admin_cluster + + connection_type_class(Types::CountableConnectionType) + + field :cluster_agent, + Types::Clusters::AgentType, + description: 'Cluster agent this token is associated with.', + null: true + + field :created_at, + Types::TimeType, + null: true, + description: 'Timestamp the token was created.' + + field :created_by_user, + Types::UserType, + null: true, + description: 'User who created the token.' + + field :description, + GraphQL::Types::String, + null: true, + description: 'Description of the token.' + + field :last_used_at, + Types::TimeType, + null: true, + description: 'Timestamp the token was last used.' + + field :id, + ::Types::GlobalIDType[::Clusters::AgentToken], + null: false, + description: 'Global ID of the token.' + + field :name, + GraphQL::Types::String, + null: true, + description: 'Name given to the token.' + + def cluster_agent + Gitlab::Graphql::Loaders::BatchModelLoader.new(::Clusters::Agent, object.agent_id).find + end + end + end +end diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb new file mode 100644 index 00000000000..ce748f6e8ae --- /dev/null +++ b/app/graphql/types/clusters/agent_type.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Types + module Clusters + class AgentType < BaseObject + graphql_name 'ClusterAgent' + + authorize :admin_cluster + + connection_type_class(Types::CountableConnectionType) + + field :created_at, + Types::TimeType, + null: true, + description: 'Timestamp the cluster agent was created.' + + field :created_by_user, + Types::UserType, + null: true, + description: 'User object, containing information about the person who created the agent.' + + field :id, GraphQL::Types::ID, + null: false, + description: 'ID of the cluster agent.' + + field :name, + GraphQL::Types::String, + null: true, + description: 'Name of the cluster agent.' + + field :project, Types::ProjectType, + description: 'Project this cluster agent is associated with.', + null: true, + authorize: :read_project + + field :tokens, Types::Clusters::AgentTokenType.connection_type, + description: 'Tokens associated with the cluster agent.', + null: true, + resolver: ::Resolvers::Clusters::AgentTokensResolver + + field :updated_at, + Types::TimeType, + null: true, + description: 'Timestamp the cluster agent was updated.' + + field :web_path, + GraphQL::Types::String, + null: true, + description: 'Web path of the cluster agent.' + + field :connections, + Types::Kas::AgentConnectionType.connection_type, + null: true, + description: 'Active connections for the cluster agent', + complexity: 5, + resolver: ::Resolvers::Kas::AgentConnectionsResolver + + def project + Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find + end + + def web_path + ::Gitlab::Routing.url_helpers.project_cluster_agent_path(object.project, object.name) + end + end + end +end diff --git a/app/graphql/types/concerns/find_closest.rb b/app/graphql/types/concerns/find_closest.rb index 1d76e872364..3064db19ea0 100644 --- a/app/graphql/types/concerns/find_closest.rb +++ b/app/graphql/types/concerns/find_closest.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true module FindClosest - # Find the closest node of a given type above this node, and return the domain object - def closest_parent(type, parent) - parent = parent.try(:parent) while parent && parent.object.class != type - return unless parent + # Find the closest node which has any of the given types above this node, and return the domain object + def closest_parent(types, parent) + while parent - parent.object.object + if types.any? {|type| parent.object.instance_of? type} + return parent.object.object + else + parent = parent.try(:parent) + end + end end end diff --git a/app/graphql/types/container_expiration_policy_older_than_enum.rb b/app/graphql/types/container_expiration_policy_older_than_enum.rb index 7364910f8cd..9c32d767caf 100644 --- a/app/graphql/types/container_expiration_policy_older_than_enum.rb +++ b/app/graphql/types/container_expiration_policy_older_than_enum.rb @@ -6,6 +6,7 @@ module Types '7d': 'SEVEN_DAYS', '14d': 'FOURTEEN_DAYS', '30d': 'THIRTY_DAYS', + '60d': 'SIXTY_DAYS', '90d': 'NINETY_DAYS' }.freeze diff --git a/app/graphql/types/container_repository_details_type.rb b/app/graphql/types/container_repository_details_type.rb index 1a9f57e701f..8190cc9bc25 100644 --- a/app/graphql/types/container_repository_details_type.rb +++ b/app/graphql/types/container_repository_details_type.rb @@ -17,5 +17,11 @@ module Types def can_delete Ability.allowed?(current_user, :destroy_container_image, object) end + + def tags + object.tags + rescue Faraday::Error + raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'We are having trouble connecting to the Container Registry. If this error persists, please review the troubleshooting documentation.' + end end end diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb index 67093f57862..1fe5cf112f0 100644 --- a/app/graphql/types/container_repository_type.rb +++ b/app/graphql/types/container_repository_type.rb @@ -28,5 +28,11 @@ module Types def project Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find end + + def tags_count + object.tags_count + rescue Faraday::Error + raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'We are having trouble connecting to the Container Registry. If this error persists, please review the troubleshooting documentation.' + end end end diff --git a/app/graphql/types/customer_relations/contact_type.rb b/app/graphql/types/customer_relations/contact_type.rb index 35b5bf45698..b5224a3e239 100644 --- a/app/graphql/types/customer_relations/contact_type.rb +++ b/app/graphql/types/customer_relations/contact_type.rb @@ -39,7 +39,7 @@ module Types field :description, GraphQL::Types::String, null: true, - description: 'Description or notes for the contact.' + description: 'Description of or notes for the contact.' field :created_at, Types::TimeType, diff --git a/app/graphql/types/customer_relations/organization_type.rb b/app/graphql/types/customer_relations/organization_type.rb index 0e091d4a9a3..9b22fa35b11 100644 --- a/app/graphql/types/customer_relations/organization_type.rb +++ b/app/graphql/types/customer_relations/organization_type.rb @@ -25,7 +25,7 @@ module Types field :description, GraphQL::Types::String, null: true, - description: 'Description or notes for the organization.' + description: 'Description of or notes for the organization.' field :created_at, Types::TimeType, diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb index 79e789d3f8b..826ae61a1a3 100644 --- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb +++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb @@ -13,6 +13,9 @@ module Types field :id, GraphQL::Types::ID, null: false, description: 'ID (global ID) of the error.' + field :integrated, GraphQL::Types::Boolean, + null: true, + description: 'Error tracking backend.' field :sentry_id, GraphQL::Types::String, method: :id, null: false, diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 8fe4ba557ea..b1bbabcdaed 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -234,6 +234,10 @@ module Types ) end + def dependency_proxy_setting + group.dependency_proxy_setting || group.create_dependency_proxy_setting + end + private def group diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index c8db2b84ff2..3b0f93d8dc1 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -136,6 +136,9 @@ module Types field :project_id, GraphQL::Types::Int, null: false, method: :project_id, description: 'ID of the issue project.' + field :customer_relations_contacts, Types::CustomerRelations::ContactType.connection_type, null: true, + description: 'Customer relations contacts of the issue.' + def author Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find end diff --git a/app/graphql/types/issues/negated_issue_filter_input_type.rb b/app/graphql/types/issues/negated_issue_filter_input_type.rb index 4f620a5b3d9..c8b7cdaa68e 100644 --- a/app/graphql/types/issues/negated_issue_filter_input_type.rb +++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb @@ -29,6 +29,10 @@ module Types argument :my_reaction_emoji, GraphQL::Types::String, required: false, description: 'Filter by reaction emoji applied by the current user.' + argument :types, [Types::IssueTypeEnum], + as: :issue_types, + description: 'Filters out issues by the given issue types.', + required: false end end end diff --git a/app/graphql/types/kas/agent_configuration_type.rb b/app/graphql/types/kas/agent_configuration_type.rb new file mode 100644 index 00000000000..397a5739671 --- /dev/null +++ b/app/graphql/types/kas/agent_configuration_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Kas + # rubocop: disable Graphql/AuthorizeTypes + class AgentConfigurationType < BaseObject + graphql_name 'AgentConfiguration' + description 'Configuration details for an Agent' + + field :agent_name, + GraphQL::Types::String, + null: true, + description: 'Name of the agent.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/kas/agent_connection_type.rb b/app/graphql/types/kas/agent_connection_type.rb new file mode 100644 index 00000000000..9c6321bece9 --- /dev/null +++ b/app/graphql/types/kas/agent_connection_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module Kas + # rubocop: disable Graphql/AuthorizeTypes + class AgentConnectionType < BaseObject + graphql_name 'ConnectedAgent' + description 'Connection details for an Agent' + + field :connected_at, + Types::TimeType, + null: true, + description: 'When the connection was established.' + + field :connection_id, + GraphQL::Types::BigInt, + null: true, + description: 'ID of the connection.' + + field :metadata, + Types::Kas::AgentMetadataType, + method: :agent_meta, + null: true, + description: 'Information about the Agent.' + + def connected_at + Time.at(object.connected_at.seconds) + end + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/kas/agent_metadata_type.rb b/app/graphql/types/kas/agent_metadata_type.rb new file mode 100644 index 00000000000..4a3bb09b9e1 --- /dev/null +++ b/app/graphql/types/kas/agent_metadata_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Types + module Kas + # rubocop: disable Graphql/AuthorizeTypes + class AgentMetadataType < BaseObject + graphql_name 'AgentMetadata' + description 'Information about a connected Agent' + + field :version, + GraphQL::Types::String, + null: true, + description: 'Agent version tag.' + + field :commit, + GraphQL::Types::String, + method: :commit_id, + null: true, + description: 'Agent version commit.' + + field :pod_namespace, + GraphQL::Types::String, + null: true, + description: 'Namespace of the pod running the Agent.' + + field :pod_name, + GraphQL::Types::String, + null: true, + description: 'Name of the pod running the Agent.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/merge_requests/interacts_with_merge_request.rb b/app/graphql/types/merge_requests/interacts_with_merge_request.rb index d685ac4d3c9..d4a1f2faa8d 100644 --- a/app/graphql/types/merge_requests/interacts_with_merge_request.rb +++ b/app/graphql/types/merge_requests/interacts_with_merge_request.rb @@ -14,7 +14,7 @@ module Types end def merge_request_interaction(parent:) - merge_request = closest_parent(::Types::MergeRequestType, parent) + merge_request = closest_parent([::Types::MergeRequestType], parent) return unless merge_request Users::MergeRequestInteraction.new(user: object, merge_request: merge_request) diff --git a/app/graphql/types/milestone_wildcard_id_enum.rb b/app/graphql/types/milestone_wildcard_id_enum.rb index 12e8e07fb05..ad9651a26dc 100644 --- a/app/graphql/types/milestone_wildcard_id_enum.rb +++ b/app/graphql/types/milestone_wildcard_id_enum.rb @@ -8,6 +8,6 @@ module Types value 'NONE', 'No milestone is assigned.' value 'ANY', 'Milestone is assigned.' value 'STARTED', 'Milestone assigned is open and started (start date <= today).' - value 'UPCOMING', 'Milestone assigned is due closest in the future (due date > today).' + value 'UPCOMING', 'Milestone assigned is due in the future (due date > today).' end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index ea50af1c554..cd4c45d2942 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -31,13 +31,20 @@ module Types mount_mutation Mutations::Boards::Lists::Update mount_mutation Mutations::Boards::Lists::Destroy mount_mutation Mutations::Branches::Create, calls_gitaly: true + mount_mutation Mutations::Clusters::Agents::Create + mount_mutation Mutations::Clusters::Agents::Delete + mount_mutation Mutations::Clusters::AgentTokens::Create + mount_mutation Mutations::Clusters::AgentTokens::Delete mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji + mount_mutation Mutations::CustomerRelations::Contacts::Create + mount_mutation Mutations::CustomerRelations::Contacts::Update mount_mutation Mutations::CustomerRelations::Organizations::Create mount_mutation Mutations::CustomerRelations::Organizations::Update mount_mutation Mutations::Discussions::ToggleResolve mount_mutation Mutations::DependencyProxy::ImageTtlGroupPolicy::Update + mount_mutation Mutations::DependencyProxy::GroupSettings::Update mount_mutation Mutations::Environments::CanaryIngress::Update mount_mutation Mutations::Issues::Create mount_mutation Mutations::Issues::SetAssignees diff --git a/app/graphql/types/packages/nuget/metadatum_type.rb b/app/graphql/types/packages/nuget/metadatum_type.rb index ed9d97724af..b58fd954a74 100644 --- a/app/graphql/types/packages/nuget/metadatum_type.rb +++ b/app/graphql/types/packages/nuget/metadatum_type.rb @@ -10,9 +10,9 @@ module Types authorize :read_package field :id, ::Types::GlobalIDType[::Packages::Nuget::Metadatum], null: false, description: 'ID of the metadatum.' - field :license_url, GraphQL::Types::String, null: false, description: 'License URL of the Nuget package.' - field :project_url, GraphQL::Types::String, null: false, description: 'Project URL of the Nuget package.' - field :icon_url, GraphQL::Types::String, null: false, description: 'Icon URL of the Nuget package.' + field :license_url, GraphQL::Types::String, null: true, description: 'License URL of the Nuget package.' + field :project_url, GraphQL::Types::String, null: true, description: 'Project URL of the Nuget package.' + field :icon_url, GraphQL::Types::String, null: true, description: 'Icon URL of the Nuget package.' end end end diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb index f3fa79cc08c..9851c6aec7e 100644 --- a/app/graphql/types/packages/package_type.rb +++ b/app/graphql/types/packages/package_type.rb @@ -6,6 +6,8 @@ module Types graphql_name 'Package' description 'Represents a package in the Package Registry. Note that this type is in beta and susceptible to changes' + connection_type_class(Types::CountableConnectionType) + authorize :read_package field :id, ::Types::GlobalIDType[::Packages::Package], null: false, @@ -26,6 +28,7 @@ module Types description: 'Other versions of the package.', deprecated: { reason: 'This field is now only returned in the PackageDetailsType', milestone: '13.11' } field :status, Types::Packages::PackageStatusEnum, null: false, description: 'Package status.' + field :can_destroy, GraphQL::Types::Boolean, null: false, description: 'Whether the user can destroy the package.' def project Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find @@ -35,6 +38,10 @@ module Types [] end + def can_destroy + Ability.allowed?(current_user, :destroy_package, object) + end + # NOTE: This method must be kept in sync with the union # type: `Types::Packages::MetadataType`. # diff --git a/app/graphql/types/permission_types/ci/runner.rb b/app/graphql/types/permission_types/ci/runner.rb new file mode 100644 index 00000000000..2e92a4011e9 --- /dev/null +++ b/app/graphql/types/permission_types/ci/runner.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + module Ci + class Runner < BasePermissionType + graphql_name 'RunnerPermissions' + + abilities :read_runner, :update_runner, :delete_runner + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index aef46a05a2f..791875242df 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -208,6 +208,7 @@ module Types Types::Ci::PipelineType, null: true, description: 'Build pipeline of the project.', + extras: [:lookahead], resolver: Resolvers::ProjectPipelineResolver field :ci_cd_settings, @@ -361,6 +362,25 @@ module Types complexity: 5, resolver: ::Resolvers::TimelogResolver + field :agent_configurations, + ::Types::Kas::AgentConfigurationType.connection_type, + null: true, + description: 'Agent configurations defined by the project', + resolver: ::Resolvers::Kas::AgentConfigurationsResolver + + field :cluster_agent, + ::Types::Clusters::AgentType, + null: true, + description: 'Find a single cluster agent by name.', + resolver: ::Resolvers::Clusters::AgentsResolver.single + + field :cluster_agents, + ::Types::Clusters::AgentType.connection_type, + extras: [:lookahead], + null: true, + description: 'Cluster agents associated with the project.', + resolver: ::Resolvers::Clusters::AgentsResolver + def label(title:) BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| LabelsFinder diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index e02191fbf3e..ed4ddbb982b 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -136,6 +136,10 @@ module Types complexity: 5, resolver: ::Resolvers::TimelogResolver + field :board_list, ::Types::BoardListType, + null: true, + resolver: Resolvers::BoardListResolver + def design_management DesignManagementObject.new(nil) end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index cf15433f2e5..2103a37180f 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -333,6 +333,9 @@ module ApplicationSettingsHelper :throttle_authenticated_files_api_enabled, :throttle_authenticated_files_api_period_in_seconds, :throttle_authenticated_files_api_requests_per_period, + :throttle_authenticated_deprecated_api_enabled, + :throttle_authenticated_deprecated_api_period_in_seconds, + :throttle_authenticated_deprecated_api_requests_per_period, :throttle_unauthenticated_api_enabled, :throttle_unauthenticated_api_period_in_seconds, :throttle_unauthenticated_api_requests_per_period, @@ -345,6 +348,9 @@ module ApplicationSettingsHelper :throttle_unauthenticated_files_api_enabled, :throttle_unauthenticated_files_api_period_in_seconds, :throttle_unauthenticated_files_api_requests_per_period, + :throttle_unauthenticated_deprecated_api_enabled, + :throttle_unauthenticated_deprecated_api_period_in_seconds, + :throttle_unauthenticated_deprecated_api_requests_per_period, :throttle_protected_paths_enabled, :throttle_protected_paths_period_in_seconds, :throttle_protected_paths_requests_per_period, @@ -400,7 +406,8 @@ module ApplicationSettingsHelper :user_deactivation_emails_enabled, :sidekiq_job_limiter_mode, :sidekiq_job_limiter_compression_threshold_bytes, - :sidekiq_job_limiter_limit_bytes + :sidekiq_job_limiter_limit_bytes, + :suggest_pipeline_enabled ].tap do |settings| settings << :deactivate_dormant_users unless Gitlab.com? end @@ -464,10 +471,6 @@ module ApplicationSettingsHelper } end - def show_documentation_base_url_field? - Feature.enabled?(:help_page_documentation_redirect) - end - def valid_runner_registrars Gitlab::CurrentSettings.valid_runner_registrars end @@ -477,8 +480,6 @@ module ApplicationSettingsHelper end def pending_user_count - return 0 if Gitlab::CurrentSettings.new_user_signups_cap.blank? - User.blocked_pending_approval.count end end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 4cfa1528d9b..dd852a68682 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -9,6 +9,10 @@ module AvatarsHelper source_icon(group, options) end + def topic_icon(topic, options = {}) + source_icon(topic, options) + end + # Takes both user and email and returns the avatar_icon by # user (preferred) or email. def avatar_icon_for(user = nil, email = nil, size = nil, scale = 2, only_path: true) diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index 882302f05ad..d02fe3f20b0 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -7,7 +7,7 @@ module Ci "endpoint" => project_job_path(@project, @build, format: :json), "project_path" => @project.full_path, "artifact_help_url" => help_page_path('user/gitlab_com/index.html', anchor: 'gitlab-cicd'), - "deployment_help_url" => help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting'), + "deployment_help_url" => help_page_path('user/project/clusters/deploy_to_cluster.html', anchor: 'troubleshooting'), "runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'), "page_path" => project_job_path(@project, @build), "build_status" => @build.status, diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index c9231a4eff3..ec10610714b 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -77,7 +77,7 @@ module Ci def toggle_shared_runners_settings_data(project) { is_enabled: "#{project.shared_runners_enabled?}", - is_disabled_and_unoverridable: "#{project.group&.shared_runners_setting == 'disabled_and_unoverridable'}", + is_disabled_and_unoverridable: "#{project.group&.shared_runners_setting == Namespace::SR_DISABLED_AND_UNOVERRIDABLE}", update_path: toggle_shared_runners_project_runners_path(project) } end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 53017beee85..ee5f4bb364a 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -17,6 +17,15 @@ module CommitsHelper commit_person_link(commit, options.merge(source: :committer)) end + def commit_committer_avatar(committer, options = {}) + user_avatar(options.merge({ + user: committer, + user_name: committer.name, + user_email: committer.email, + css_class: 'd-none d-sm-inline-block float-none gl-mr-0! gl-vertical-align-text-bottom' + })) + end + def commit_to_html(commit, ref, project) render 'projects/commits/commit.html', commit: commit, diff --git a/app/helpers/feature_flags_helper.rb b/app/helpers/feature_flags_helper.rb index 2b8804bc07e..e12c6c605d2 100644 --- a/app/helpers/feature_flags_helper.rb +++ b/app/helpers/feature_flags_helper.rb @@ -11,8 +11,15 @@ module FeatureFlagsHelper project.feature_flags_client_token end - def feature_flag_issues_links_endpoint(_project, _feature_flag, _user) - '' + def edit_feature_flag_data + { + endpoint: project_feature_flag_path(@project, @feature_flag), + project_id: @project.id, + feature_flags_path: project_feature_flags_path(@project), + environments_endpoint: search_project_environments_path(@project, format: :json), + strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'), + environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs') + } end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index a24776eb2e4..30aaa0a5acc 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -109,7 +109,7 @@ module GroupsHelper end def prevent_sharing_groups_outside_hierarchy_help_text(group) - s_("GroupSettings|This setting is only available on the top-level group and it applies to all subgroups. Groups that have already been shared with a group outside %{group} will still be shared, and this access will have to be revoked manually.").html_safe % { group: link_to_group(group) } + s_("GroupSettings|Available only on the top-level group. Applies to all subgroups. Groups already shared with a group outside %{group} are still shared unless removed manually.").html_safe % { group: link_to_group(group) } end def parent_group_options(current_group) @@ -178,7 +178,7 @@ module GroupsHelper end def default_help - s_("GroupSettings|This setting will be applied to all subgroups unless overridden by a group owner. Groups that already have access to the project will continue to have access unless removed manually.") + s_("GroupSettings|Applied to all subgroups unless overridden by a group owner. Groups already added to the project lose access.") end def ancestor_locked_but_you_can_override(group) diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb index 2725d28c47c..c1dfd2b2cda 100644 --- a/app/helpers/hooks_helper.rb +++ b/app/helpers/hooks_helper.rb @@ -36,6 +36,15 @@ module HooksHelper admin_hook_path(hook) end end + + def hook_log_path(hook, hook_log) + case hook + when ProjectHook + hook_log.present.details_path + when SystemHook + admin_hook_hook_log_path(hook, hook_log) + end + end end HooksHelper.prepend_mod_with('HooksHelper') diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index 904508867d3..8819aa9e9cc 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -125,15 +125,6 @@ module IntegrationsHelper !Gitlab.com? end - def integration_tabs(integration:) - [ - { key: 'edit', text: _('Settings'), href: scoped_edit_integration_path(integration) }, - ( - { key: 'overrides', text: s_('Integrations|Projects using custom settings'), href: scoped_overrides_integration_path(integration) } if integration.instance_level? - ) - ].compact - end - def jira_issue_breadcrumb_link(issue_reference) link_to '', { class: 'gl-display-flex gl-align-items-center gl-white-space-nowrap' } do icon = image_tag image_path('illustrations/logos/jira.svg'), width: 15, height: 15, class: 'gl-mr-2' diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index f3cc46216e5..24c6ef8cd68 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -198,7 +198,7 @@ module IssuablesHelper if count != -1 html << " " << content_tag(:span, format_count(issuable_type, count, Gitlab::IssuablesCountForState::THRESHOLD), - class: 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm' + class: 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex' ) end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 40e86b4623c..49f7d9aeef1 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -238,9 +238,10 @@ module IssuesHelper ) end - def group_issues_list_data(group, current_user, issues) + def group_issues_list_data(group, current_user, issues, projects) common_issues_list_data(group, current_user).merge( - has_any_issues: issues.to_a.any?.to_s + has_any_issues: issues.to_a.any?.to_s, + has_any_projects: any_projects?(projects).to_s ) end diff --git a/app/helpers/one_trust_helper.rb b/app/helpers/one_trust_helper.rb new file mode 100644 index 00000000000..9f92a73a4d4 --- /dev/null +++ b/app/helpers/one_trust_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module OneTrustHelper + def one_trust_enabled? + Feature.enabled?(:ecomm_instrumentation, type: :ops) && + Gitlab.config.extra.has_key?('one_trust_id') && + Gitlab.config.extra.one_trust_id.present? && + !current_user + end +end diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index ebf30fb3538..c69d9eb1326 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -41,6 +41,7 @@ module PackagesHelper def packages_list_data(type, resource) { resource_id: resource.id, + full_path: resource.full_path, page_type: type, empty_list_help_url: help_page_path('user/packages/package_registry/index'), empty_list_illustration: image_path('illustrations/no-packages.svg'), @@ -70,6 +71,7 @@ module PackagesHelper can_delete: can?(current_user, :destroy_package, project).to_s, svg_path: image_path('illustrations/no-packages.svg'), npm_path: package_registry_instance_url(:npm), + npm_project_path: package_registry_project_url(project.id, :npm), npm_help_path: help_page_path('user/packages/npm_registry/index'), maven_path: package_registry_project_url(project.id, :maven), maven_help_path: help_page_path('user/packages/maven_repository/index'), diff --git a/app/helpers/projects/cluster_agents_helper.rb b/app/helpers/projects/cluster_agents_helper.rb new file mode 100644 index 00000000000..20fa721cc3b --- /dev/null +++ b/app/helpers/projects/cluster_agents_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Projects::ClusterAgentsHelper + def js_cluster_agent_details_data(agent_name, project) + { + agent_name: agent_name, + project_path: project.full_path + } + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index d7f1cd505e9..03e7fb5ffc4 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -20,16 +20,15 @@ module ProjectsHelper end def link_to_member_avatar(author, opts = {}) - default_opts = { size: 16, lazy_load: false } + default_opts = { size: 16 } opts = default_opts.merge(opts) classes = %W[avatar avatar-inline s#{opts[:size]}] classes << opts[:avatar_class] if opts[:avatar_class] avatar = avatar_icon_for_user(author, opts[:size]) - src = opts[:lazy_load] ? nil : avatar - image_tag(src, width: opts[:size], class: classes, alt: '', "data-src" => avatar) + image_tag(avatar, width: opts[:size], class: classes, alt: '') end def author_content_tag(author, opts = {}) @@ -351,7 +350,7 @@ module ProjectsHelper end def show_terraform_banner?(project) - project.repository_languages.with_programming_language('HCL').exists? && project.terraform_states.empty? + Feature.enabled?(:show_terraform_banner, type: :ops, default_enabled: true) && project.repository_languages.with_programming_language('HCL').exists? && project.terraform_states.empty? end def project_permissions_panel_data(project) diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb index 1d9320f0106..b73e49803ae 100644 --- a/app/helpers/routing/pseudonymization_helper.rb +++ b/app/helpers/routing/pseudonymization_helper.rb @@ -6,7 +6,8 @@ module Routing return unless Feature.enabled?(:mask_page_urls, type: :ops) mask_params(Rails.application.routes.recognize_path(request.original_fullpath)) - rescue ActionController::RoutingError, URI::InvalidURIError + rescue ActionController::RoutingError, URI::InvalidURIError => e + Gitlab::ErrorTracking.track_exception(e, url: request.original_fullpath) nil end @@ -27,7 +28,7 @@ module Routing when 'groups' "/namespace:#{group.id}" when 'projects' - "/namespace:#{project.namespace.id}/project:#{project.id}" + "/namespace:#{project.namespace_id}/project:#{project.id}" when 'root' '' else @@ -43,7 +44,7 @@ module Routing masked_url = "#{request.protocol}#{request.host_with_port}" if request_params.has_key?(:project_id) - masked_url += "/namespace:#{project.namespace.id}/project:#{project.id}/-/#{namespace_type}" + masked_url += "/namespace:#{project.namespace_id}/project:#{project.id}/-/#{namespace_type}" end if request_params.has_key?(:id) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index b8e58e3afb1..cb28025c900 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -87,9 +87,9 @@ module SearchHelper def search_entries_info_template(collection) if collection.total_pages > 1 - s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element}").html_safe + s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for %{term_element}").html_safe else - s_("SearchResults|Showing %{count} %{scope} for%{term_element}").html_safe + s_("SearchResults|Showing %{count} %{scope} for %{term_element}").html_safe end end diff --git a/app/helpers/startupjs_helper.rb b/app/helpers/startupjs_helper.rb index b595590c7c9..2e8f0cb7dbe 100644 --- a/app/helpers/startupjs_helper.rb +++ b/app/helpers/startupjs_helper.rb @@ -5,6 +5,13 @@ module StartupjsHelper @graphql_startup_calls end + def page_startup_graphql_headers + { + 'X-CSRF-Token' => form_authenticity_token, + 'x-gitlab-feature-category' => ::Gitlab::ApplicationContext.current_context_attribute(:feature_category).presence || '' + } + end + def add_page_startup_graphql_call(query, variables = {}) @graphql_startup_calls ||= [] file_location = File.join(Rails.root, "app/graphql/queries/#{query}.query.graphql") diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index e64e1c935dd..a6bb2f3b246 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -1,6 +1,67 @@ # frozen_string_literal: true module TabHelper + # Navigation tabs helper + + # Create a <gl-tabs> container + # + # Returns a `ul` element with classes that correspond to + # the <gl-tabs/> component. Can be populated by + # gl_tab_link_to elements. + # + # See more at: https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-tabs-tab--default + def gl_tabs_nav(html_options = {}, &block) + gl_tabs_classes = %w[nav gl-tabs-nav] + + html_options = html_options.merge( + class: [*html_options[:class], gl_tabs_classes].join(' '), + role: 'tablist' + ) + + content = capture(&block) if block_given? + content_tag(:ul, content, html_options) + end + + # Create a <gl-tab> link + # + # When a tab is active it gets highlighted to indicate this is currently viewed tab. + # Internally `current_page?` is called to determine if this is the current tab. + # + # Usage is the same as "link_to", with the following additional options: + # + # html_options - The html_options hash (default: {}) + # :item_active - Overrides the default state focing the "active" css classes (optional). + # + def gl_tab_link_to(name = nil, options = {}, html_options = {}, &block) + tab_class = 'nav-item' + link_classes = %w[nav-link gl-tab-nav-item] + active_link_classes = %w[active gl-tab-nav-item-active gl-tab-nav-item-active-indigo] + + if block_given? + # Shift params to skip the omitted "name" param + html_options = options + options = name + end + + html_options = html_options.merge( + class: [*html_options[:class], link_classes].join(' ') + ) + + if gl_tab_link_to_active?(options, html_options) + html_options[:class] = [*html_options[:class], active_link_classes].join(' ') + end + + html_options = html_options.except(:item_active) + + content_tag(:li, class: tab_class, role: 'presentation') do + if block_given? + link_to(options, html_options, &block) + else + link_to(name, options, html_options) + end + end + end + # Navigation link helper # # Returns an `li` element with an 'active' class if the supplied @@ -12,7 +73,6 @@ module TabHelper # :action - One or more action names to check (optional). # :path - A shorthand path, such as 'dashboard#index', to check (optional). # :html_options - Extra options to be passed to the list element (optional). - # :unless - Callable object to skip rendering the 'active' class on `li` element (optional). # block - An optional block that will become the contents of the returned # `li` element. # @@ -57,11 +117,6 @@ module TabHelper # nav_link(path: 'admin/appearances#show') { "Hello"} # # => '<li class="active">Hello</li>' # - # # Shorthand path + unless - # # Add `active` class when TreeController is requested, except the `index` action. - # nav_link(controller: 'tree', unless: -> { action_name?('index') }) { "Hello" } - # # => '<li class="active">Hello</li>' - # # # When `TreeController#index` is requested # # => '<li>Hello</li>' # @@ -90,8 +145,6 @@ module TabHelper end def active_nav_link?(options) - return false if options[:unless]&.call - controller = options.delete(:controller) action = options.delete(:action) @@ -148,4 +201,12 @@ module TabHelper current_controller?(*controller) || current_action?(*action) end end + + def gl_tab_link_to_active?(options, html_options) + if html_options.has_key?(:item_active) + return html_options[:item_active] + end + + current_page?(options) + end end diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb index f92e32ff9b6..a0d9c8403e8 100644 --- a/app/helpers/time_zone_helper.rb +++ b/app/helpers/time_zone_helper.rb @@ -33,6 +33,8 @@ module TimeZoneHelper end def local_time(timezone) + return if timezone.blank? + time_zone_instance = ActiveSupport::TimeZone.new(timezone) || Time.zone time_zone_instance.now.strftime("%-l:%M %p") end diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb index 0993e210f42..eca40572735 100644 --- a/app/helpers/timeboxes_helper.rb +++ b/app/helpers/timeboxes_helper.rb @@ -78,19 +78,6 @@ module TimeboxesHelper end # rubocop: enable CodeReuse/ActiveRecord - # Show 'active' class if provided GET param matches check - # `or_blank` allows the function to return 'active' when given an empty param - # Could be refactored to be simpler but that may make it harder to read - def milestone_class_for_state(param, check, match_blank_param = false) - if match_blank_param - 'active' if param.blank? || param == check - elsif param == check - 'active' - else - check - end - end - def milestone_progress_tooltip_text(milestone) has_issues = milestone.total_issues_count > 0 diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 2c3dc243d85..1c67ca983fa 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -10,6 +10,7 @@ module UserCalloutsHelper REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' INVITE_MEMBERS_BANNER = 'invite_members_banner' + SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout' def show_gke_cluster_integration_callout?(project) active_nav_link?(controller: sidebar_operations_paths) && @@ -64,6 +65,11 @@ module UserCalloutsHelper !multiple_members?(group) end + def show_security_newsletter_user_callout? + current_user&.admin? && + !user_dismissed?(SECURITY_NEWSLETTER_CALLOUT) + end + private def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index 8785c4cdcbb..4862282bc73 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -41,6 +41,15 @@ module WorkhorseHelper head :ok end + def send_dependency(token, url, filename) + headers.store(*Gitlab::Workhorse.send_dependency(token, url)) + headers['Content-Disposition'] = + ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: filename) + headers['Content-Type'] = 'application/gzip' + + head :ok + end + def set_workhorse_internal_api_content_type headers['Content-Type'] = Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE end diff --git a/app/models/analytics/cycle_analytics/issue_stage_event.rb b/app/models/analytics/cycle_analytics/issue_stage_event.rb index 1da8973ff21..3e6ed86d534 100644 --- a/app/models/analytics/cycle_analytics/issue_stage_event.rb +++ b/app/models/analytics/cycle_analytics/issue_stage_event.rb @@ -3,9 +3,14 @@ module Analytics module CycleAnalytics class IssueStageEvent < ApplicationRecord + include StageEventModel extend SuppressCompositePrimaryKeyWarning validates(*%i[stage_event_hash_id issue_id group_id project_id start_event_timestamp], presence: true) + + def self.issuable_id_column + :issue_id + end end end end diff --git a/app/models/analytics/cycle_analytics/merge_request_stage_event.rb b/app/models/analytics/cycle_analytics/merge_request_stage_event.rb index d2f899ae933..d0ec3c4e8b9 100644 --- a/app/models/analytics/cycle_analytics/merge_request_stage_event.rb +++ b/app/models/analytics/cycle_analytics/merge_request_stage_event.rb @@ -3,9 +3,14 @@ module Analytics module CycleAnalytics class MergeRequestStageEvent < ApplicationRecord + include StageEventModel extend SuppressCompositePrimaryKeyWarning validates(*%i[stage_event_hash_id merge_request_id group_id project_id start_event_timestamp], presence: true) + + def self.issuable_id_column + :merge_request_id + end end end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 5f16b990d01..5a8cbd8d71c 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -9,8 +9,6 @@ class ApplicationSetting < ApplicationRecord include Sanitizable ignore_columns %i[elasticsearch_shards elasticsearch_replicas], remove_with: '14.4', remove_after: '2021-09-22' - ignore_column :seat_link_enabled, remove_with: '14.4', remove_after: '2021-09-22' - ignore_column :cloud_license_enabled, remove_with: '14.4', remove_after: '2021-09-22' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -366,6 +364,10 @@ class ApplicationSetting < ApplicationRecord validates :container_registry_expiration_policies_worker_capacity, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :dependency_proxy_ttl_group_policy_worker_capacity, + allow_nil: false, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :invisible_captcha_enabled, inclusion: { in: [true, false], message: _('must be a boolean value') } @@ -481,6 +483,8 @@ class ApplicationSetting < ApplicationRecord validates :throttle_unauthenticated_packages_api_period_in_seconds validates :throttle_unauthenticated_files_api_requests_per_period validates :throttle_unauthenticated_files_api_period_in_seconds + validates :throttle_unauthenticated_deprecated_api_requests_per_period + validates :throttle_unauthenticated_deprecated_api_period_in_seconds validates :throttle_authenticated_api_requests_per_period validates :throttle_authenticated_api_period_in_seconds validates :throttle_authenticated_git_lfs_requests_per_period @@ -491,6 +495,8 @@ class ApplicationSetting < ApplicationRecord validates :throttle_authenticated_packages_api_period_in_seconds validates :throttle_authenticated_files_api_requests_per_period validates :throttle_authenticated_files_api_period_in_seconds + validates :throttle_authenticated_deprecated_api_requests_per_period + validates :throttle_authenticated_deprecated_api_period_in_seconds validates :throttle_protected_paths_requests_per_period validates :throttle_protected_paths_period_in_seconds end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 612fda158d3..7bdea36bb8a 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -159,6 +159,7 @@ module ApplicationSettingImplementation spam_check_endpoint_enabled: false, spam_check_endpoint_url: nil, spam_check_api_key: nil, + suggest_pipeline_enabled: true, terminal_max_session_time: 0, throttle_authenticated_api_enabled: false, throttle_authenticated_api_period_in_seconds: 3600, @@ -175,6 +176,9 @@ module ApplicationSettingImplementation throttle_authenticated_files_api_enabled: false, throttle_authenticated_files_api_period_in_seconds: 15, throttle_authenticated_files_api_requests_per_period: 500, + throttle_authenticated_deprecated_api_enabled: false, + throttle_authenticated_deprecated_api_period_in_seconds: 3600, + throttle_authenticated_deprecated_api_requests_per_period: 3600, throttle_incident_management_notification_enabled: false, throttle_incident_management_notification_per_period: 3600, throttle_incident_management_notification_period_in_seconds: 3600, @@ -193,6 +197,9 @@ module ApplicationSettingImplementation throttle_unauthenticated_files_api_enabled: false, throttle_unauthenticated_files_api_period_in_seconds: 15, throttle_unauthenticated_files_api_requests_per_period: 125, + throttle_unauthenticated_deprecated_api_enabled: false, + throttle_unauthenticated_deprecated_api_period_in_seconds: 3600, + throttle_unauthenticated_deprecated_api_requests_per_period: 1800, time_tracking_limit_to_hours: false, two_factor_grace_period: 48, unique_ips_limit_enabled: false, diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index f17fff742fe..a1c6793607f 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -71,7 +71,7 @@ class AuditEvent < ApplicationRecord end def lazy_author - BatchLoader.for(author_id).batch(replace_methods: false) do |author_ids, loader| + BatchLoader.for(author_id).batch do |author_ids, loader| User.select(:id, :name, :username).where(id: author_ids).find_each do |user| loader.call(user.id, user) end diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index d251b0adbd3..c8f6b9aaedb 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -66,5 +66,3 @@ class AwardEmoji < ApplicationRecord awardable.try(:update_upvotes_count) if upvote? end end - -AwardEmoji.prepend_mod_with('AwardEmoji') diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb index dee55675304..818ae04ba29 100644 --- a/app/models/bulk_import.rb +++ b/app/models/bulk_import.rb @@ -4,7 +4,8 @@ # projects to a GitLab instance. It associates the import with the responsible # user. class BulkImport < ApplicationRecord - MINIMUM_GITLAB_MAJOR_VERSION = 14 + MIN_MAJOR_VERSION = 14 + MIN_MINOR_VERSION_FOR_PROJECT = 4 belongs_to :user, optional: false @@ -34,6 +35,14 @@ class BulkImport < ApplicationRecord end end + def source_version_info + Gitlab::VersionInfo.parse(source_version) + end + + def self.min_gl_version_for_project_migration + Gitlab::VersionInfo.new(MIN_MAJOR_VERSION, MIN_MINOR_VERSION_FOR_PROJECT) + end + def self.all_human_statuses state_machine.states.map(&:human_name) end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index ab5d248ff8c..ecac4ab95f4 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -20,6 +20,8 @@ class BulkImports::Entity < ApplicationRecord self.table_name = 'bulk_import_entities' + EXPORT_RELATIONS_URL = '/%{resource}/%{full_path}/export_relations' + belongs_to :bulk_import, optional: false belongs_to :parent, class_name: 'BulkImports::Entity', optional: true @@ -81,9 +83,9 @@ class BulkImports::Entity < ApplicationRecord def pipelines @pipelines ||= case source_type when 'group_entity' - BulkImports::Groups::Stage.pipelines + BulkImports::Groups::Stage.new(bulk_import).pipelines when 'project_entity' - BulkImports::Projects::Stage.pipelines + BulkImports::Projects::Stage.new(bulk_import).pipelines end end @@ -102,6 +104,14 @@ class BulkImports::Entity < ApplicationRecord end end + def pluralized_name + source_type.gsub('_entity', '').pluralize + end + + def export_relations_url_path + @export_relations_url_path ||= EXPORT_RELATIONS_URL % { resource: pluralized_name, full_path: encoded_source_full_path } + end + private def validate_parent_is_a_group diff --git a/app/models/bulk_imports/export.rb b/app/models/bulk_imports/export.rb index 371b58dea03..8d4d31ee92d 100644 --- a/app/models/bulk_imports/export.rb +++ b/app/models/bulk_imports/export.rb @@ -53,7 +53,7 @@ module BulkImports end def relation_definition - config.portable_tree[:include].find { |include| include[relation.to_sym] } + config.relation_definition_for(relation) end def config diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb index ff165830cf1..abf064adaae 100644 --- a/app/models/bulk_imports/export_status.rb +++ b/app/models/bulk_imports/export_status.rb @@ -41,7 +41,7 @@ module BulkImports end def status_endpoint - "/groups/#{entity.encoded_source_full_path}/export_relations/status" + File.join(entity.export_relations_url_path, 'status') end end end diff --git a/app/models/bulk_imports/file_transfer/base_config.rb b/app/models/bulk_imports/file_transfer/base_config.rb index ddea7c3f64c..4d370315ad5 100644 --- a/app/models/bulk_imports/file_transfer/base_config.rb +++ b/app/models/bulk_imports/file_transfer/base_config.rb @@ -22,15 +22,25 @@ module BulkImports end def export_path - strong_memoize(:export_path) do - relative_path = File.join(base_export_path, SecureRandom.hex) - - ::Gitlab::ImportExport.export_path(relative_path: relative_path) - end + @export_path ||= Dir.mktmpdir('bulk_imports') end def portable_relations - import_export_config.dig(:tree, portable_class_sym).keys.map(&:to_s) - skipped_relations + tree_relations + file_relations - skipped_relations + end + + def tree_relation?(relation) + tree_relations.include?(relation) + end + + def file_relation?(relation) + file_relations.include?(relation) + end + + def tree_relation_definition_for(relation) + return unless tree_relation?(relation) + + portable_tree[:include].find { |include| include[relation.to_sym] } end private @@ -44,7 +54,7 @@ module BulkImports end def import_export_config - ::Gitlab::ImportExport::Config.new(config: import_export_yaml).to_h + @config ||= ::Gitlab::ImportExport::Config.new(config: import_export_yaml).to_h end def portable_class @@ -63,8 +73,12 @@ module BulkImports raise NotImplementedError end - def base_export_path - raise NotImplementedError + def tree_relations + import_export_config.dig(:tree, portable_class_sym).keys.map(&:to_s) + end + + def file_relations + [] end def skipped_relations diff --git a/app/models/bulk_imports/file_transfer/group_config.rb b/app/models/bulk_imports/file_transfer/group_config.rb index 2266cbb484f..6766c00246b 100644 --- a/app/models/bulk_imports/file_transfer/group_config.rb +++ b/app/models/bulk_imports/file_transfer/group_config.rb @@ -3,16 +3,14 @@ module BulkImports module FileTransfer class GroupConfig < BaseConfig - def base_export_path - portable.full_path - end + SKIPPED_RELATIONS = %w(members).freeze def import_export_yaml ::Gitlab::ImportExport.group_config_file end def skipped_relations - @skipped_relations ||= %w(members) + SKIPPED_RELATIONS end end end diff --git a/app/models/bulk_imports/file_transfer/project_config.rb b/app/models/bulk_imports/file_transfer/project_config.rb index 8a57f51c1c5..9a0434da08a 100644 --- a/app/models/bulk_imports/file_transfer/project_config.rb +++ b/app/models/bulk_imports/file_transfer/project_config.rb @@ -3,16 +3,23 @@ module BulkImports module FileTransfer class ProjectConfig < BaseConfig - def base_export_path - portable.disk_path - end + UPLOADS_RELATION = 'uploads' + + SKIPPED_RELATIONS = %w( + project_members + group_members + ).freeze def import_export_yaml ::Gitlab::ImportExport.config_file end + def file_relations + [UPLOADS_RELATION] + end + def skipped_relations - @skipped_relations ||= %w(project_members group_members) + SKIPPED_RELATIONS end end end diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb index c185470b1c2..9de3239ee0f 100644 --- a/app/models/bulk_imports/tracker.rb +++ b/app/models/bulk_imports/tracker.rb @@ -50,6 +50,8 @@ class BulkImports::Tracker < ApplicationRecord event :start do transition created: :started + # To avoid errors when re-starting a pipeline in case of network errors + transition started: :started end event :finish do diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 97fb8233d34..50bda64d537 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -31,7 +31,7 @@ module Ci next unless bridge.triggers_downstream_pipeline? bridge.run_after_commit do - ::Ci::CreateCrossProjectPipelineWorker.perform_async(bridge.id) + ::Ci::CreateDownstreamPipelineWorker.perform_async(bridge.id) end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index e2e24247679..990ef71a457 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -42,6 +42,10 @@ module Ci has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id, inverse_of: :build has_many :report_results, class_name: 'Ci::BuildReportResult', inverse_of: :build + # Projects::DestroyService destroys Ci::Pipelines, which use_fast_destroy on :job_artifacts + # before we delete builds. By doing this, the relation should be empty and not fire any + # DELETE queries when the Ci::Build is destroyed. The next step is to remove `dependent: :destroy`. + # Details: https://gitlab.com/gitlab-org/gitlab/-/issues/24644#note_689472685 has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id @@ -55,6 +59,8 @@ module Ci has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build has_one :trace_metadata, class_name: 'Ci::BuildTraceMetadata', inverse_of: :build + has_many :terraform_state_versions, class_name: 'Terraform::StateVersion', dependent: :nullify, inverse_of: :build, foreign_key: :ci_build_id # rubocop:disable Cop/ActiveRecordDependent + accepts_nested_attributes_for :runner_session, update_only: true accepts_nested_attributes_for :job_variables @@ -64,8 +70,8 @@ module Ci delegate :gitlab_deploy_token, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true - ignore_columns :id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' - ignore_columns :stage_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' + ignore_columns :id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22' + ignore_columns :stage_id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22' ## # Since Gitlab 11.5, deployments records started being created right after @@ -192,7 +198,6 @@ module Ci add_authentication_token_field :token, encrypted: :required before_save :ensure_token - before_destroy { unscoped_project } after_save :stick_build_if_status_changed @@ -308,8 +313,10 @@ module Ci end after_transition pending: :running do |build| - Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do - build.deployment&.run + unless build.update_deployment_after_transaction_commit? + Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do + build.deployment&.run + end end build.run_after_commit do @@ -332,8 +339,10 @@ module Ci end after_transition any => [:success] do |build| - Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do - build.deployment&.succeed + unless build.update_deployment_after_transaction_commit? + Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do + build.deployment&.succeed + end end build.run_after_commit do @@ -346,12 +355,14 @@ module Ci next unless build.project next unless build.deployment - begin - Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do - build.deployment.drop! + unless build.update_deployment_after_transaction_commit? + begin + Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do + build.deployment.drop! + end + rescue StandardError => e + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, build_id: build.id) end - rescue StandardError => e - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, build_id: build.id) end true @@ -370,14 +381,29 @@ module Ci end after_transition any => [:skipped, :canceled] do |build, transition| - Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do - if transition.to_name == :skipped - build.deployment&.skip - else - build.deployment&.cancel + unless build.update_deployment_after_transaction_commit? + Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do + if transition.to_name == :skipped + build.deployment&.skip + else + build.deployment&.cancel + end end end end + + # Synchronize Deployment Status + # Please note that the data integirty is not assured because we can't use + # a database transaction due to DB decomposition. + after_transition do |build, transition| + next if transition.loopback? + next unless build.project + next unless build.update_deployment_after_transaction_commit? + + build.run_after_commit do + build.deployment&.sync_status_with(build) + end + end end def self.build_matchers(project) @@ -1094,6 +1120,12 @@ module Ci runner&.instance_type? end + def update_deployment_after_transaction_commit? + strong_memoize(:update_deployment_after_transaction_commit) do + Feature.enabled?(:update_deployment_after_transaction_commit, project, default_enabled: :yaml) + end + end + protected def run_status_commit_hooks! @@ -1108,7 +1140,7 @@ module Ci return unless saved_change_to_status? return unless running? - ::Gitlab::Database::LoadBalancing::Sticking.stick(:build, id) + self.class.sticking.stick(:build, id) end def status_commit_hooks @@ -1154,10 +1186,6 @@ module Ci self.update(erased_by: user, erased_at: Time.current, artifacts_expire_at: nil) end - def unscoped_project - @unscoped_project ||= Project.unscoped.find_by(id: project_id) - end - def environment_url options&.dig(:environment, :url) || persisted_environment&.external_url end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 90237a4be52..0d6d6f7a6a5 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -37,8 +37,8 @@ module Ci job_timeout_source: 4 } - ignore_column :build_id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22' - ignore_columns :id_convert_to_bigint, remove_with: '14.3', remove_after: '2021-09-22' + ignore_column :build_id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22' + ignore_columns :id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22' def update_timeout_state timeout = timeout_with_highest_precedence diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 003659570b3..bf1470ca20f 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -5,8 +5,6 @@ module Ci include BulkInsertSafe include IgnorableColumns - ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' - belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs validates :build, presence: true diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index 45de47116cd..e12c0f82c99 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -6,8 +6,6 @@ module Ci class BuildRunnerSession < Ci::ApplicationRecord include IgnorableColumns - ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' - TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com' DEFAULT_SERVICE_NAME = 'build' DEFAULT_PORT_NAME = 'default_port' diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 7a15d7ba940..6edb5ef4579 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -9,8 +9,6 @@ module Ci include ::Gitlab::OptimisticLocking include IgnorableColumns - ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' - belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id default_value_for :data_store, :redis_trace_chunks diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb index 901b84ceec6..1ffa0e31f99 100644 --- a/app/models/ci/build_trace_metadata.rb +++ b/app/models/ci/build_trace_metadata.rb @@ -37,8 +37,10 @@ module Ci increment!(:archival_attempts, touch: :last_archival_attempt_at) end - def track_archival!(trace_artifact_id) - update!(trace_artifact_id: trace_artifact_id, archived_at: Time.current) + def track_archival!(trace_artifact_id, checksum) + update!(trace_artifact_id: trace_artifact_id, + checksum: checksum, + archived_at: Time.current) end def archival_attempts_message @@ -49,6 +51,11 @@ module Ci end end + def remote_checksum_valid? + checksum.present? && + checksum == remote_checksum + end + private def backoff diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb index 99118f8090b..c2ab8ca0929 100644 --- a/app/models/ci/job_token/project_scope_link.rb +++ b/app/models/ci/job_token/project_scope_link.rb @@ -5,7 +5,7 @@ module Ci module JobToken - class ProjectScopeLink < ApplicationRecord + class ProjectScopeLink < Ci::ApplicationRecord self.table_name = 'ci_job_token_project_scope_links' belongs_to :source_project, class_name: 'Project' diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index 42cfdc21d66..3a5765aa00c 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -32,12 +32,15 @@ module Ci def all_projects Project.from_union([ Project.id_in(source_project), - Project.where_exists( - Ci::JobToken::ProjectScopeLink - .from_project(source_project) - .where('projects.id = ci_job_token_project_scope_links.target_project_id')) + Project.id_in(target_project_ids) ], remove_duplicates: false) end + + private + + def target_project_ids + Ci::JobToken::ProjectScopeLink.from_project(source_project).pluck(:target_project_id) + end end end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 1a0cec3c935..0041ec5135c 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -82,7 +82,8 @@ module Ci # Merge requests for which the current pipeline is running against # the merge request's latest commit. has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest' - + has_many :package_build_infos, class_name: 'Packages::BuildInfo', dependent: :nullify, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent + has_many :package_file_build_infos, class_name: 'Packages::PackageFileBuildInfo', dependent: :nullify, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline has_many :failed_builds, -> { latest.failed }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline @@ -861,11 +862,6 @@ module Ci self.duration = Gitlab::Ci::Pipeline::Duration.from_pipeline(self) end - def execute_hooks - project.execute_hooks(pipeline_data, :pipeline_hooks) if project.has_active_hooks?(:pipeline_hooks) - project.execute_integrations(pipeline_data, :pipeline_hooks) if project.has_active_integrations?(:pipeline_hooks) - end - # All the merge requests for which the current pipeline runs/ran against def all_merge_requests @all_merge_requests ||= @@ -929,9 +925,22 @@ module Ci end def environments_in_self_and_descendants - environment_ids = self_and_descendants.joins(:deployments).select(:'deployments.environment_id') + if ::Feature.enabled?(:avoid_cross_joins_environments_in_self_and_descendants, default_enabled: :yaml) + # We limit to 100 unique environments for application safety. + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700 + expanded_environment_names = + builds_in_self_and_descendants.joins(:metadata) + .where.not('ci_builds_metadata.expanded_environment_name' => nil) + .distinct('ci_builds_metadata.expanded_environment_name') + .limit(100) + .pluck(:expanded_environment_name) + + Environment.where(project: project, name: expanded_environment_names) + else + environment_ids = self_and_descendants.joins(:deployments).select(:'deployments.environment_id') - Environment.where(id: environment_ids) + Environment.where(id: environment_ids) + end end # With multi-project and parent-child pipelines @@ -1251,12 +1260,6 @@ module Ci messages.build(severity: severity, content: content) end - def pipeline_data - strong_memoize(:pipeline_data) do - Gitlab::DataBuilder::Pipeline.build(self) - end - end - def merge_request_diff_sha return unless merge_request? diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 30d335fd7d5..372df8cc264 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -58,7 +58,8 @@ module Ci after_transition any => ::Ci::Processable.completed_statuses do |processable| next unless processable.with_resource_group? - next unless processable.resource_group.release_resource_from(processable) + + processable.resource_group.release_resource_from(processable) processable.run_after_commit do Ci::ResourceGroups::AssignResourceFromResourceGroupWorker diff --git a/app/models/ci/resource_group.rb b/app/models/ci/resource_group.rb index 8a7456041e6..6d25f747a9d 100644 --- a/app/models/ci/resource_group.rb +++ b/app/models/ci/resource_group.rb @@ -14,6 +14,12 @@ module Ci before_create :ensure_resource + enum process_mode: { + unordered: 0, + oldest_first: 1, + newest_first: 2 + } + ## # NOTE: This is concurrency-safe method that the subquery in the `UPDATE` # works as explicit locking. @@ -25,8 +31,34 @@ module Ci resources.retained_by(processable).update_all(build_id: nil) > 0 end + def upcoming_processables + if unordered? + processables.waiting_for_resource + elsif oldest_first? + processables.waiting_for_resource_or_upcoming + .order(Arel.sql("commit_id ASC, #{sort_by_job_status}")) + elsif newest_first? + processables.waiting_for_resource_or_upcoming + .order(Arel.sql("commit_id DESC, #{sort_by_job_status}")) + else + Ci::Processable.none + end + end + private + # In order to avoid deadlock, we do NOT specify the job execution order in the same pipeline. + # The system processes wherever ready to transition to `pending` status from `waiting_for_resource`. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/202186 for more information. + def sort_by_job_status + <<~SQL + CASE status + WHEN 'waiting_for_resource' THEN 0 + ELSE 1 + END ASC + SQL + end + def ensure_resource # Currently we only support one resource per group, which means # maximum one build can be set to the resource group, thus builds diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 4aa232ad26b..2f718ad7582 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -51,7 +51,7 @@ module Ci has_many :runner_projects, inverse_of: :runner, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :runner_projects has_many :runner_namespaces, inverse_of: :runner, autosave: true - has_many :groups, through: :runner_namespaces + has_many :groups, through: :runner_namespaces, disable_joins: true has_one :last_build, -> { order('id DESC') }, class_name: 'Ci::Build' @@ -246,7 +246,7 @@ module Ci begin transaction do - self.projects << project + self.runner_projects << ::Ci::RunnerProject.new(project: project, runner: self) self.save! end rescue ActiveRecord::RecordInvalid => e @@ -280,7 +280,7 @@ module Ci end def belongs_to_more_than_one_project? - self.projects.limit(2).count(:all) > 1 + runner_projects.limit(2).count(:all) > 1 end def assigned_to_group? @@ -309,7 +309,9 @@ module Ci end def only_for?(project) - projects == [project] + ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do + projects == [project] + end end def short_sha @@ -344,7 +346,7 @@ module Ci # intention here is not to execute `Ci::RegisterJobService#execute` on # the primary database. # - ::Gitlab::Database::LoadBalancing::Sticking.stick(:runner, id) + ::Ci::Runner.sticking.stick(:runner, id) SecureRandom.hex.tap do |new_update| ::Gitlab::Workhorse.set_key_and_notify(runner_queue_key, new_update, @@ -428,10 +430,8 @@ module Ci end def no_projects - ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do - if projects.any? - errors.add(:runner, 'cannot have projects assigned') - end + if runner_projects.any? + errors.add(:runner, 'cannot have projects assigned') end end @@ -444,14 +444,16 @@ module Ci end def any_project - unless projects.any? + unless runner_projects.any? errors.add(:runner, 'needs to be assigned to at least one project') end end def exactly_one_group - unless groups.one? - errors.add(:runner, 'needs to be assigned to exactly one group') + ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do + unless groups.one? + errors.add(:runner, 'needs to be assigned to exactly one group') + end end end diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb index d1353b97ed9..52a31863fb2 100644 --- a/app/models/ci/runner_namespace.rb +++ b/app/models/ci/runner_namespace.rb @@ -7,7 +7,6 @@ module Ci self.limit_name = 'ci_registered_group_runners' self.limit_scope = :group self.limit_relation = :recent_runners - self.limit_feature_flag = :ci_runner_limits self.limit_feature_flag_for_override = :ci_runner_limits_override belongs_to :runner, inverse_of: :runner_namespaces diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb index e1c435e9b1f..148a29a0f8b 100644 --- a/app/models/ci/runner_project.rb +++ b/app/models/ci/runner_project.rb @@ -7,7 +7,6 @@ module Ci self.limit_name = 'ci_registered_project_runners' self.limit_scope = :project self.limit_relation = :recent_runners - self.limit_feature_flag = :ci_runner_limits self.limit_feature_flag_for_override = :ci_runner_limits_override belongs_to :runner, inverse_of: :runner_projects diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 39e26bf2785..131e18adf62 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -8,8 +8,6 @@ module Ci include Presentable include IgnorableColumns - ignore_column :id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22' - enum status: Ci::HasStatus::STATUSES_ENUM belongs_to :project diff --git a/app/models/clusters/agents/group_authorization.rb b/app/models/clusters/agents/group_authorization.rb index 74c0cec3b7e..28a711aaf17 100644 --- a/app/models/clusters/agents/group_authorization.rb +++ b/app/models/clusters/agents/group_authorization.rb @@ -10,7 +10,9 @@ module Clusters validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' } - delegate :project, to: :agent + def config_project + agent.project + end end end end diff --git a/app/models/clusters/agents/implicit_authorization.rb b/app/models/clusters/agents/implicit_authorization.rb index 967cc686045..9f7f653ed65 100644 --- a/app/models/clusters/agents/implicit_authorization.rb +++ b/app/models/clusters/agents/implicit_authorization.rb @@ -6,12 +6,15 @@ module Clusters attr_reader :agent delegate :id, to: :agent, prefix: true - delegate :project, to: :agent def initialize(agent:) @agent = agent end + def config_project + agent.project + end + def config nil end diff --git a/app/models/clusters/agents/project_authorization.rb b/app/models/clusters/agents/project_authorization.rb index 1c71a0a432a..f6d19086751 100644 --- a/app/models/clusters/agents/project_authorization.rb +++ b/app/models/clusters/agents/project_authorization.rb @@ -9,6 +9,10 @@ module Clusters belongs_to :project, class_name: '::Project', optional: false validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' } + + def config_project + agent.project + end end end end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 993ccb33655..7cef92ce81a 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -72,7 +72,7 @@ module Clusters if cluster.group_type? attributes[:groups] = [group] elsif cluster.project_type? - attributes[:projects] = [project] + attributes[:runner_projects] = [::Ci::RunnerProject.new(project: project)] end attributes diff --git a/app/models/clusters/integrations/elastic_stack.rb b/app/models/clusters/integrations/elastic_stack.rb index 565d268259a..97d73d252b9 100644 --- a/app/models/clusters/integrations/elastic_stack.rb +++ b/app/models/clusters/integrations/elastic_stack.rb @@ -14,6 +14,8 @@ module Clusters validates :cluster, presence: true validates :enabled, inclusion: { in: [true, false] } + scope :enabled, -> { where(enabled: true) } + def available? enabled end diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb index 3f2c47d48e6..d745a49afc1 100644 --- a/app/models/clusters/integrations/prometheus.rb +++ b/app/models/clusters/integrations/prometheus.rb @@ -21,6 +21,8 @@ module Clusters default_value_for(:alert_manager_token) { SecureRandom.hex } + scope :enabled, -> { where(enabled: true) } + after_destroy do run_after_commit do deactivate_project_integrations diff --git a/app/models/commit.rb b/app/models/commit.rb index 6c8b4ae1139..553681ee960 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -133,7 +133,7 @@ class Commit end def lazy(container, oid) - BatchLoader.for({ container: container, oid: oid }).batch(replace_methods: false) do |items, loader| + BatchLoader.for({ container: container, oid: oid }).batch do |items, loader| items_by_container = items.group_by { |i| i[:container] } items_by_container.each do |container, commit_ids| diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 8cba3d04502..43427e2ebc7 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -62,6 +62,9 @@ class CommitStatus < Ci::ApplicationRecord scope :updated_before, ->(lookback:, timeout:) { where('(ci_builds.created_at BETWEEN ? AND ?) AND (ci_builds.updated_at BETWEEN ? AND ?)', lookback, timeout, lookback, timeout) } + scope :scheduled_at_before, ->(date) { + where('ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?', date) + } # The scope applies `pluck` to split the queries. Use with care. scope :for_project_paths, -> (paths) do diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index 7bb6004ca83..d9e6756ab86 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -27,7 +27,8 @@ module Analytics alias_attribute :custom_stage?, :custom scope :default_stages, -> { where(custom: false) } scope :ordered, -> { order(:relative_position, :id) } - scope :for_list, -> { includes(:start_event_label, :end_event_label).ordered } + scope :with_preloaded_labels, -> { includes(:start_event_label, :end_event_label) } + scope :for_list, -> { with_preloaded_labels.ordered } scope :by_value_stream, -> (value_stream) { where(value_stream_id: value_stream.id) } before_save :ensure_stage_event_hash_id diff --git a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb new file mode 100644 index 00000000000..7462e1e828b --- /dev/null +++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + module StageEventModel + extend ActiveSupport::Concern + + class_methods do + def upsert_data(data) + upsert_values = data.map do |row| + row.values_at( + :stage_event_hash_id, + :issuable_id, + :group_id, + :project_id, + :author_id, + :milestone_id, + :start_event_timestamp, + :end_event_timestamp + ) + end + + value_list = Arel::Nodes::ValuesList.new(upsert_values).to_sql + + query = <<~SQL + INSERT INTO #{quoted_table_name} + ( + stage_event_hash_id, + #{connection.quote_column_name(issuable_id_column)}, + group_id, + project_id, + milestone_id, + author_id, + start_event_timestamp, + end_event_timestamp + ) + #{value_list} + ON CONFLICT(stage_event_hash_id, #{issuable_id_column}) + DO UPDATE SET + group_id = excluded.group_id, + project_id = excluded.project_id, + start_event_timestamp = excluded.start_event_timestamp, + end_event_timestamp = excluded.end_event_timestamp, + milestone_id = excluded.milestone_id, + author_id = excluded.author_id + SQL + + result = connection.execute(query) + result.cmd_tuples + end + end + end + end +end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 84a74386ff7..b32502c3ee2 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -18,6 +18,7 @@ module Avatarable prepend ShadowMethods include ObjectStorage::BackgroundMove include Gitlab::Utils::StrongMemoize + include ApplicationHelper validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validates :avatar, file_size: { maximum: MAXIMUM_FILE_SIZE }, if: :avatar_changed? @@ -110,7 +111,7 @@ module Avatarable def retrieve_upload_from_batch(identifier) BatchLoader.for(identifier: identifier, model: self) - .batch(key: self.class, cache: true, replace_methods: false) do |upload_params, loader, args| + .batch(key: self.class) do |upload_params, loader, args| model_class = args[:key] paths = upload_params.flat_map do |params| params[:model].upload_paths(params[:identifier]) diff --git a/app/models/concerns/bulk_insert_safe.rb b/app/models/concerns/bulk_insert_safe.rb index 908f0b6a7e2..6c3093ca916 100644 --- a/app/models/concerns/bulk_insert_safe.rb +++ b/app/models/concerns/bulk_insert_safe.rb @@ -51,6 +51,12 @@ module BulkInsertSafe PrimaryKeySetError = Class.new(StandardError) class_methods do + def insert_all_proxy_class + @insert_all_proxy_class ||= Class.new(self) do + attr_readonly :created_at + end + end + def set_callback(name, *args) unless _bulk_insert_callback_allowed?(name, args) raise MethodNotAllowedError, @@ -138,7 +144,7 @@ module BulkInsertSafe when nil false else - raise ArgumentError, "returns needs to be :ids or nil" + returns end # Handle insertions for tables with a composite primary key @@ -153,9 +159,9 @@ module BulkInsertSafe item_batch, validate, &handle_attributes) ActiveRecord::InsertAll - .new(self, attributes, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by) + .new(insert_all_proxy_class, attributes, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by) .execute - .pluck(primary_key) + .cast_values(insert_all_proxy_class.attribute_types).to_a end end end diff --git a/app/models/concerns/checksummable.rb b/app/models/concerns/checksummable.rb index 056abafd0ce..9812c62fcc4 100644 --- a/app/models/concerns/checksummable.rb +++ b/app/models/concerns/checksummable.rb @@ -8,8 +8,12 @@ module Checksummable Zlib.crc32(data) end - def hexdigest(path) + def sha256_hexdigest(path) ::Digest::SHA256.file(path).hexdigest end + + def md5_hexdigest(path) + ::Digest::MD5.file(path).hexdigest + end end end diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index c1299e3d468..8d715279da8 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -95,6 +95,7 @@ module Ci scope :failed_or_canceled, -> { with_status(:failed, :canceled) } scope :complete, -> { with_status(completed_statuses) } scope :incomplete, -> { without_statuses(completed_statuses) } + scope :waiting_for_resource_or_upcoming, -> { with_status(:created, :scheduled, :waiting_for_resource) } scope :cancelable, -> do where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled]) diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index ec86746ae54..344f5aa4cd5 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -20,6 +20,7 @@ module Ci delegate :interruptible, to: :metadata, prefix: false, allow_nil: true delegate :has_exposed_artifacts?, to: :metadata, prefix: false, allow_nil: true delegate :environment_auto_stop_in, to: :metadata, prefix: false, allow_nil: true + delegate :runner_features, to: :metadata, prefix: false, allow_nil: false before_create :ensure_metadata end diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb index 7f46e44697e..1b4cc14f4a2 100644 --- a/app/models/concerns/enums/ci/commit_status.rb +++ b/app/models/concerns/enums/ci/commit_status.rb @@ -27,6 +27,7 @@ module Enums no_matching_runner: 18, # not used anymore, but cannot be deleted because of old data trace_size_exceeded: 19, builds_disabled: 20, + environment_creation_failure: 21, insufficient_bridge_permissions: 1_001, downstream_bridge_project_not_found: 1_002, invalid_bridge_trigger: 1_003, diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index 9218ba47d20..d614d6c4584 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -72,12 +72,10 @@ module HasRepository end def default_branch - @default_branch ||= repository.root_ref || default_branch_from_preferences + @default_branch ||= repository.empty? ? default_branch_from_preferences : repository.root_ref end def default_branch_from_preferences - return unless empty_repo? - (default_branch_from_group_preferences || Gitlab::CurrentSettings.default_branch_name).presence end diff --git a/app/models/concerns/integrations/has_data_fields.rb b/app/models/concerns/integrations/has_data_fields.rb index 1709b56080e..25a1d855119 100644 --- a/app/models/concerns/integrations/has_data_fields.rb +++ b/app/models/concerns/integrations/has_data_fields.rb @@ -45,7 +45,6 @@ module Integrations included do has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::IssueTrackerData' has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::JiraTrackerData' - has_one :open_project_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::OpenProjectTrackerData' has_one :zentao_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :integration_id, class_name: 'Integrations::ZentaoTrackerData' def data_fields diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb index 933e8b5f687..209456f8b67 100644 --- a/app/models/concerns/issue_available_features.rb +++ b/app/models/concerns/issue_available_features.rb @@ -12,7 +12,8 @@ module IssueAvailableFeatures { assignee: %w(issue incident), confidentiality: %w(issue incident), - time_tracking: %w(issue incident) + time_tracking: %w(issue incident), + move_and_clone: %w(issue incident) }.with_indifferent_access end end diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb index 196bec04be6..ff52769fce8 100644 --- a/app/models/concerns/packages/debian/distribution.rb +++ b/app/models/concerns/packages/debian/distribution.rb @@ -96,18 +96,8 @@ module Packages architectures.pluck(:name).sort end - def needs_update? - !file.exists? || time_duration_expired? - end - private - def time_duration_expired? - return false unless valid_time_duration_seconds.present? - - updated_at + valid_time_duration_seconds.seconds + 6.hours < Time.current - end - def unique_codename_and_suite errors.add(:codename, _('has already been taken as Suite')) if codename_exists_as_suite? errors.add(:suite, _('has already been taken as Codename')) if suite_exists_as_codename? diff --git a/app/models/concerns/restricted_signup.rb b/app/models/concerns/restricted_signup.rb index 587f8c35ff7..cf97be21165 100644 --- a/app/models/concerns/restricted_signup.rb +++ b/app/models/concerns/restricted_signup.rb @@ -7,15 +7,49 @@ module RestrictedSignup def validate_admin_signup_restrictions(email) return if allowed_domain?(email) + error_type = fetch_error_type(email) + + return unless error_type.present? + + [ + signup_email_invalid_message, + error_message[created_by_key][error_type] + ].join(' ') + end + + def fetch_error_type(email) if allowlist_present? - return _('domain is not authorized for sign-up.') + :allowlist elsif denied_domain?(email) - return _('is not from an allowed domain.') + :denylist elsif restricted_email?(email) - return _('is not allowed. Try again with a different email address, or contact your GitLab admin.') + :restricted end + end + + def error_message + { + admin: { + allowlist: html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check 'Allowed domains for sign-ups'.")).html_safe, + denylist: html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check the 'Domain denylist'.")).html_safe, + restricted: html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check 'Email restrictions for sign-ups'.")).html_safe, + group_setting: html_escape_once(_("Go to the group’s 'Settings > General' page, and check 'Restrict membership by email domain'.")).html_safe + }, + nonadmin: { + allowlist: error_nonadmin, + denylist: error_nonadmin, + restricted: error_nonadmin, + group_setting: error_nonadmin + } + } + end + + def error_nonadmin + _("Check with your administrator.") + end - nil + def created_by_key + created_by&.can_admin_all_resources? ? :admin : :nonadmin end def denied_domain?(email) diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 847abdc1b6d..f382b3624ed 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -41,7 +41,7 @@ module Routable has_one :route, as: :source, autosave: true, dependent: :destroy, inverse_of: :source # rubocop:disable Cop/ActiveRecordDependent has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - validates :route, presence: true + validates :route, presence: true, unless: -> { is_a?(Namespaces::ProjectNamespace) } scope :with_route, -> { includes(:route) } @@ -185,6 +185,7 @@ module Routable def prepare_route return unless full_path_changed? || full_name_changed? + return if is_a?(Namespaces::ProjectNamespace) route || build_route(source: self) route.path = build_full_path diff --git a/app/models/concerns/ttl_expirable.rb b/app/models/concerns/ttl_expirable.rb new file mode 100644 index 00000000000..00abe0a06e6 --- /dev/null +++ b/app/models/concerns/ttl_expirable.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module TtlExpirable + extend ActiveSupport::Concern + + included do + validates :status, presence: true + + enum status: { default: 0, expired: 1, processing: 2, error: 3 } + + scope :updated_before, ->(number_of_days) { where("updated_at <= ?", Time.zone.now - number_of_days.days) } + scope :active, -> { where(status: :default) } + + scope :lock_next_by, ->(sort) do + order(sort) + .limit(1) + .lock('FOR UPDATE SKIP LOCKED') + end + end +end diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index a656856487d..7f96b3901f1 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -2,6 +2,15 @@ module VulnerabilityFindingHelpers extend ActiveSupport::Concern + + # Manually resolvable report types cannot be considered fixed once removed from the + # target branch due to requiring active triage, such as rotation of an exposed token. + REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION = %w[secret_detection].freeze + + def requires_manual_resolution? + REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION.include?(report_type) + end + def matches_signatures(other_signatures, other_uuid) other_signature_types = other_signatures.index_by(&:algorithm_type) diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb index 9bacd9a0edf..aecb47f7a03 100644 --- a/app/models/container_expiration_policy.rb +++ b/app/models/container_expiration_policy.rb @@ -74,6 +74,7 @@ class ContainerExpirationPolicy < ApplicationRecord '7d': _('%{days} days until tags are automatically removed') % { days: 7 }, '14d': _('%{days} days until tags are automatically removed') % { days: 14 }, '30d': _('%{days} days until tags are automatically removed') % { days: 30 }, + '60d': _('%{days} days until tags are automatically removed') % { days: 60 }, '90d': _('%{days} days until tags are automatically removed') % { days: 90 } } end diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index aea48a5ec20..ecdac64b31b 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -5,7 +5,7 @@ class CustomEmoji < ApplicationRecord belongs_to :namespace, inverse_of: :custom_emoji - belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id' + belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'namespace_id' belongs_to :creator, class_name: "User", inverse_of: :created_custom_emoji # For now only external emoji are supported. See https://gitlab.com/gitlab-org/gitlab/-/issues/230467 diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index aaa7e2ae175..c632f8e2efa 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -5,8 +5,9 @@ class CustomerRelations::Contact < ApplicationRecord self.table_name = "customer_relations_contacts" - belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'group_id' + belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'group_id' belongs_to :organization, optional: true + has_and_belongs_to_many :issues, join_table: :issue_customer_relations_contacts # rubocop: disable Rails/HasAndBelongsToMany strip_attributes! :phone, :first_name, :last_name diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb index a18d3ab8148..c206d1e05f5 100644 --- a/app/models/customer_relations/organization.rb +++ b/app/models/customer_relations/organization.rb @@ -5,7 +5,7 @@ class CustomerRelations::Organization < ApplicationRecord self.table_name = "customer_relations_organizations" - belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'group_id' + belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'group_id' strip_attributes! :name diff --git a/app/models/dependency_proxy/blob.rb b/app/models/dependency_proxy/blob.rb index 5de6b1cf28f..7ca15652586 100644 --- a/app/models/dependency_proxy/blob.rb +++ b/app/models/dependency_proxy/blob.rb @@ -2,15 +2,14 @@ class DependencyProxy::Blob < ApplicationRecord include FileStoreMounter + include TtlExpirable + include EachBatch belongs_to :group validates :group, presence: true validates :file, presence: true validates :file_name, presence: true - validates :status, presence: true - - enum status: { default: 0, expired: 1 } mount_file_store_uploader DependencyProxy::FileUploader diff --git a/app/models/dependency_proxy/image_ttl_group_policy.rb b/app/models/dependency_proxy/image_ttl_group_policy.rb index 5a1b8cb8f1f..0dfb298a39e 100644 --- a/app/models/dependency_proxy/image_ttl_group_policy.rb +++ b/app/models/dependency_proxy/image_ttl_group_policy.rb @@ -8,4 +8,6 @@ class DependencyProxy::ImageTtlGroupPolicy < ApplicationRecord validates :group, presence: true validates :enabled, inclusion: { in: [true, false] } validates :ttl, numericality: { greater_than: 0 }, allow_nil: true + + scope :enabled, -> { where(enabled: true) } end diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb index 15e5137b50a..b83047efe54 100644 --- a/app/models/dependency_proxy/manifest.rb +++ b/app/models/dependency_proxy/manifest.rb @@ -2,6 +2,8 @@ class DependencyProxy::Manifest < ApplicationRecord include FileStoreMounter + include TtlExpirable + include EachBatch belongs_to :group @@ -9,9 +11,6 @@ class DependencyProxy::Manifest < ApplicationRecord validates :file, presence: true validates :file_name, presence: true validates :digest, presence: true - validates :status, presence: true - - enum status: { default: 0, expired: 1 } mount_file_store_uploader DependencyProxy::FileUploader diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 4a690ccc67e..f91700f764b 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -10,7 +10,8 @@ class Deployment < ApplicationRecord include FastDestroyAll include IgnorableColumns - ignore_column :deployable_id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22' + StatusUpdateError = Class.new(StandardError) + StatusSyncError = Class.new(StandardError) belongs_to :project, required: true belongs_to :environment, required: true @@ -48,7 +49,6 @@ class Deployment < ApplicationRecord scope :stoppable, -> { where.not(on_stop: nil).where.not(deployable_id: nil).success } scope :active, -> { where(status: %i[created running]) } scope :older_than, -> (deployment) { where('deployments.id < ?', deployment.id) } - scope :with_deployable, -> { joins('INNER JOIN ci_builds ON ci_builds.id = deployments.deployable_id').preload(:deployable) } scope :with_api_entity_associations, -> { preload({ deployable: { runner: [], tags: [], user: [], job_artifacts_archive: [] } }) } scope :finished_after, ->(date) { where('finished_at >= ?', date) } @@ -150,6 +150,16 @@ class Deployment < ApplicationRecord success.find_by!(iid: iid) end + # It should be used with caution especially on chaining. + # Fetching any unbounded or large intermediate dataset could lead to loading too many IDs into memory. + # See: https://docs.gitlab.com/ee/development/database/multiple_databases.html#use-disable_joins-for-has_one-or-has_many-through-relations + # For safety we default limit to fetch not more than 1000 records. + def self.builds(limit = 1000) + deployable_ids = where.not(deployable_id: nil).limit(limit).pluck(:deployable_id) + + Ci::Build.where(id: deployable_ids) + end + class << self ## # FastDestroyAll concerns @@ -305,20 +315,23 @@ class Deployment < ApplicationRecord # Changes the status of a deployment and triggers the corresponding state # machine events. def update_status(status) - case status - when 'running' - run - when 'success' - succeed - when 'failed' - drop - when 'canceled' - cancel - when 'skipped' - skip - else - raise ArgumentError, "The status #{status.inspect} is invalid" - end + update_status!(status) + rescue StandardError => e + Gitlab::ErrorTracking.track_exception( + StatusUpdateError.new(e.message), deployment_id: self.id) + + false + end + + def sync_status_with(build) + return false unless ::Deployment.statuses.include?(build.status) + + update_status!(build.status) + rescue StandardError => e + Gitlab::ErrorTracking.track_exception( + StatusSyncError.new(e.message), deployment_id: self.id, build_id: build.id) + + false end def valid_sha @@ -346,6 +359,23 @@ class Deployment < ApplicationRecord private + def update_status!(status) + case status + when 'running' + run! + when 'success' + succeed! + when 'failed' + drop! + when 'canceled' + cancel! + when 'skipped' + skip! + else + raise ArgumentError, "The status #{status.inspect} is invalid" + end + end + def legacy_finished_at self.created_at if success? && !read_attribute(:finished_at) end diff --git a/app/models/environment.rb b/app/models/environment.rb index 48522a23068..31ab426728b 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -28,8 +28,8 @@ class Environment < ApplicationRecord has_one :last_deployment, -> { success.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment' - has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus', disable_joins: -> { ::Feature.enabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml) } - has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: -> { ::Feature.enabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml) } + has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus', disable_joins: true + has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: true has_one :upcoming_deployment, -> { running.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment @@ -198,14 +198,14 @@ class Environment < ApplicationRecord # Overriding association def last_visible_deployable - return super if association_cached?(:last_visible_deployable) || ::Feature.disabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml) + return super if association_cached?(:last_visible_deployable) last_visible_deployment&.deployable end # Overriding association def last_visible_pipeline - return super if association_cached?(:last_visible_pipeline) || ::Feature.disabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml) + return super if association_cached?(:last_visible_pipeline) last_visible_deployable&.pipeline end @@ -260,10 +260,9 @@ class Environment < ApplicationRecord end def cancel_deployment_jobs! - jobs = active_deployments.with_deployable - jobs.each do |deployment| - Gitlab::OptimisticLocking.retry_lock(deployment.deployable, name: 'environment_cancel_deployment_jobs') do |deployable| - deployable.cancel! if deployable&.cancelable? + active_deployments.builds.each do |build| + Gitlab::OptimisticLocking.retry_lock(build, name: 'environment_cancel_deployment_jobs') do |build| + build.cancel! if build&.cancelable? end rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, environment_id: id, deployment_id: deployment.id) diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb index 3be7af2e4bf..07c0983f239 100644 --- a/app/models/environment_status.rb +++ b/app/models/environment_status.rb @@ -100,13 +100,11 @@ class EnvironmentStatus def self.build_environments_status(mr, user, pipeline) return [] unless pipeline - ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/340781') do - pipeline.environments_in_self_and_descendants.includes(:project).available.map do |environment| - next unless Ability.allowed?(user, :read_environment, environment) + pipeline.environments_in_self_and_descendants.includes(:project).available.map do |environment| + next unless Ability.allowed?(user, :read_environment, environment) - EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha) - end.compact - end + EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha) + end.compact end private_class_method :build_environments_status end diff --git a/app/models/error_tracking/error.rb b/app/models/error_tracking/error.rb index 39ecc487806..2d6a4694def 100644 --- a/app/models/error_tracking/error.rb +++ b/app/models/error_tracking/error.rb @@ -7,6 +7,14 @@ class ErrorTracking::Error < ApplicationRecord has_many :events, class_name: 'ErrorTracking::ErrorEvent' + has_one :first_event, + -> { order(id: :asc) }, + class_name: 'ErrorTracking::ErrorEvent' + + has_one :last_event, + -> { order(id: :desc) }, + class_name: 'ErrorTracking::ErrorEvent' + scope :for_status, -> (status) { where(status: status) } validates :project, presence: true @@ -90,7 +98,10 @@ class ErrorTracking::Error < ApplicationRecord status: status, tags: { level: nil, logger: nil }, external_url: external_url, - external_base_url: external_base_url + external_base_url: external_base_url, + integrated: true, + first_release_version: first_event&.release, + last_release_version: last_event&.release ) end @@ -106,6 +117,6 @@ class ErrorTracking::Error < ApplicationRecord # For compatibility with sentry integration def external_base_url - Gitlab::Routing.url_helpers.root_url + Gitlab::Routing.url_helpers.project_url(project) end end diff --git a/app/models/error_tracking/error_event.rb b/app/models/error_tracking/error_event.rb index 4de13de7e2e..686518a39fb 100644 --- a/app/models/error_tracking/error_event.rb +++ b/app/models/error_tracking/error_event.rb @@ -22,6 +22,10 @@ class ErrorTracking::ErrorEvent < ApplicationRecord ) end + def release + payload.dig('release') + end + private def build_stacktrace diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index dd5ce9f7387..25f812645b1 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -46,6 +46,11 @@ module ErrorTracking after_save :clear_reactive_cache! + # When a user enables the integrated error tracking + # we want to immediately provide them with a first + # working client key so they have a DSN for Sentry SDK. + after_save :create_client_key! + def sentry_enabled enabled && !integrated_client? end @@ -54,6 +59,12 @@ module ErrorTracking integrated end + def gitlab_dsn + strong_memoize(:gitlab_dsn) do + client_key&.sentry_dsn + end + end + def api_url=(value) super clear_memoization(:api_url_slugs) @@ -236,5 +247,19 @@ module ErrorTracking errors.add(:project, 'is a required field') end end + + def client_key + # Project can have multiple client keys. + # However for UI simplicity we render the first active one for user. + # In future we should make it possible to manage client keys from UI. + # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/329596 + project.error_tracking_client_keys.active.first + end + + def create_client_key! + if enabled? && integrated_client? && !client_key + project.error_tracking_client_keys.create! + end + end end end diff --git a/app/models/group.rb b/app/models/group.rb index a667a908707..c5e119451e3 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -192,9 +192,15 @@ class Group < Namespace # Returns the ids of the passed group models where the `emails_disabled` # column is set to true anywhere in the ancestor hierarchy. def ids_with_disabled_email(groups) - innner_query = Gitlab::ObjectHierarchy - .new(Group.where('id = namespaces_with_emails_disabled.id')) - .base_and_ancestors + inner_groups = Group.where('id = namespaces_with_emails_disabled.id') + + inner_ancestors = if Feature.enabled?(:linear_group_ancestor_scopes, default_enabled: :yaml) + inner_groups.self_and_ancestors + else + Gitlab::ObjectHierarchy.new(inner_groups).base_and_ancestors + end + + inner_query = inner_ancestors .where(emails_disabled: true) .select('1') .limit(1) @@ -202,7 +208,7 @@ class Group < Namespace group_ids = Namespace .from('(SELECT * FROM namespaces) as namespaces_with_emails_disabled') .where(namespaces_with_emails_disabled: { id: groups }) - .where('EXISTS (?)', innner_query) + .where('EXISTS (?)', inner_query) .pluck(:id) Set.new(group_ids) @@ -701,9 +707,9 @@ class Group < Namespace raise ArgumentError unless SHARED_RUNNERS_SETTINGS.include?(state) case state - when 'disabled_and_unoverridable' then disable_shared_runners! # also disallows override - when 'disabled_with_override' then disable_shared_runners_and_allow_override! - when 'enabled' then enable_shared_runners! # set both to true + when SR_DISABLED_AND_UNOVERRIDABLE then disable_shared_runners! # also disallows override + when SR_DISABLED_WITH_OVERRIDE then disable_shared_runners_and_allow_override! + when SR_ENABLED then enable_shared_runners! # set both to true end end diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb index 9565dae08b5..0bf9e805aa8 100644 --- a/app/models/instance_configuration.rb +++ b/app/models/instance_configuration.rb @@ -22,7 +22,12 @@ class InstanceConfiguration private def ssh_algorithms_hashes - SSH_ALGORITHMS.map { |algo| ssh_algorithm_hashes(algo) }.compact + SSH_ALGORITHMS.select { |algo| ssh_algorithm_enabled?(algo) }.map { |algo| ssh_algorithm_hashes(algo) }.compact + end + + def ssh_algorithm_enabled?(algorithm) + algorithm_key_restriction = application_settings["#{algorithm.downcase}_key_restriction"] + algorithm_key_restriction.nil? || algorithm_key_restriction != ApplicationSetting::FORBIDDEN_KEY_VALUE end def host @@ -98,6 +103,11 @@ class InstanceConfiguration requests_per_period: application_settings[:throttle_authenticated_packages_api_requests_per_period], period_in_seconds: application_settings[:throttle_authenticated_packages_api_period_in_seconds] }, + authenticated_git_lfs_api: { + enabled: application_settings[:throttle_authenticated_git_lfs_enabled], + requests_per_period: application_settings[:throttle_authenticated_git_lfs_requests_per_period], + period_in_seconds: application_settings[:throttle_authenticated_git_lfs_period_in_seconds] + }, issue_creation: application_setting_limit_per_minute(:issues_create_limit), note_creation: application_setting_limit_per_minute(:notes_create_limit), project_export: application_setting_limit_per_minute(:project_export_limit), diff --git a/app/models/integrations/open_project.rb b/app/models/integrations/open_project.rb deleted file mode 100644 index e4cfb24151a..00000000000 --- a/app/models/integrations/open_project.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Integrations - class OpenProject < BaseIssueTracker - validates :url, public_url: true, presence: true, if: :activated? - validates :api_url, public_url: true, allow_blank: true, if: :activated? - validates :token, presence: true, if: :activated? - validates :project_identifier_code, presence: true, if: :activated? - - data_field :url, :api_url, :token, :closed_status_id, :project_identifier_code - - def data_fields - open_project_tracker_data || self.build_open_project_tracker_data - end - - def self.to_param - 'open_project' - end - end -end diff --git a/app/models/integrations/open_project_tracker_data.rb b/app/models/integrations/open_project_tracker_data.rb deleted file mode 100644 index b3f2618b94f..00000000000 --- a/app/models/integrations/open_project_tracker_data.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Integrations - class OpenProjectTrackerData < ApplicationRecord - include BaseDataFields - - # When the Open Project is fresh installed, the default closed status id is "13" based on current version: v8. - DEFAULT_CLOSED_STATUS_ID = "13" - - attr_encrypted :url, encryption_options - attr_encrypted :api_url, encryption_options - attr_encrypted :token, encryption_options - - def closed_status_id - super || DEFAULT_CLOSED_STATUS_ID - end - end -end diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index ad6a9164d00..e3e180ae959 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -15,13 +15,8 @@ module Integrations end def help - 'This service sends notifications about projects events to a Unify Circuit conversation.<br /> - To set up this service: - <ol> - <li><a href="https://www.circuit.com/unifyportalfaqdetail?articleId=164448" target="_blank" rel="noopener noreferrer">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li> - <li>Paste the <strong>Webhook URL</strong> into the field below.</li> - <li>Select events below to enable notifications.</li> - </ol>' + docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/unify_circuit'), target: '_blank', rel: 'noopener noreferrer' + s_('Integrations|Send notifications about project events to a Unify Circuit conversation. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end def event_field(event) @@ -37,7 +32,7 @@ module Integrations def default_fields [ - { type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true }, + { type: 'text', name: 'webhook', placeholder: "https://yourcircuit.com/rest/v2/webhooks/incoming/…", required: true }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] diff --git a/app/models/issue.rb b/app/models/issue.rb index e0b0c352c22..9c568414ec2 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -81,6 +81,7 @@ class Issue < ApplicationRecord has_and_belongs_to_many :self_managed_prometheus_alert_events, join_table: :issues_self_managed_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany has_and_belongs_to_many :prometheus_alert_events, join_table: :issues_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany has_many :prometheus_alerts, through: :prometheus_alert_events + has_and_belongs_to_many :customer_relations_contacts, join_table: :issue_customer_relations_contacts, class_name: 'CustomerRelations::Contact' # rubocop: disable Rails/HasAndBelongsToMany accepts_nested_attributes_for :issuable_severity, update_only: true accepts_nested_attributes_for :sentry_issue @@ -107,8 +108,6 @@ class Issue < ApplicationRecord scope :order_due_date_asc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'ASC')) } scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) } scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) } - scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) } - scope :order_relative_position_desc, -> { reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')) } scope :order_closed_date_desc, -> { reorder(closed_at: :desc) } scope :order_created_at_desc, -> { reorder(created_at: :desc) } scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') } @@ -127,6 +126,7 @@ class Issue < ApplicationRecord project: [:route, { namespace: :route }]) } scope :with_issue_type, ->(types) { where(issue_type: types) } + scope :without_issue_type, ->(types) { where.not(issue_type: types) } scope :public_only, -> { without_hidden.where(confidential: false) @@ -166,6 +166,8 @@ class Issue < ApplicationRecord scope :by_project_id_and_iid, ->(composites) do where_composite(%i[project_id iid], composites) end + scope :with_null_relative_position, -> { where(relative_position: nil) } + scope :with_non_null_relative_position, -> { where.not(relative_position: nil) } after_commit :expire_etag_cache, unless: :importing? after_save :ensure_metrics, unless: :importing? @@ -266,8 +268,8 @@ class Issue < ApplicationRecord 'due_date' => -> { order_due_date_asc.with_order_id_desc }, 'due_date_asc' => -> { order_due_date_asc.with_order_id_desc }, 'due_date_desc' => -> { order_due_date_desc.with_order_id_desc }, - 'relative_position' => -> { order_relative_position_asc.with_order_id_desc }, - 'relative_position_asc' => -> { order_relative_position_asc.with_order_id_desc } + 'relative_position' => -> { order_by_relative_position }, + 'relative_position_asc' => -> { order_by_relative_position } } ) end @@ -277,7 +279,7 @@ class Issue < ApplicationRecord when 'closest_future_date', 'closest_future_date_asc' then order_closest_future_date when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc when 'due_date_desc' then order_due_date_desc.with_order_id_desc - when 'relative_position', 'relative_position_asc' then order_relative_position_asc.with_order_id_desc + when 'relative_position', 'relative_position_asc' then order_by_relative_position when 'severity_asc' then order_severity_asc.with_order_id_desc when 'severity_desc' then order_severity_desc.with_order_id_desc else @@ -285,13 +287,8 @@ class Issue < ApplicationRecord end end - # `with_cte` argument allows sorting when using CTE queries and prevents - # errors in postgres when using CTE search optimisation - def self.order_by_position_and_priority(with_cte: false) - order = Gitlab::Pagination::Keyset::Order.build([column_order_relative_position, column_order_highest_priority, column_order_id_desc]) - - order_labels_priority(with_cte: with_cte) - .reorder(order) + def self.order_by_relative_position + reorder(Gitlab::Pagination::Keyset::Order.build([column_order_relative_position, column_order_id_asc])) end def self.column_order_relative_position @@ -306,25 +303,6 @@ class Issue < ApplicationRecord ) end - def self.column_order_highest_priority - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'highest_priority', - column_expression: Arel.sql('highest_priorities.label_priority'), - order_expression: Gitlab::Database.nulls_last_order('highest_priorities.label_priority', 'ASC'), - reversed_order_expression: Gitlab::Database.nulls_last_order('highest_priorities.label_priority', 'DESC'), - order_direction: :asc, - nullable: :nulls_last, - distinct: false - ) - end - - def self.column_order_id_desc - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'id', - order_expression: arel_table[:id].desc - ) - end - def self.column_order_id_asc Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'id', @@ -541,6 +519,10 @@ class Issue < ApplicationRecord issue_type_supports?(:time_tracking) end + def supports_move_and_clone? + issue_type_supports?(:move_and_clone) + end + def email_participants_emails issue_email_participants.pluck(:email) end diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index 53e7d52c558..9765ac6f2e9 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -49,7 +49,7 @@ class LfsObject < ApplicationRecord end def self.calculate_oid(path) - self.hexdigest(path) + self.sha256_hexdigest(path) end end diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb index a39d88b2e49..ca5a2800a03 100644 --- a/app/models/loose_foreign_keys/deleted_record.rb +++ b/app/models/loose_foreign_keys/deleted_record.rb @@ -2,48 +2,4 @@ class LooseForeignKeys::DeletedRecord < ApplicationRecord extend SuppressCompositePrimaryKeyWarning - include PartitionedTable - - partitioned_by :created_at, strategy: :monthly, retain_for: 3.months, retain_non_empty_partitions: true - - scope :ordered_by_primary_keys, -> { order(:created_at, :deleted_table_name, :deleted_table_primary_key_value) } - - def self.load_batch(batch_size) - ordered_by_primary_keys - .limit(batch_size) - .to_a - end - - # Because the table has composite primary keys, the delete_all or delete methods are not going to work. - # This method implements deletion that benefits from the primary key index, example: - # - # > DELETE - # > FROM "loose_foreign_keys_deleted_records" - # > WHERE (created_at, - # > deleted_table_name, - # > deleted_table_primary_key_value) IN - # > (SELECT created_at::TIMESTAMP WITH TIME ZONE, - # > deleted_table_name, - # > deleted_table_primary_key_value - # > FROM (VALUES (LIST_OF_VALUES)) AS primary_key_values (created_at, deleted_table_name, deleted_table_primary_key_value)) - def self.delete_records(records) - values = records.pluck(:created_at, :deleted_table_name, :deleted_table_primary_key_value) - - primary_keys = connection.primary_keys(table_name).join(', ') - - primary_keys_with_type_cast = [ - Arel.sql('created_at::timestamp with time zone'), - Arel.sql('deleted_table_name'), - Arel.sql('deleted_table_primary_key_value') - ] - - value_list = Arel::Nodes::ValuesList.new(values) - - # (SELECT primary keys FROM VALUES) - inner_query = Arel::SelectManager.new - inner_query.from("#{Arel::Nodes::Grouping.new([value_list]).as('primary_key_values').to_sql} (#{primary_keys})") - inner_query.projections = primary_keys_with_type_cast - - where(Arel::Nodes::Grouping.new([Arel.sql(primary_keys)]).in(inner_query)).delete_all - end end diff --git a/app/models/member.rb b/app/models/member.rb index beb4c05f2a6..21fd4aebd7b 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -50,6 +50,11 @@ class Member < ApplicationRecord }, if: :project_bot? + scope :with_invited_user_state, -> do + joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email') + .select('members.*', 'invited_user.state as invited_user_state') + end + scope :in_hierarchy, ->(source) do groups = source.root_ancestor.self_and_descendants group_members = Member.default_scoped.where(source: groups) @@ -178,7 +183,13 @@ class Member < ApplicationRecord after_destroy :post_destroy_hook, unless: :pending?, if: :hook_prerequisites_met? after_save :log_invitation_token_cleanup - after_commit :refresh_member_authorized_projects, unless: :importing? + after_commit on: [:create, :update], unless: :importing? do + refresh_member_authorized_projects(blocking: true) + end + + after_commit on: [:destroy], unless: :importing? do + refresh_member_authorized_projects(blocking: false) + end default_value_for :notification_level, NotificationSetting.levels[:global] @@ -395,8 +406,8 @@ class Member < ApplicationRecord # transaction has been committed, resulting in the job either throwing an # error or not doing any meaningful work. # rubocop: disable CodeReuse/ServiceClass - def refresh_member_authorized_projects - UserProjectAccessChangedService.new(user_id).execute + def refresh_member_authorized_projects(blocking:) + UserProjectAccessChangedService.new(user_id).execute(blocking: blocking) end # rubocop: enable CodeReuse/ServiceClass @@ -442,6 +453,14 @@ class Member < ApplicationRecord errors.add(:user, error) if error end + def signup_email_invalid_message + if source_type == 'Project' + _("is not allowed for this project.") + else + _("is not allowed for this group.") + end + end + def update_highest_role? return unless user_id.present? diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index a13133c90e9..9062a405218 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -43,15 +43,17 @@ class GroupMember < Member # Because source_type is `Namespace`... def real_source_type - 'Group' + Group.sti_name end def notifiable_options { group: group } end + private + override :refresh_member_authorized_projects - def refresh_member_authorized_projects + def refresh_member_authorized_projects(blocking:) # Here, `destroyed_by_association` will be present if the # GroupMember is being destroyed due to the `dependent: :destroy` # callback on Group. In this case, there is no need to refresh the @@ -63,8 +65,6 @@ class GroupMember < Member super end - private - def access_level_inclusion return if access_level.in?(Gitlab::Access.all_values) diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 72cb831cc88..eec46b3493e 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -90,24 +90,28 @@ class ProjectMember < Member { project: project } end + private + override :refresh_member_authorized_projects - def refresh_member_authorized_projects + def refresh_member_authorized_projects(blocking:) return super unless Feature.enabled?(:specialized_service_for_project_member_auth_refresh) return unless user # rubocop:disable CodeReuse/ServiceClass - AuthorizedProjectUpdate::ProjectRecalculatePerUserService.new(project, user).execute + if blocking + AuthorizedProjectUpdate::ProjectRecalculatePerUserService.new(project, user).execute + else + AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.perform_async(project.id, user.id) + end # Until we compare the inconsistency rates of the new, specialized service and # the old approach, we still run AuthorizedProjectsWorker # but with some delay and lower urgency as a safety net. UserProjectAccessChangedService.new(user_id) - .execute(blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY) + .execute(blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY) # rubocop:enable CodeReuse/ServiceClass end - private - def send_invite run_after_commit_or_now { notification_service.invite_project_member(self, @raw_invite_token) } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index db49ec6f412..15862fb2bfa 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1111,15 +1111,23 @@ class MergeRequest < ApplicationRecord can_be_merged? && !should_be_rebased? end + # rubocop: disable CodeReuse/ServiceClass def mergeable_state?(skip_ci_check: false, skip_discussions_check: false) return false unless open? return false if work_in_progress? return false if broken? - return false unless skip_ci_check || mergeable_ci_state? return false unless skip_discussions_check || mergeable_discussions_state? - true + if Feature.enabled?(:improved_mergeability_checks, self.project, default_enabled: :yaml) + additional_checks = MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: { skip_ci_check: skip_ci_check }) + additional_checks.execute.all?(&:success?) + else + return false unless skip_ci_check || mergeable_ci_state? + + true + end end + # rubocop: enable CodeReuse/ServiceClass def ff_merge_possible? project.repository.ancestor?(target_branch_sha, diff_head_sha) @@ -1658,6 +1666,10 @@ class MergeRequest < ApplicationRecord service_class.new(project, current_user, id: id, report_type: report_type).execute(comparison_base_pipeline(identifier), actual_head_pipeline) end + def recent_diff_head_shas(limit = 100) + merge_request_diffs.recent(limit).pluck(:head_commit_sha) + end + def all_commits MergeRequestDiffCommit .where(merge_request_diff: merge_request_diffs.recent) @@ -1857,7 +1869,7 @@ class MergeRequest < ApplicationRecord override :ensure_metrics def ensure_metrics - if Feature.enabled?(:use_upsert_query_for_mr_metrics) + if Feature.enabled?(:use_upsert_query_for_mr_metrics, default_enabled: :yaml) MergeRequest::Metrics.record!(self) else # Backward compatibility: some merge request metrics records will not have target_project_id filled in. @@ -1918,20 +1930,6 @@ class MergeRequest < ApplicationRecord end end - def lazy_upvotes_count - BatchLoader.for(id).batch(default_value: 0) do |ids, loader| - counts = AwardEmoji - .where(awardable_id: ids) - .upvotes - .group(:awardable_id) - .count - - counts.each do |id, count| - loader.call(id, count) - end - end - end - private def set_draft_status diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index d2b3ca753b1..bd94c0ad30e 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -66,7 +66,7 @@ class MergeRequestDiff < ApplicationRecord joins(:merge_request).where(merge_requests: { target_project_id: project_id }) end - scope :recent, -> { order(id: :desc).limit(100) } + scope :recent, -> (limit = 100) { order(id: :desc).limit(limit) } scope :files_in_database, -> do where(stored_externally: [false, nil]).where(arel_table[:files_count].gt(0)) diff --git a/app/models/metrics/dashboard/annotation.rb b/app/models/metrics/dashboard/annotation.rb index 3383dda20c9..d3d3f973398 100644 --- a/app/models/metrics/dashboard/annotation.rb +++ b/app/models/metrics/dashboard/annotation.rb @@ -32,19 +32,19 @@ module Metrics def ending_at_after_starting_at return if ending_at.blank? || starting_at.blank? || starting_at <= ending_at - errors.add(:ending_at, s_("Metrics::Dashboard::Annotation|can't be before starting_at time")) + errors.add(:ending_at, s_("MetricsDashboardAnnotation|can't be before starting_at time")) end def single_ownership return if cluster.nil? ^ environment.nil? - errors.add(:base, s_("Metrics::Dashboard::Annotation|Annotation can't belong to both a cluster and an environment at the same time")) + errors.add(:base, s_("MetricsDashboardAnnotation|Annotation can't belong to both a cluster and an environment at the same time")) end def orphaned_annotation return if cluster.present? || environment.present? - errors.add(:base, s_("Metrics::Dashboard::Annotation|Annotation must belong to a cluster or an environment")) + errors.add(:base, s_("MetricsDashboardAnnotation|Annotation must belong to a cluster or an environment")) end end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 0c160cedb4d..e6406293c66 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -28,7 +28,10 @@ class Namespace < ApplicationRecord # Android repo (15) + some extra backup. NUMBER_OF_ANCESTORS_ALLOWED = 20 - SHARED_RUNNERS_SETTINGS = %w[disabled_and_unoverridable disabled_with_override enabled].freeze + SR_DISABLED_AND_UNOVERRIDABLE = 'disabled_and_unoverridable' + SR_DISABLED_WITH_OVERRIDE = 'disabled_with_override' + SR_ENABLED = 'enabled' + SHARED_RUNNERS_SETTINGS = [SR_DISABLED_AND_UNOVERRIDABLE, SR_DISABLED_WITH_OVERRIDE, SR_ENABLED].freeze URL_MAX_LENGTH = 255 cache_markdown_field :description, pipeline: :description @@ -44,6 +47,8 @@ class Namespace < ApplicationRecord # This should _not_ be `inverse_of: :namespace`, because that would also set # `user.namespace` when this user creates a group with themselves as `owner`. + # TODO: can this be moved into the UserNamespace class? + # evaluate in issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 belongs_to :owner, class_name: "User" belongs_to :parent, class_name: "Namespace" @@ -63,21 +68,31 @@ class Namespace < ApplicationRecord length: { maximum: 255 } validates :description, length: { maximum: 255 } + validates :path, presence: true, - length: { maximum: URL_MAX_LENGTH }, - namespace_path: true + length: { maximum: URL_MAX_LENGTH } + + validates :path, namespace_path: true, if: ->(n) { !n.project_namespace? } + # Project path validator is used for project namespaces for now to assure + # compatibility with project paths + # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/341764 + validates :path, project_path: true, if: ->(n) { n.project_namespace? } # Introduce minimal path length of 2 characters. # Allow change of other attributes without forcing users to # rename their user or group. At the same time prevent changing # the path without complying with new 2 chars requirement. # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/225214 - validates :path, length: { minimum: 2 }, if: :path_changed? + # + # For ProjectNamespace we don't check minimal path length to keep + # compatibility with existing project restrictions. + # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/341764 + validates :path, length: { minimum: 2 }, if: :enforce_minimum_path_length? validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } - validate :validate_parent_type, if: -> { Feature.enabled?(:validate_namespace_parent_type) } + validate :validate_parent_type, if: -> { Feature.enabled?(:validate_namespace_parent_type, default_enabled: :yaml) } validate :nesting_level_allowed validate :changing_shared_runners_enabled_is_allowed validate :changing_allow_descendants_override_disabled_shared_runners_is_allowed @@ -93,7 +108,7 @@ class Namespace < ApplicationRecord # Legacy Storage specific hooks - after_update :move_dir, if: :saved_change_to_path_or_parent? + after_update :move_dir, if: :saved_change_to_path_or_parent?, unless: -> { is_a?(Namespaces::ProjectNamespace) } before_destroy(prepend: true) { prepare_for_destroy } after_destroy :rm_dir after_commit :expire_child_caches, on: :update, if: -> { @@ -101,7 +116,12 @@ class Namespace < ApplicationRecord saved_change_to_name? || saved_change_to_path? || saved_change_to_parent_id? } - scope :for_user, -> { where(type: nil) } + # TODO: change to `type: Namespaces::UserNamespace.sti_name` when + # working on issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 + scope :user_namespaces, -> { where(type: [nil, Namespaces::UserNamespace.sti_name]) } + # TODO: this can be simplified with `type != 'Project'` when working on issue + # https://gitlab.com/gitlab-org/gitlab/-/issues/341070 + scope :without_project_namespaces, -> { where("type IS DISTINCT FROM ?", Namespaces::ProjectNamespace.sti_name) } scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) } scope :include_route, -> { includes(:route) } scope :by_parent, -> (parent) { where(parent_id: parent) } @@ -138,14 +158,12 @@ class Namespace < ApplicationRecord class << self def sti_class_for(type_name) case type_name - when 'Group' + when Group.sti_name Group - when 'Project' + when Namespaces::ProjectNamespace.sti_name Namespaces::ProjectNamespace - when 'User' - # TODO: We create a normal Namespace until - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68894 is ready - Namespace + when Namespaces::UserNamespace.sti_name + Namespaces::UserNamespace else Namespace end @@ -247,27 +265,27 @@ class Namespace < ApplicationRecord end def kind - return 'group' if group? - return 'project' if project? + return 'group' if group_namespace? + return 'project' if project_namespace? 'user' # defaults to user end - def group? + def group_namespace? type == Group.sti_name end - def project? + def project_namespace? type == Namespaces::ProjectNamespace.sti_name end - def user? + def user_namespace? # That last bit ensures we're considered a user namespace as a default - type.nil? || type == Namespaces::UserNamespace.sti_name || !(group? || project?) + type.nil? || type == Namespaces::UserNamespace.sti_name || !(group_namespace? || project_namespace?) end def owner_required? - user? + user_namespace? end def find_fork_of(project) @@ -314,7 +332,7 @@ class Namespace < ApplicationRecord # that belongs to this namespace def all_projects if Feature.enabled?(:recursive_approach_for_all_projects, default_enabled: :yaml) - namespace = user? ? self : self_and_descendant_ids + namespace = user_namespace? ? self : self_and_descendant_ids Project.where(namespace: namespace) else Project.inside_path(full_path) @@ -416,7 +434,7 @@ class Namespace < ApplicationRecord def changing_shared_runners_enabled_is_allowed return unless new_record? || changes.has_key?(:shared_runners_enabled) - if shared_runners_enabled && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable' + if shared_runners_enabled && has_parent? && parent.shared_runners_setting == SR_DISABLED_AND_UNOVERRIDABLE errors.add(:shared_runners_enabled, _('cannot be enabled because parent group has shared Runners disabled')) end end @@ -428,30 +446,30 @@ class Namespace < ApplicationRecord errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be changed if shared runners are enabled')) end - if allow_descendants_override_disabled_shared_runners && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable' + if allow_descendants_override_disabled_shared_runners && has_parent? && parent.shared_runners_setting == SR_DISABLED_AND_UNOVERRIDABLE errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be enabled because parent group does not allow it')) end end def shared_runners_setting if shared_runners_enabled - 'enabled' + SR_ENABLED else if allow_descendants_override_disabled_shared_runners - 'disabled_with_override' + SR_DISABLED_WITH_OVERRIDE else - 'disabled_and_unoverridable' + SR_DISABLED_AND_UNOVERRIDABLE end end end def shared_runners_setting_higher_than?(other_setting) - if other_setting == 'enabled' + if other_setting == SR_ENABLED false - elsif other_setting == 'disabled_with_override' - shared_runners_setting == 'enabled' - elsif other_setting == 'disabled_and_unoverridable' - shared_runners_setting == 'enabled' || shared_runners_setting == 'disabled_with_override' + elsif other_setting == SR_DISABLED_WITH_OVERRIDE + shared_runners_setting == SR_ENABLED + elsif other_setting == SR_DISABLED_AND_UNOVERRIDABLE + shared_runners_setting == SR_ENABLED || shared_runners_setting == SR_DISABLED_WITH_OVERRIDE else raise ArgumentError end @@ -536,21 +554,21 @@ class Namespace < ApplicationRecord def validate_parent_type unless has_parent? - if project? + if project_namespace? errors.add(:parent_id, _('must be set for a project namespace')) end return end - if parent.project? + if parent.project_namespace? errors.add(:parent_id, _('project namespace cannot be the parent of another namespace')) end - if user? + if user_namespace? errors.add(:parent_id, _('cannot not be used for user namespace')) - elsif group? - errors.add(:parent_id, _('user namespace cannot be the parent of another namespace')) if parent.user? + elsif group_namespace? + errors.add(:parent_id, _('user namespace cannot be the parent of another namespace')) if parent.user_namespace? end end @@ -575,6 +593,10 @@ class Namespace < ApplicationRecord project.track_project_repository end end + + def enforce_minimum_path_length? + path_changed? && !project_namespace? + end end Namespace.prepend_mod_with('Namespace') diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb index 73061b78637..99e32537595 100644 --- a/app/models/namespace/root_storage_statistics.rb +++ b/app/models/namespace/root_storage_statistics.rb @@ -57,7 +57,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord end def attributes_from_personal_snippets - return {} unless namespace.user? + return {} unless namespace.user_namespace? from_personal_snippets.take.slice(SNIPPETS_SIZE_STAT_NAME) end diff --git a/app/models/namespaces/user_namespace.rb b/app/models/namespaces/user_namespace.rb index 517d68b118d..22b7a0a3b2b 100644 --- a/app/models/namespaces/user_namespace.rb +++ b/app/models/namespaces/user_namespace.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # TODO: currently not created/mapped in the database, will be done in another issue -# https://gitlab.com/gitlab-org/gitlab/-/issues/337102 +# https://gitlab.com/gitlab-org/gitlab/-/issues/341070 module Namespaces class UserNamespace < Namespace def self.sti_name diff --git a/app/models/note.rb b/app/models/note.rb index a8f5c305d9b..37473518892 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -149,7 +149,7 @@ class Note < ApplicationRecord scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) } before_validation :nullify_blank_type, :nullify_blank_line_code - after_save :keep_around_commit, if: :for_project_noteable?, unless: :importing? + after_save :keep_around_commit, if: :for_project_noteable?, unless: -> { importing? || skip_keep_around_commits } after_save :expire_etag_cache, unless: :importing? after_save :touch_noteable, unless: :importing? after_destroy :expire_etag_cache @@ -355,8 +355,6 @@ class Note < ApplicationRecord end def noteable_author?(noteable) - return false unless ::Feature.enabled?(:show_author_on_note, project) - noteable.author == self.author end diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb index 46810749b18..7db396bcad5 100644 --- a/app/models/operations/feature_flag.rb +++ b/app/models/operations/feature_flag.rb @@ -19,13 +19,10 @@ module Operations default_value_for :active, true default_value_for :version, :new_version_flag - # scopes exists only for the first version - has_many :scopes, class_name: 'Operations::FeatureFlagScope' # strategies exists only for the second version has_many :strategies, class_name: 'Operations::FeatureFlags::Strategy' has_many :feature_flag_issues has_many :issues, through: :feature_flag_issues - has_one :default_scope, -> { where(environment_scope: '*') }, class_name: 'Operations::FeatureFlagScope' validates :project, presence: true validates :name, @@ -37,10 +34,7 @@ module Operations } validates :name, uniqueness: { scope: :project_id } validates :description, allow_blank: true, length: 0..255 - validate :first_default_scope, on: :create, if: :has_scopes? - validate :version_associations - accepts_nested_attributes_for :scopes, allow_destroy: true accepts_nested_attributes_for :strategies, allow_destroy: true scope :ordered, -> { order(:name) } @@ -56,7 +50,7 @@ module Operations class << self def preload_relations - preload(:scopes, strategies: :scopes) + preload(strategies: :scopes) end def for_unleash_client(project, environment) @@ -104,13 +98,6 @@ module Operations Ability.issues_readable_by_user(issues, current_user) end - def execute_hooks(current_user) - run_after_commit do - feature_flag_data = Gitlab::DataBuilder::FeatureFlag.build(self, current_user) - project.execute_hooks(feature_flag_data, :feature_flag_hooks) - end - end - def hook_attrs { id: id, @@ -119,27 +106,5 @@ module Operations active: active } end - - private - - def version_associations - if new_version_flag? && scopes.any? - errors.add(:version_associations, 'version 2 feature flags may not have scopes') - end - end - - def first_default_scope - unless scopes.first.environment_scope == '*' - errors.add(:default_scope, 'has to be the first element') - end - end - - def build_default_scope - scopes.build(environment_scope: '*', active: self.active) - end - - def has_scopes? - scopes.any? - end end end diff --git a/app/models/operations/feature_flag_scope.rb b/app/models/operations/feature_flag_scope.rb deleted file mode 100644 index 9068ca0f588..00000000000 --- a/app/models/operations/feature_flag_scope.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -# All of the legacy flags have been removed in 14.1, including all of the -# `operations_feature_flag_scopes` rows. Therefore, this model and the database -# table are unused and should be removed. - -module Operations - class FeatureFlagScope < ApplicationRecord - prepend HasEnvironmentScope - include Gitlab::Utils::StrongMemoize - - self.table_name = 'operations_feature_flag_scopes' - - belongs_to :feature_flag - - validates :environment_scope, uniqueness: { - scope: :feature_flag, - message: "(%{value}) has already been taken" - } - - validates :environment_scope, - if: :default_scope?, on: :update, - inclusion: { in: %w(*), message: 'cannot be changed from default scope' } - - validates :strategies, feature_flag_strategies: true - - before_destroy :prevent_destroy_default_scope, if: :default_scope? - - scope :ordered, -> { order(:id) } - scope :enabled, -> { where(active: true) } - scope :disabled, -> { where(active: false) } - - def self.with_name_and_description - joins(:feature_flag) - .select(FeatureFlag.arel_table[:name], FeatureFlag.arel_table[:description]) - end - - def self.for_unleash_client(project, environment) - select_columns = [ - 'DISTINCT ON (operations_feature_flag_scopes.feature_flag_id) operations_feature_flag_scopes.id', - '(operations_feature_flags.active AND operations_feature_flag_scopes.active) AS active', - 'operations_feature_flag_scopes.strategies', - 'operations_feature_flag_scopes.environment_scope', - 'operations_feature_flag_scopes.created_at', - 'operations_feature_flag_scopes.updated_at' - ] - - select(select_columns) - .with_name_and_description - .where(feature_flag_id: project.operations_feature_flags.select(:id)) - .order(:feature_flag_id) - .on_environment(environment) - .reverse_order - end - - private - - def default_scope? - environment_scope_was == '*' - end - - def prevent_destroy_default_scope - raise ActiveRecord::ReadOnlyRecord, "default scope cannot be destroyed" - end - end -end diff --git a/app/models/packages/composer/cache_file.rb b/app/models/packages/composer/cache_file.rb index ecd7596b989..5222101d171 100644 --- a/app/models/packages/composer/cache_file.rb +++ b/app/models/packages/composer/cache_file.rb @@ -9,15 +9,13 @@ module Packages mount_file_store_uploader Packages::Composer::CacheUploader - belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id' + belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'namespace_id' belongs_to :namespace validates :namespace, presence: true scope :with_namespace, ->(namespace) { where(namespace: namespace) } scope :with_sha, ->(sha) { where(file_sha256: sha) } - scope :expired, -> { where("delete_at <= ?", Time.current) } - scope :without_namespace, -> { where(namespace_id: nil) } end end end diff --git a/app/models/packages/helm/file_metadatum.rb b/app/models/packages/helm/file_metadatum.rb index 1771003d1f9..dfa4ab6df82 100644 --- a/app/models/packages/helm/file_metadatum.rb +++ b/app/models/packages/helm/file_metadatum.rb @@ -12,7 +12,7 @@ module Packages validates :channel, presence: true, - length: { maximum: 63 }, + length: { maximum: 255 }, format: { with: Gitlab::Regex.helm_channel_regex } validates :metadata, diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index c932d0bf800..0c5a155d48a 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -129,18 +129,15 @@ class PagesDomain < ApplicationRecord store = OpenSSL::X509::Store.new store.set_default_paths - # This forces to load all intermediate certificates stored in `certificate` - Tempfile.open('certificate_chain') do |f| - f.write(certificate) - f.flush - store.add_file(f.path) - end - - store.verify(x509) + store.verify(x509, untrusted_ca_certs_bundle) rescue OpenSSL::X509::StoreError false end + def untrusted_ca_certs_bundle + ::Gitlab::X509::Certificate.load_ca_certs_bundle(certificate) + end + def expired? return false unless x509 diff --git a/app/models/preloaders/merge_requests_preloader.rb b/app/models/preloaders/merge_requests_preloader.rb deleted file mode 100644 index cefe8408cab..00000000000 --- a/app/models/preloaders/merge_requests_preloader.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Preloaders - class MergeRequestsPreloader - attr_reader :merge_requests - - def initialize(merge_requests) - @merge_requests = merge_requests - end - - def execute - preloader = ActiveRecord::Associations::Preloader.new - preloader.preload(merge_requests, { target_project: [:project_feature] }) - merge_requests.each do |merge_request| - merge_request.lazy_upvotes_count - end - end - end -end diff --git a/app/models/product_analytics_event.rb b/app/models/product_analytics_event.rb index d2026d3b333..52baa3be6c4 100644 --- a/app/models/product_analytics_event.rb +++ b/app/models/product_analytics_event.rb @@ -20,8 +20,6 @@ class ProductAnalyticsEvent < ApplicationRecord where('collector_tstamp BETWEEN ? AND ? ', today - duration + 1, today + 1) } - scope :by_category_and_action, ->(category, action) { where(se_category: category, se_action: action) } - def self.count_by_graph(graph, days) group(graph).timerange(days).count end diff --git a/app/models/project.rb b/app/models/project.rb index 74ffeef797e..6eb19b4462c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -98,6 +98,7 @@ class Project < ApplicationRecord before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? } before_save :ensure_runners_token + before_save :ensure_project_namespace_in_sync after_save :update_project_statistics, if: :saved_change_to_namespace_id? @@ -128,26 +129,9 @@ class Project < ApplicationRecord after_initialize :use_hashed_storage after_create :check_repository_absence! - # Required during the `ActsAsTaggableOn::Tag -> Topic` migration - # TODO: remove 'acts_as_ordered_taggable_on' and ':topics_acts_as_taggable' in the further process of the migration - # https://gitlab.com/gitlab-org/gitlab/-/issues/335946 - acts_as_ordered_taggable_on :topics - has_many :topics_acts_as_taggable, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") }, - class_name: 'ActsAsTaggableOn::Tag', - through: :topic_taggings, - source: :tag - has_many :project_topics, -> { order(:id) }, class_name: 'Projects::ProjectTopic' has_many :topics, through: :project_topics, class_name: 'Projects::Topic' - # Required during the `ActsAsTaggableOn::Tag -> Topic` migration - # TODO: remove 'topics' in the further process of the migration - # https://gitlab.com/gitlab-org/gitlab/-/issues/335946 - alias_method :topics_new, :topics - def topics - self.topics_acts_as_taggable + self.topics_new - end - attr_accessor :old_path_with_namespace attr_accessor :template_name attr_writer :pipeline_status @@ -159,11 +143,11 @@ class Project < ApplicationRecord # Relations belongs_to :pool_repository belongs_to :creator, class_name: 'User' - belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id' + belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'namespace_id' belongs_to :namespace # Sync deletion via DB Trigger to ensure we do not have # a project without a project_namespace (or vice-versa) - belongs_to :project_namespace, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id', inverse_of: :project + belongs_to :project_namespace, autosave: true, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id', inverse_of: :project alias_method :parent, :namespace alias_attribute :parent_id, :namespace_id @@ -233,6 +217,7 @@ class Project < ApplicationRecord has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :export_jobs, class_name: 'ProjectExportJob' + has_many :bulk_import_exports, class_name: 'BulkImports::Export', inverse_of: :project has_one :project_repository, inverse_of: :project has_one :tracing_setting, class_name: 'ProjectTracingSetting' has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting' @@ -652,15 +637,8 @@ class Project < ApplicationRecord scope :with_topic, ->(topic_name) do topic = Projects::Topic.find_by_name(topic_name) - acts_as_taggable_on_topic = ActsAsTaggableOn::Tag.find_by_name(topic_name) - - return none unless topic || acts_as_taggable_on_topic - - relations = [] - relations << where(id: topic.project_topics.select(:project_id)) if topic - relations << where(id: acts_as_taggable_on_topic.taggings.select(:taggable_id)) if acts_as_taggable_on_topic - Project.from_union(relations) + topic ? where(id: topic.project_topics.select(:project_id)) : none end enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } @@ -678,7 +656,7 @@ class Project < ApplicationRecord mount_uploader :bfg_object_map, AttachmentUploader def self.with_api_entity_associations - preload(:project_feature, :route, :topics, :topics_acts_as_taggable, :group, :timelogs, namespace: [:route, :owner]) + preload(:project_feature, :route, :topics, :group, :timelogs, namespace: [:route, :owner]) end def self.with_web_entity_associations @@ -851,7 +829,7 @@ class Project < ApplicationRecord end def group_ids - joins(:namespace).where(namespaces: { type: 'Group' }).select(:namespace_id) + joins(:namespace).where(namespaces: { type: Group.sti_name }).select(:namespace_id) end # Returns ids of projects with issuables available for given user @@ -1200,7 +1178,7 @@ class Project < ApplicationRecord end def import? - external_import? || forked? || gitlab_project_import? || jira_import? || bare_repository_import? + external_import? || forked? || gitlab_project_import? || jira_import? || bare_repository_import? || gitlab_project_migration? end def external_import? @@ -1223,6 +1201,10 @@ class Project < ApplicationRecord import_type == 'gitlab_project' end + def gitlab_project_migration? + import_type == 'gitlab_project_migration' + end + def gitea_import? import_type == 'gitea' end @@ -1327,11 +1309,21 @@ class Project < ApplicationRecord def changing_shared_runners_enabled_is_allowed return unless new_record? || changes.has_key?(:shared_runners_enabled) - if shared_runners_enabled && group && group.shared_runners_setting == 'disabled_and_unoverridable' + if shared_runners_setting_conflicting_with_group? errors.add(:shared_runners_enabled, _('cannot be enabled because parent group does not allow it')) end end + def shared_runners_setting_conflicting_with_group? + shared_runners_enabled && group&.shared_runners_setting == Namespace::SR_DISABLED_AND_UNOVERRIDABLE + end + + def reconcile_shared_runners_setting! + if shared_runners_setting_conflicting_with_group? + self.shared_runners_enabled = false + end + end + def to_param if persisted? && errors.include?(:path) path_was @@ -1814,7 +1806,7 @@ class Project < ApplicationRecord def open_issues_count(current_user = nil) return Projects::OpenIssuesCountService.new(self, current_user).count unless current_user.nil? - BatchLoader.for(self).batch(replace_methods: false) do |projects, loader| + BatchLoader.for(self).batch do |projects, loader| issues_count_per_project = ::Projects::BatchOpenIssuesCountService.new(projects).refresh_cache_and_retrieve_data issues_count_per_project.each do |project, count| @@ -2279,7 +2271,7 @@ class Project < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def forks_count - BatchLoader.for(self).batch(replace_methods: false) do |projects, loader| + BatchLoader.for(self).batch do |projects, loader| fork_count_per_project = ::Projects::BatchForksCountService.new(projects).refresh_cache_and_retrieve_data fork_count_per_project.each do |project, count| @@ -2418,7 +2410,7 @@ class Project < ApplicationRecord end def mark_primary_write_location - ::Gitlab::Database::LoadBalancing::Sticking.mark_primary_write_location(:project, self.id) + self.class.sticking.mark_primary_write_location(:project, self.id) end def toggle_ci_cd_settings!(settings_attribute) @@ -2677,10 +2669,6 @@ class Project < ApplicationRecord ProjectStatistics.increment_statistic(self, statistic, delta) end - def merge_requests_author_approval - !!read_attribute(:merge_requests_author_approval) - end - def ci_forward_deployment_enabled? return false unless ci_cd_settings @@ -2734,15 +2722,9 @@ class Project < ApplicationRecord @topic_list = @topic_list.split(',') if @topic_list.instance_of?(String) @topic_list = @topic_list.map(&:strip).uniq.reject(&:empty?) - if @topic_list != self.topic_list || self.topics_acts_as_taggable.any? - self.topics_new.delete_all + if @topic_list != self.topic_list + self.topics.delete_all self.topics = @topic_list.map { |topic| Projects::Topic.find_or_create_by(name: topic) } - - # Remove old topics (ActsAsTaggableOn::Tag) - # Required during the `ActsAsTaggableOn::Tag -> Topic` migration - # TODO: remove in the further process of the migration - # https://gitlab.com/gitlab-org/gitlab/-/issues/335946 - self.topic_taggings.clear end @topic_list = nil @@ -2912,6 +2894,15 @@ class Project < ApplicationRecord def online_runners_with_tags @online_runners_with_tags ||= active_runners.with_tags.online end + + def ensure_project_namespace_in_sync + if changes.keys & [:name, :path, :namespace_id, :visibility_level] && project_namespace.present? + project_namespace.name = name + project_namespace.path = path + project_namespace.parent = namespace + project_namespace.visibility_level = visibility_level + end + end end Project.prepend_mod_with('Project') diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index b2559636f32..24d892290a6 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class ProjectSetting < ApplicationRecord - include IgnorableColumns - - ignore_column :allow_editing_commit_messages, remove_with: '14.4', remove_after: '2021-09-10' - belongs_to :project, inverse_of: :project_setting enum squash_option: { diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 387732cf151..99cec647a98 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -31,7 +31,6 @@ class ProjectStatistics < ApplicationRecord scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) } scope :for_namespaces, -> (namespaces) { where(namespace: namespaces) } - scope :with_any_ci_minutes_used, -> { where.not(shared_runners_seconds: 0) } def total_repository_size repository_size + lfs_objects_size @@ -70,7 +69,7 @@ class ProjectStatistics < ApplicationRecord end def update_lfs_objects_size - self.lfs_objects_size = project.lfs_objects.sum(:size) + self.lfs_objects_size = LfsObject.joins(:lfs_objects_projects).where(lfs_objects_projects: { project_id: project.id }).sum(:size) end def update_uploads_size diff --git a/app/models/projects/project_topic.rb b/app/models/projects/project_topic.rb index d4b456ef482..7021a48646a 100644 --- a/app/models/projects/project_topic.rb +++ b/app/models/projects/project_topic.rb @@ -3,6 +3,6 @@ module Projects class ProjectTopic < ApplicationRecord belongs_to :project - belongs_to :topic + belongs_to :topic, counter_cache: :total_projects_count end end diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb index a17aa550edb..f3352ecc5ee 100644 --- a/app/models/projects/topic.rb +++ b/app/models/projects/topic.rb @@ -1,10 +1,30 @@ # frozen_string_literal: true +require 'carrierwave/orm/activerecord' + module Projects class Topic < ApplicationRecord + include Avatarable + include Gitlab::SQL::Pattern + validates :name, presence: true, uniqueness: true, length: { maximum: 255 } + validates :description, length: { maximum: 1024 } has_many :project_topics, class_name: 'Projects::ProjectTopic' has_many :projects, through: :project_topics + + scope :order_by_total_projects_count, -> { order(total_projects_count: :desc).order(id: :asc) } + scope :reorder_by_similarity, -> (search) do + order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [ + { column: arel_table['name'] } + ]) + reorder(order_expression.desc, arel_table['total_projects_count'].desc, arel_table['id']) + end + + class << self + def search(query) + fuzzy_search(query, [:name]) + end + end end end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 3d32144e0f8..b4e2d17c3e5 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -10,6 +10,8 @@ class ProtectedBranch < ApplicationRecord scope :allowing_force_push, -> { where(allow_force_push: true) } + scope :get_ids_by_name, -> (name) { where(name: name).pluck(:id) } + protected_ref_access_levels :merge, :push def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil) diff --git a/app/models/release.rb b/app/models/release.rb index 0dd71c6ebfb..eac6346cc60 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -33,6 +33,7 @@ class Release < ApplicationRecord includes(:author, :evidences, :milestones, :links, :sorted_links, project: [:project_feature, :route, { namespace: :route }]) } + scope :with_milestones, -> { joins(:milestone_releases) } scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) } scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) } diff --git a/app/models/repository.rb b/app/models/repository.rb index f20b306c806..119d874a6e1 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -732,7 +732,7 @@ class Repository end def tags_sorted_by(value) - return raw_repository.tags(sort_by: value) if Feature.enabled?(:gitaly_tags_finder, project, default_enabled: :yaml) + return raw_repository.tags(sort_by: value) if Feature.enabled?(:tags_finder_gitaly, project, default_enabled: :yaml) tags_ruby_sort(value) end @@ -1054,10 +1054,10 @@ class Repository end def squash(user, merge_request, message) - raw.squash(user, merge_request.id, start_sha: merge_request.diff_start_sha, - end_sha: merge_request.diff_head_sha, - author: merge_request.author, - message: message) + raw.squash(user, start_sha: merge_request.diff_start_sha, + end_sha: merge_request.diff_head_sha, + author: merge_request.author, + message: message) end def submodule_links diff --git a/app/models/upload.rb b/app/models/upload.rb index 0a4acdfc7e3..c1a3df82457 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -18,6 +18,8 @@ class Upload < ApplicationRecord before_save :calculate_checksum!, if: :foreground_checksummable? after_commit :schedule_checksum, if: :needs_checksum? + after_commit :update_project_statistics, on: [:create, :destroy], if: :project? + # as the FileUploader is not mounted, the default CarrierWave ActiveRecord # hooks are not executed and the file will not be deleted after_destroy :delete_file!, if: -> { uploader_class <= FileUploader } @@ -67,7 +69,7 @@ class Upload < ApplicationRecord self.checksum = nil return unless needs_checksum? - self.checksum = self.class.hexdigest(absolute_path) + self.checksum = self.class.sha256_hexdigest(absolute_path) end # Initialize the associated Uploader class with current model @@ -161,6 +163,14 @@ class Upload < ApplicationRecord def mount_point super&.to_sym end + + def project? + model_type == "Project" + end + + def update_project_statistics + ProjectCacheWorker.perform_async(model_id, [], [:uploads_size]) + end end Upload.prepend_mod_with('Upload') diff --git a/app/models/user.rb b/app/models/user.rb index a4c8d606911..25a2588a6a7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -112,7 +112,14 @@ class User < ApplicationRecord # # Namespace for personal projects - has_one :namespace, -> { where(type: nil) }, dependent: :destroy, foreign_key: :owner_id, inverse_of: :owner, autosave: true # rubocop:disable Cop/ActiveRecordDependent + # TODO: change to `:namespace, -> { where(type: Namespaces::UserNamespace.sti_name}, class_name: 'Namespaces::UserNamespace'...` + # when working on issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 + has_one :namespace, + -> { where(type: [nil, Namespaces::UserNamespace.sti_name]) }, + dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent + foreign_key: :owner_id, + inverse_of: :owner, + autosave: true # rubocop:disable Cop/ActiveRecordDependent # Profile has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -229,9 +236,9 @@ class User < ApplicationRecord validates :first_name, length: { maximum: 127 } validates :last_name, length: { maximum: 127 } validates :email, confirmation: true - validates :notification_email, devise_email: true, allow_blank: true, if: ->(user) { user.notification_email != user.email } + validates :notification_email, devise_email: true, allow_blank: true validates :public_email, uniqueness: true, devise_email: true, allow_blank: true - validates :commit_email, devise_email: true, allow_blank: true, if: ->(user) { user.commit_email != user.email && user.commit_email != Gitlab::PrivateCommitEmail::TOKEN } + validates :commit_email, devise_email: true, allow_blank: true, unless: ->(user) { user.commit_email == Gitlab::PrivateCommitEmail::TOKEN } validates :projects_limit, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } @@ -316,6 +323,7 @@ class User < ApplicationRecord delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true + delegate :registration_objective, :registration_objective=, to: :user_detail, allow_nil: true accepts_nested_attributes_for :user_preference, update_only: true accepts_nested_attributes_for :user_detail, update_only: true @@ -449,11 +457,12 @@ class User < ApplicationRecord scope :dormant, -> { active.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) } scope :with_no_activity, -> { active.where(last_activity_on: nil) } scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } + scope :get_ids_by_username, -> (username) { where(username: username).pluck(:id) } def preferred_language read_attribute('preferred_language') || I18n.default_locale.to_s.presence_in(Gitlab::I18n.available_locales) || - 'en' + default_preferred_language end def active_for_authentication? @@ -728,7 +737,7 @@ class User < ApplicationRecord end def find_by_full_path(path, follow_redirects: false) - namespace = Namespace.for_user.find_by_full_path(path, follow_redirects: follow_redirects) + namespace = Namespace.user_namespaces.find_by_full_path(path, follow_redirects: follow_redirects) namespace&.owner end @@ -1434,7 +1443,10 @@ class User < ApplicationRecord namespace.path = username if username_changed? namespace.name = name if name_changed? else - namespace = build_namespace(path: username, name: name) + # TODO: we should no longer need the `type` parameter once we can make the + # the `has_one :namespace` association use the correct class. + # issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 + namespace = build_namespace(path: username, name: name, type: ::Namespaces::UserNamespace.sti_name) namespace.build_namespace_settings end end @@ -2003,6 +2015,11 @@ class User < ApplicationRecord private + # To enable JiHu repository to modify the default language options + def default_preferred_language + 'en' + end + def notification_email_verified return if notification_email.blank? || temp_oauth_email? @@ -2094,10 +2111,14 @@ class User < ApplicationRecord errors.add(:email, error) if error end + def signup_email_invalid_message + _('is not allowed for sign-up.') + end + def check_username_format return if username.blank? || Mime::EXTENSION_LOOKUP.keys.none? { |type| username.end_with?(".#{type}") } - errors.add(:username, _('ending with a file extension is not allowed.')) + errors.add(:username, _('ending with a reserved file extension is not allowed.')) end def groups_with_developer_maintainer_project_access diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 04bc29755f8..b990aedd4f8 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -36,7 +36,8 @@ class UserCallout < ApplicationRecord trial_status_reminder_d3: 35, # EE-only security_configuration_devops_alert: 36, # EE-only profile_personal_access_token_expiry: 37, # EE-only - terraform_notification_dismissed: 38 + terraform_notification_dismissed: 38, + security_newsletter_callout: 39 } validates :feature_name, diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index c41cff67864..6b0ed89c683 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -3,7 +3,10 @@ class UserDetail < ApplicationRecord extend ::Gitlab::Utils::Override include IgnorableColumns - ignore_columns %i[bio_html cached_markdown_version], remove_with: '13.6', remove_after: '2021-10-22' + + ignore_columns %i[bio_html cached_markdown_version], remove_with: '14.5', remove_after: '2021-10-22' + + REGISTRATION_OBJECTIVE_PAIRS = { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5, joining_team: 6 }.freeze belongs_to :user @@ -14,6 +17,8 @@ class UserDetail < ApplicationRecord before_save :prevent_nil_bio + enum registration_objective: REGISTRATION_OBJECTIVE_PAIRS, _suffix: true + private def prevent_nil_bio diff --git a/app/models/user_highest_role.rb b/app/models/user_highest_role.rb index 4853fc3d248..dd5c85a5a87 100644 --- a/app/models/user_highest_role.rb +++ b/app/models/user_highest_role.rb @@ -3,7 +3,13 @@ class UserHighestRole < ApplicationRecord belongs_to :user, optional: false - validates :highest_access_level, allow_nil: true, inclusion: { in: Gitlab::Access.all_values } + validates :highest_access_level, allow_nil: true, inclusion: { in: ->(_) { self.allowed_values } } scope :with_highest_access_level, -> (highest_access_level) { where(highest_access_level: highest_access_level) } + + def self.allowed_values + Gitlab::Access.all_values + end end + +UserHighestRole.prepend_mod diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 337ae7125f3..7687430cfd1 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -23,7 +23,6 @@ class UserPreference < ApplicationRecord ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22' default_value_for :tab_width, value: Gitlab::TabWidth::DEFAULT, allows_nil: false - default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false default_value_for :time_display_relative, value: true, allows_nil: false default_value_for :time_format_in_24h, value: false, allows_nil: false default_value_for :render_whitespace_in_code, value: false, allows_nil: false diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb index 5e255acd882..a4cc43d1f13 100644 --- a/app/models/users/credit_card_validation.rb +++ b/app/models/users/credit_card_validation.rb @@ -7,5 +7,18 @@ module Users self.table_name = 'user_credit_card_validations' belongs_to :user + + validates :holder_name, length: { maximum: 26 } + validates :last_digits, allow_nil: true, numericality: { + greater_than_or_equal_to: 0, less_than_or_equal_to: 9999 + } + + def similar_records + self.class.where( + expiration_date: expiration_date, + last_digits: last_digits, + holder_name: holder_name + ).order(credit_card_validated_at: :desc).includes(:user) + end end end diff --git a/app/policies/ci/resource_group_policy.rb b/app/policies/ci/resource_group_policy.rb new file mode 100644 index 00000000000..ef384265b11 --- /dev/null +++ b/app/policies/ci/resource_group_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class ResourceGroupPolicy < BasePolicy + delegate { @subject.project } + end +end diff --git a/app/policies/clusters/agent_policy.rb b/app/policies/clusters/agent_policy.rb new file mode 100644 index 00000000000..25e78c84802 --- /dev/null +++ b/app/policies/clusters/agent_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Clusters + class AgentPolicy < BasePolicy + alias_method :cluster_agent, :subject + + delegate { cluster_agent.project } + end +end diff --git a/app/policies/clusters/agent_token_policy.rb b/app/policies/clusters/agent_token_policy.rb new file mode 100644 index 00000000000..e876ecfac26 --- /dev/null +++ b/app/policies/clusters/agent_token_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Clusters + class AgentTokenPolicy < BasePolicy + alias_method :token, :subject + + delegate { token.agent } + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 7abffd2c352..64395f69c42 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -134,6 +134,8 @@ class GroupPolicy < BasePolicy enable :create_package enable :create_package_settings enable :developer_access + enable :admin_organization + enable :admin_contact end rule { reporter }.policy do @@ -147,7 +149,6 @@ class GroupPolicy < BasePolicy enable :read_prometheus enable :read_package enable :read_package_settings - enable :admin_organization end rule { maintainer }.policy do @@ -162,7 +163,6 @@ class GroupPolicy < BasePolicy enable :admin_cluster enable :read_deploy_token enable :create_jira_connect_subscription - enable :update_runners_registration_token enable :maintainer_access end @@ -179,6 +179,7 @@ class GroupPolicy < BasePolicy enable :update_default_branch_protection enable :create_deploy_token enable :destroy_deploy_token + enable :update_runners_registration_token enable :owner_access end diff --git a/app/policies/list_policy.rb b/app/policies/list_policy.rb new file mode 100644 index 00000000000..97845746546 --- /dev/null +++ b/app/policies/list_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ListPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass + delegate { @subject.board.resource_parent } +end diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index dcbeda9f5d3..0cf1bcb9737 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -1,26 +1,9 @@ # frozen_string_literal: true -class NamespacePolicy < BasePolicy - rule { anonymous }.prevent_all - - condition(:personal_project, scope: :subject) { @subject.kind == 'user' } - condition(:can_create_personal_project, scope: :user) { @user.can_create_project? } - condition(:owner) { @subject.owner == @user } - - rule { owner | admin }.policy do - enable :owner_access - enable :create_projects - enable :admin_namespace - enable :read_namespace - enable :read_statistics - enable :create_jira_connect_subscription - enable :create_package_settings - enable :read_package_settings - end - - rule { personal_project & ~can_create_personal_project }.prevent :create_projects - - rule { (owner | admin) & can?(:create_projects) }.enable :transfer_projects +class NamespacePolicy < ::Namespaces::UserNamespacePolicy + # NamespacePolicy has been traditionally for user namespaces. + # So these policies have been moved into Namespaces::UserNamespacePolicy. + # Once the user namespace conversion is complete, we can look at + # either removing this file or locating common namespace policy items + # here. end - -NamespacePolicy.prepend_mod_with('NamespacePolicy') diff --git a/app/policies/namespaces/project_namespace_policy.rb b/app/policies/namespaces/project_namespace_policy.rb new file mode 100644 index 00000000000..bc08a7a45ed --- /dev/null +++ b/app/policies/namespaces/project_namespace_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Namespaces + class ProjectNamespacePolicy < BasePolicy + # For now users are not granted any permissions on project namespace + # as it's completely hidden to them. When we start using project + # namespaces in queries, we will have to extend this policy. + end +end diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb new file mode 100644 index 00000000000..f8b285e5312 --- /dev/null +++ b/app/policies/namespaces/user_namespace_policy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Namespaces + class UserNamespacePolicy < BasePolicy + rule { anonymous }.prevent_all + + condition(:personal_project, scope: :subject) { @subject.kind == 'user' } + condition(:can_create_personal_project, scope: :user) { @user.can_create_project? } + condition(:owner) { @subject.owner == @user } + + rule { owner | admin }.policy do + enable :owner_access + enable :create_projects + enable :admin_namespace + enable :read_namespace + enable :read_statistics + enable :create_jira_connect_subscription + enable :create_package_settings + enable :read_package_settings + end + + rule { personal_project & ~can_create_personal_project }.prevent :create_projects + + rule { (owner | admin) & can?(:create_projects) }.enable :transfer_projects + end +end + +Namespaces::UserNamespacePolicy.prepend_mod_with('Namespaces::UserNamespacePolicy') diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 54b11ea6041..59aa47beff9 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -357,6 +357,8 @@ class ProjectPolicy < BasePolicy enable :update_commit_status enable :create_build enable :update_build + enable :read_resource_group + enable :update_resource_group enable :create_merge_request_from enable :create_wiki enable :push_code @@ -436,6 +438,7 @@ class ProjectPolicy < BasePolicy enable :destroy_freeze_period enable :admin_feature_flags_client enable :update_runners_registration_token + enable :manage_project_google_cloud end rule { public_project & metrics_dashboard_allowed }.policy do diff --git a/app/presenters/README.md b/app/presenters/README.md index 62aec4fc8a2..dfd1818f97d 100644 --- a/app/presenters/README.md +++ b/app/presenters/README.md @@ -66,14 +66,15 @@ we gain the following benefits: ### Presenter definition -Every presenter should inherit from `Gitlab::View::Presenter::Simple`, which -provides a `.presents` the method which allows you to define an accessor for the +If you need a presenter class that has only necessary interfaces for the view-related context, +inherit from `Gitlab::View::Presenter::Simple`. +It provides a `.presents` the method which allows you to define an accessor for the presented object. It also includes common helpers like `Gitlab::Routing` and `Gitlab::Allowable`. ```ruby class LabelPresenter < Gitlab::View::Presenter::Simple - presents :label + presents ::Label, as: :label def text_color label.color.to_s @@ -85,13 +86,14 @@ class LabelPresenter < Gitlab::View::Presenter::Simple end ``` -In some cases, it can be more practical to transparently delegate all missing -method calls to the presented object, in these cases, you can make your -presenter inherit from `Gitlab::View::Presenter::Delegated`: +If you need a presenter class that delegates missing method calls to the presented object, +inherit from `Gitlab::View::Presenter::Delegated`. +This is more like an "extension" in the sense that the produced object is going to have +all of interfaces of the presented object **AND** all of the interfaces in the presenter class: ```ruby class LabelPresenter < Gitlab::View::Presenter::Delegated - presents :label + presents ::Label, as: :label def text_color # color is delegated to label @@ -152,3 +154,82 @@ You can also present the model in the view: %div{ class: label.text_color } = render partial: label, label: label ``` + +### Validate accidental overrides + +We use presenters in many places, such as Controller, Haml, GraphQL/Rest API, +it's very handy to extend the core/backend logic of Active Record models, +however, there is a risk that it accidentally overrides important logic. + +For example, [this production incident](https://gitlab.com/gitlab-com/gl-infra/production/-/issues/5498) +was caused by [including `ActionView::Helpers::UrlHelper` in a presenter](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69537/diffs#4b581cff00ef3cc9780efd23682af383de302e7d_3_3). +The `tag` accesor in `Ci::Build` was accidentally overridden by `ActionView::Helpers::TagHelper#tag`, +and as a conseuqence, a wrong `tag` value was persited into database. + +Starting from GitLab 14.4, we validate the presenters (specifically all of the subclasses of `Gitlab::View::Presenter::Delegated`) +that they do not accidentally override core/backend logic. In such case, a pipeline in merge requests fails with an error message, +here is an example: + +```plaintext +We've detected that a presetner is overriding a specific method(s) on a subject model. +There is a risk that it accidentally modifies the backend/core logic that leads to production incident. +Please follow https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/presenters/README.md#validate-accidental-overrides +to resolve this error with caution. + +Here are the conflict details. + +- Ci::PipelinePresenter#tag is overriding Ci::Pipeline#tag. delegator_location: /devkitkat/services/rails/cache/ruby/2.7.0/gems/actionview-6.1.3.2/lib/action_view/helpers/tag_helper.rb:271 original_location: /devkitkat/services/rails/cache/ruby/2.7.0/gems/activemodel-6.1.3.2/lib/active_model/attribute_methods.rb:254 +``` + +Here are the potential solutions: + +- If the conflict happens on an instance method in the presenter: + - If you intend to override the core/backend logic, define `delegator_override <method-name>` on top of the conflicted method. + This explicitly adds the method to an allowlist. + - If you do NOT intend to override the core/backend logic, rename the method name in the presenter. +- If the conflict happens on an included module in the presenter, remove the module from the presenter and find a workaround. + +### How to use the `Gitlab::Utils::DelegatorOverride` validator + +If a presenter class inhertis from `Gitlab::View::Presenter::Delegated`, +you should define what object class is presented: + +```ruby +class WebHookLogPresenter < Gitlab::View::Presenter::Delegated + presents ::WebHookLog, as: :web_hook_log # This defines that the presenter presents `WebHookLog` Active Record model. +``` + +These presenters are validated not to accidentaly override the methods in the presented object. +You can run the validation locally with: + +```shell +bundle exec rake lint:static_verification +``` + +To add a method to an allowlist, use `delegator_override`. For example: + +```ruby +class VulnerabilityPresenter < Gitlab::View::Presenter::Delegated + presents ::Vulnerability, as: :vulnerability + + delegator_override :description # This adds the `description` method to an allowlist that the override is intentional. + def description + vulnerability.description || finding.description + end +``` + +To add methods of a module to an allowlist, use `delegator_override_with`. For example: + +```ruby +module Ci + class PipelinePresenter < Gitlab::View::Presenter::Delegated + include Gitlab::Utils::StrongMemoize + include ActionView::Helpers::UrlHelper + + delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate + delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::UrlHelper` inclusion as it overrides `Ci::Pipeline#tag` +``` + +Keep in mind that if you use `delegator_override_with`, +there is a high chance that you're doing **something wrong**. +Read the [Validate Accidental Overrides](#validate-accidental-overrides) for more information. diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb index c6c6fe837a0..86fe9859271 100644 --- a/app/presenters/alert_management/alert_presenter.rb +++ b/app/presenters/alert_management/alert_presenter.rb @@ -2,10 +2,12 @@ module AlertManagement class AlertPresenter < Gitlab::View::Presenter::Delegated - include Gitlab::Utils::StrongMemoize include IncidentManagement::Settings include ActionView::Helpers::UrlHelper + presents ::AlertManagement::Alert + delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate + MARKDOWN_LINE_BREAK = " \n" HORIZONTAL_LINE = "\n\n---\n\n" @@ -30,6 +32,7 @@ module AlertManagement started_at&.strftime('%d %B %Y, %-l:%M%p (%Z)') end + delegator_override :details_url def details_url details_project_alert_management_url(project, alert.iid) end @@ -65,6 +68,7 @@ module AlertManagement private attr_reader :alert, :project + delegate :alert_markdown, :full_query, to: :parsed_payload def issue_summary_markdown diff --git a/app/presenters/award_emoji_presenter.rb b/app/presenters/award_emoji_presenter.rb index 98713855d35..8a7b58e0aba 100644 --- a/app/presenters/award_emoji_presenter.rb +++ b/app/presenters/award_emoji_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class AwardEmojiPresenter < Gitlab::View::Presenter::Delegated - presents :award_emoji + presents ::AwardEmoji, as: :award_emoji def description as_emoji['description'] diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index ecc16e2840c..c198859aa4c 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -7,7 +7,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated include TreeHelper include ChecksCollaboration - presents :blob + presents ::Blob, as: :blob def highlight(to: nil, plain: nil) load_all_blob_data diff --git a/app/presenters/blobs/unfold_presenter.rb b/app/presenters/blobs/unfold_presenter.rb index 487c6fe0757..b921b5bf670 100644 --- a/app/presenters/blobs/unfold_presenter.rb +++ b/app/presenters/blobs/unfold_presenter.rb @@ -6,6 +6,8 @@ module Blobs include ActiveModel::AttributeAssignment include Gitlab::Utils::StrongMemoize + presents ::Blob + attribute :full, :boolean, default: false attribute :since, :integer, default: 1 attribute :to, :integer, default: 1 diff --git a/app/presenters/board_presenter.rb b/app/presenters/board_presenter.rb index d7cecd44dd7..bb3e96b8faf 100644 --- a/app/presenters/board_presenter.rb +++ b/app/presenters/board_presenter.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class BoardPresenter < Gitlab::View::Presenter::Delegated - presents :board + presents ::Board, as: :board end diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb index 724e10c26c3..a62d7cdbbd4 100644 --- a/app/presenters/ci/bridge_presenter.rb +++ b/app/presenters/ci/bridge_presenter.rb @@ -2,6 +2,9 @@ module Ci class BridgePresenter < ProcessablePresenter + presents ::Ci::Bridge + + delegator_override :detailed_status def detailed_status @detailed_status ||= subject.detailed_status(user) end diff --git a/app/presenters/ci/build_metadata_presenter.rb b/app/presenters/ci/build_metadata_presenter.rb index 4871bb3a919..2f559adf77d 100644 --- a/app/presenters/ci/build_metadata_presenter.rb +++ b/app/presenters/ci/build_metadata_presenter.rb @@ -9,8 +9,9 @@ module Ci job_timeout_source: 'job' }.freeze - presents :metadata + presents ::Ci::BuildMetadata, as: :metadata + delegator_override :timeout_source def timeout_source return unless metadata.timeout_source? diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index 06ed6791bb7..65e1c80085f 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -2,6 +2,8 @@ module Ci class BuildPresenter < ProcessablePresenter + presents ::Ci::Build + def erased_by_user? # Build can be erased through API, therefore it does not have # `erased_by` user assigned in that case. diff --git a/app/presenters/ci/group_variable_presenter.rb b/app/presenters/ci/group_variable_presenter.rb index 99011150c84..dea9a42b622 100644 --- a/app/presenters/ci/group_variable_presenter.rb +++ b/app/presenters/ci/group_variable_presenter.rb @@ -2,7 +2,7 @@ module Ci class GroupVariablePresenter < Gitlab::View::Presenter::Delegated - presents :variable + presents ::Ci::GroupVariable, as: :variable def placeholder 'GROUP_VARIABLE' diff --git a/app/presenters/ci/legacy_stage_presenter.rb b/app/presenters/ci/legacy_stage_presenter.rb index d5c21baba28..c803abfab6a 100644 --- a/app/presenters/ci/legacy_stage_presenter.rb +++ b/app/presenters/ci/legacy_stage_presenter.rb @@ -2,7 +2,7 @@ module Ci class LegacyStagePresenter < Gitlab::View::Presenter::Delegated - presents :legacy_stage + presents ::Ci::LegacyStage, as: :legacy_stage def latest_ordered_statuses preload_statuses(legacy_stage.statuses.latest_ordered) diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 82f00f74692..e0cb899c9d3 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -5,6 +5,9 @@ module Ci include Gitlab::Utils::StrongMemoize include ActionView::Helpers::UrlHelper + delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate + delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::UrlHelper` inclusion as it overrides `Ci::Pipeline#tag` + # We use a class method here instead of a constant, allowing EE to redefine # the returned `Hash` more easily. def self.failure_reasons @@ -20,8 +23,9 @@ module Ci user_blocked: 'The user who created this pipeline is blocked.' } end - presents :pipeline + presents ::Ci::Pipeline, as: :pipeline + delegator_override :failed_builds def failed_builds return [] unless can?(current_user, :read_build, pipeline) @@ -30,6 +34,7 @@ module Ci end end + delegator_override :failure_reason def failure_reason return unless pipeline.failure_reason? diff --git a/app/presenters/ci/processable_presenter.rb b/app/presenters/ci/processable_presenter.rb index 5a8a6649071..9f3a83a00f3 100644 --- a/app/presenters/ci/processable_presenter.rb +++ b/app/presenters/ci/processable_presenter.rb @@ -2,5 +2,6 @@ module Ci class ProcessablePresenter < CommitStatusPresenter + presents ::Ci::Processable end end diff --git a/app/presenters/ci/runner_presenter.rb b/app/presenters/ci/runner_presenter.rb index 273328afc53..ad889d444f8 100644 --- a/app/presenters/ci/runner_presenter.rb +++ b/app/presenters/ci/runner_presenter.rb @@ -2,11 +2,14 @@ module Ci class RunnerPresenter < Gitlab::View::Presenter::Delegated - presents :runner + presents ::Ci::Runner, as: :runner + delegator_override :locked? def locked? read_attribute(:locked) && project_type? end + + delegator_override :locked alias_method :locked, :locked? end end diff --git a/app/presenters/ci/stage_presenter.rb b/app/presenters/ci/stage_presenter.rb index 21bda86cded..bd5bf08abbc 100644 --- a/app/presenters/ci/stage_presenter.rb +++ b/app/presenters/ci/stage_presenter.rb @@ -2,7 +2,7 @@ module Ci class StagePresenter < Gitlab::View::Presenter::Delegated - presents :stage + presents ::Ci::Stage, as: :stage PRELOADED_RELATIONS = [:pipeline, :metadata, :tags, :job_artifacts_archive, :downstream_pipeline].freeze diff --git a/app/presenters/ci/trigger_presenter.rb b/app/presenters/ci/trigger_presenter.rb index 605c8f328a4..5f0bd4b3a8b 100644 --- a/app/presenters/ci/trigger_presenter.rb +++ b/app/presenters/ci/trigger_presenter.rb @@ -2,12 +2,13 @@ module Ci class TriggerPresenter < Gitlab::View::Presenter::Delegated - presents :trigger + presents ::Ci::Trigger, as: :trigger def has_token_exposed? can?(current_user, :admin_trigger, trigger) end + delegator_override :token def token if has_token_exposed? trigger.token diff --git a/app/presenters/ci/variable_presenter.rb b/app/presenters/ci/variable_presenter.rb index f027f3aa560..ec04dd5e9ff 100644 --- a/app/presenters/ci/variable_presenter.rb +++ b/app/presenters/ci/variable_presenter.rb @@ -2,7 +2,7 @@ module Ci class VariablePresenter < Gitlab::View::Presenter::Delegated - presents :variable + presents ::Ci::Variable, as: :variable def placeholder 'PROJECT_VARIABLE' diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 03e26b92922..4b645510b51 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ClusterablePresenter < Gitlab::View::Presenter::Delegated - presents :clusterable + presents ::Project, ::Group, ::Clusters::Instance, as: :clusterable def self.fabricate(clusterable, **attributes) presenter_class = "#{clusterable.class.name}ClusterablePresenter".constantize diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index eb4bd8532af..ce060476cfd 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -3,21 +3,10 @@ module Clusters class ClusterPresenter < Gitlab::View::Presenter::Delegated include ::Gitlab::Utils::StrongMemoize - include ActionView::Helpers::SanitizeHelper - include ActionView::Helpers::UrlHelper - include IconsHelper - presents :cluster + delegator_override_with ::Gitlab::Utils::StrongMemoize # TODO: Remove `::Gitlab::Utils::StrongMemoize` inclusion as it's duplicate - # We do not want to show the group path for clusters belonging to the - # clusterable, only for the ancestor clusters. - def item_link(clusterable_presenter, *html_options) - if cluster.group_type? && clusterable != clusterable_presenter.subject - contracted_group_name(cluster.group) + ' / ' + link_to_cluster - else - link_to_cluster(*html_options) - end - end + presents ::Clusters::Cluster, as: :cluster def provider_label if aws? @@ -39,16 +28,6 @@ module Clusters can?(current_user, :read_cluster, cluster) end - def cluster_type_description - if cluster.project_type? - s_("ClusterIntegration|Project cluster") - elsif cluster.group_type? - s_("ClusterIntegration|Group cluster") - elsif cluster.instance_type? - s_("ClusterIntegration|Instance cluster") - end - end - def show_path(params: {}) if cluster.project_type? project_cluster_path(project, cluster, params) @@ -107,7 +86,7 @@ module Clusters private def image_path(path) - ActionController::Base.helpers.image_path(path) + ApplicationController.helpers.image_path(path) end # currently log explorer is only available in the scope of the project @@ -127,20 +106,6 @@ module Clusters cluster.project end end - - def contracted_group_name(group) - sanitize(group.full_name) - .sub(%r{\/.*\/}, "/ #{contracted_icon} /") - .html_safe - end - - def contracted_icon - sprite_icon('ellipsis_h', size: 12, css_class: 'vertical-align-middle') - end - - def link_to_cluster(html_options: {}) - link_to_if(can_read_cluster?, cluster.name, show_path, html_options) - end end end diff --git a/app/presenters/clusters/integration_presenter.rb b/app/presenters/clusters/integration_presenter.rb index 57608be29b1..f7be59f00f3 100644 --- a/app/presenters/clusters/integration_presenter.rb +++ b/app/presenters/clusters/integration_presenter.rb @@ -2,7 +2,7 @@ module Clusters class IntegrationPresenter < Gitlab::View::Presenter::Delegated - presents :integration + presents ::Clusters::Integrations::Prometheus, ::Clusters::Integrations::ElasticStack, as: :integration def application_type integration.class.name.demodulize.underscore diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb index c14dcab6000..7df45ca03bb 100644 --- a/app/presenters/commit_presenter.rb +++ b/app/presenters/commit_presenter.rb @@ -3,7 +3,7 @@ class CommitPresenter < Gitlab::View::Presenter::Delegated include GlobalID::Identification - presents :commit + presents ::Commit, as: :commit def status_for(ref) return unless can?(current_user, :read_commit_status, commit.project) diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 3c39470b730..7919e501bf0 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -28,19 +28,36 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated ci_quota_exceeded: 'No more CI minutes available', no_matching_runner: 'No matching runner available', trace_size_exceeded: 'The job log size limit was reached', - builds_disabled: 'The CI/CD is disabled for this project' + builds_disabled: 'The CI/CD is disabled for this project', + environment_creation_failure: 'This job could not be executed because it would create an environment with an invalid parameter.' + }.freeze + + TROUBLESHOOTING_DOC = { + environment_creation_failure: { path: 'ci/environments/index', anchor: 'a-deployment-job-failed-with-this-job-could-not-be-executed-because-it-would-create-an-environment-with-an-invalid-parameter-error' } }.freeze private_constant :CALLOUT_FAILURE_MESSAGES - presents :build + presents ::CommitStatus, as: :build def self.callout_failure_messages CALLOUT_FAILURE_MESSAGES end def callout_failure_message - self.class.callout_failure_messages.fetch(failure_reason.to_sym) + message = self.class.callout_failure_messages.fetch(failure_reason.to_sym) + + if doc = TROUBLESHOOTING_DOC[failure_reason.to_sym] + message += " #{help_page_link(doc[:path], doc[:anchor])}" + end + + message + end + + private + + def help_page_link(path, anchor) + ActionController::Base.helpers.link_to('How do I fix it?', help_page_path(path, anchor: anchor)) end end diff --git a/app/presenters/dev_ops_report/metric_presenter.rb b/app/presenters/dev_ops_report/metric_presenter.rb index 4d7ac1cd3ec..55326f8f678 100644 --- a/app/presenters/dev_ops_report/metric_presenter.rb +++ b/app/presenters/dev_ops_report/metric_presenter.rb @@ -2,6 +2,8 @@ module DevOpsReport class MetricPresenter < Gitlab::View::Presenter::Simple + presents ::DevOpsReport::Metric + delegate :created_at, to: :subject def cards diff --git a/app/presenters/environment_presenter.rb b/app/presenters/environment_presenter.rb index 6f67bbe2a5a..6c8da86187c 100644 --- a/app/presenters/environment_presenter.rb +++ b/app/presenters/environment_presenter.rb @@ -3,7 +3,7 @@ class EnvironmentPresenter < Gitlab::View::Presenter::Delegated include ActionView::Helpers::UrlHelper - presents :environment + presents ::Environment, as: :environment def path project_environment_path(project, self) diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb index c37721f7213..4c787b88e20 100644 --- a/app/presenters/event_presenter.rb +++ b/app/presenters/event_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class EventPresenter < Gitlab::View::Presenter::Delegated - presents :event + presents ::Event, as: :event def initialize(subject, **attributes) super @@ -10,6 +10,7 @@ class EventPresenter < Gitlab::View::Presenter::Delegated end # Caching `visible_to_user?` method in the presenter beause it might be called multiple times. + delegator_override :visible_to_user? def visible_to_user?(user = nil) @visible_to_user_cache.fetch(user&.id) { super(user) } end diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb index 1f2445b04a1..e9340a42e51 100644 --- a/app/presenters/gitlab/blame_presenter.rb +++ b/app/presenters/gitlab/blame_presenter.rb @@ -12,7 +12,7 @@ module Gitlab include TreeHelper include IconsHelper - presents :blame + presents nil, as: :blame CommitData = Struct.new( :author_avatar, diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb index 34e7084ab02..c51cd415029 100644 --- a/app/presenters/group_clusterable_presenter.rb +++ b/app/presenters/group_clusterable_presenter.rb @@ -2,7 +2,8 @@ class GroupClusterablePresenter < ClusterablePresenter extend ::Gitlab::Utils::Override - include ActionView::Helpers::UrlHelper + + presents ::Group override :cluster_status_cluster_path def cluster_status_cluster_path(cluster, params = {}) @@ -31,7 +32,7 @@ class GroupClusterablePresenter < ClusterablePresenter override :learn_more_link def learn_more_link - link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/group/clusters/index'), target: '_blank', rel: 'noopener noreferrer') + ApplicationController.helpers.link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/group/clusters/index'), target: '_blank', rel: 'noopener noreferrer') end def metrics_dashboard_path(cluster) diff --git a/app/presenters/group_member_presenter.rb b/app/presenters/group_member_presenter.rb index 5ab4b51f472..88facc3608d 100644 --- a/app/presenters/group_member_presenter.rb +++ b/app/presenters/group_member_presenter.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class GroupMemberPresenter < MemberPresenter + presents ::GroupMember + private def admin_member_permission diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb index 56d91f90b2e..f2550eb17e3 100644 --- a/app/presenters/instance_clusterable_presenter.rb +++ b/app/presenters/instance_clusterable_presenter.rb @@ -2,7 +2,8 @@ class InstanceClusterablePresenter < ClusterablePresenter extend ::Gitlab::Utils::Override - include ActionView::Helpers::UrlHelper + + presents ::Clusters::Instance def self.fabricate(clusterable, **attributes) attributes_with_presenter_class = attributes.merge(presenter_class: InstanceClusterablePresenter) @@ -69,7 +70,7 @@ class InstanceClusterablePresenter < ClusterablePresenter override :learn_more_link def learn_more_link - link_to(s_('ClusterIntegration|Learn more about instance Kubernetes clusters'), help_page_path('user/instance/clusters/index'), target: '_blank', rel: 'noopener noreferrer') + ApplicationController.helpers.link_to(s_('ClusterIntegration|Learn more about instance Kubernetes clusters'), help_page_path('user/instance/clusters/index'), target: '_blank', rel: 'noopener noreferrer') end def metrics_dashboard_path(cluster) diff --git a/app/presenters/invitation_presenter.rb b/app/presenters/invitation_presenter.rb index d8c07f327dd..ada8227a477 100644 --- a/app/presenters/invitation_presenter.rb +++ b/app/presenters/invitation_presenter.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class InvitationPresenter < Gitlab::View::Presenter::Delegated - presents :invitation + presents nil, as: :invitation end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index b7f4ac0555d..72fe14d224d 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true class IssuePresenter < Gitlab::View::Presenter::Delegated - presents :issue + presents ::Issue, as: :issue def issue_path url_builder.build(issue, only_path: true) end + delegator_override :subscribed? def subscribed? issue.subscribed?(current_user, issue.project) end diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb index 9e51e6fa4ba..fafade2828f 100644 --- a/app/presenters/label_presenter.rb +++ b/app/presenters/label_presenter.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true class LabelPresenter < Gitlab::View::Presenter::Delegated - presents :label + presents ::Label, as: :label delegate :name, :full_name, to: :label_subject, prefix: :subject + delegator_override :subject # TODO: Fix `Gitlab::View::Presenter::Delegated#subject` not to override `Label#subject`. + def edit_path case label when GroupLabel then edit_group_label_path(label.group, label) diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb index b37a43bf251..67d044dd01c 100644 --- a/app/presenters/member_presenter.rb +++ b/app/presenters/member_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class MemberPresenter < Gitlab::View::Presenter::Delegated - presents :member + presents ::Member, as: :member def access_level_roles member.class.access_level_roles diff --git a/app/presenters/members_presenter.rb b/app/presenters/members_presenter.rb index 03ebea36d49..b572cf96235 100644 --- a/app/presenters/members_presenter.rb +++ b/app/presenters/members_presenter.rb @@ -3,7 +3,7 @@ class MembersPresenter < Gitlab::View::Presenter::Delegated include Enumerable - presents :members + presents nil, as: :members def to_ary to_a diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index fc8a290f5f7..d19d4964524 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -8,9 +8,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated include ChecksCollaboration include Gitlab::Utils::StrongMemoize + delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate + APPROVALS_WIDGET_BASE_TYPE = 'base' - presents :merge_request + presents ::MergeRequest, as: :merge_request def ci_status if pipeline @@ -183,6 +185,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated .can_push_to_branch?(source_branch) end + delegator_override :can_remove_source_branch? def can_remove_source_branch? source_branch_exists? && merge_request.can_remove_source_branch?(current_user) end @@ -202,6 +205,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end + delegator_override :subscribed? def subscribed? merge_request.subscribed?(current_user, merge_request.target_project) end diff --git a/app/presenters/milestone_presenter.rb b/app/presenters/milestone_presenter.rb index 6bf8203702f..4084c8740f0 100644 --- a/app/presenters/milestone_presenter.rb +++ b/app/presenters/milestone_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class MilestonePresenter < Gitlab::View::Presenter::Delegated - presents :milestone + presents ::Milestone, as: :milestone def milestone_path url_builder.build(milestone, only_path: true) diff --git a/app/presenters/pages_domain_presenter.rb b/app/presenters/pages_domain_presenter.rb index 6ef89760bec..0523f702416 100644 --- a/app/presenters/pages_domain_presenter.rb +++ b/app/presenters/pages_domain_presenter.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class PagesDomainPresenter < Gitlab::View::Presenter::Delegated - presents :pages_domain + presents ::PagesDomain, as: :pages_domain + + delegator_override :subject # TODO: Fix `Gitlab::View::Presenter::Delegated#subject` not to override `PagesDomain#subject`. def needs_verification? Gitlab::CurrentSettings.pages_domain_verification_enabled? && unverified? diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb index 920304e743e..6c4d1143c0f 100644 --- a/app/presenters/project_clusterable_presenter.rb +++ b/app/presenters/project_clusterable_presenter.rb @@ -2,7 +2,8 @@ class ProjectClusterablePresenter < ClusterablePresenter extend ::Gitlab::Utils::Override - include ActionView::Helpers::UrlHelper + + presents ::Project override :cluster_status_cluster_path def cluster_status_cluster_path(cluster, params = {}) @@ -26,7 +27,7 @@ class ProjectClusterablePresenter < ClusterablePresenter override :learn_more_link def learn_more_link - link_to(s_('ClusterIntegration|Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') + ApplicationController.helpers.link_to(s_('ClusterIntegration|Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') end def metrics_dashboard_path(cluster) diff --git a/app/presenters/project_hook_presenter.rb b/app/presenters/project_hook_presenter.rb index a65c7221b5a..a696e9fd0ec 100644 --- a/app/presenters/project_hook_presenter.rb +++ b/app/presenters/project_hook_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ProjectHookPresenter < Gitlab::View::Presenter::Delegated - presents :project_hook + presents ::ProjectHook, as: :project_hook def logs_details_path(log) project_hook_hook_log_path(project, self, log) diff --git a/app/presenters/project_member_presenter.rb b/app/presenters/project_member_presenter.rb index 17947266ed7..91d3ae96877 100644 --- a/app/presenters/project_member_presenter.rb +++ b/app/presenters/project_member_presenter.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ProjectMemberPresenter < MemberPresenter + presents ::ProjectMember + private def admin_member_permission diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 066f4786cff..bbd8c715f5c 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -12,7 +12,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated include Gitlab::Utils::StrongMemoize include Gitlab::Experiment::Dsl - presents :project + delegator_override_with GitlabRoutingHelper # TODO: Remove `GitlabRoutingHelper` inclusion as it's duplicate + delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate + + presents ::Project, as: :project AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon, :itemprop, :data) MAX_TOPICS_TO_SHOW = 3 diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb index f56760b55df..7b2ffb6d755 100644 --- a/app/presenters/projects/import_export/project_export_presenter.rb +++ b/app/presenters/projects/import_export/project_export_presenter.rb @@ -5,16 +5,24 @@ module Projects class ProjectExportPresenter < Gitlab::View::Presenter::Delegated include ActiveModel::Serializers::JSON - presents :project + presents ::Project, as: :project + # TODO: Remove `ActiveModel::Serializers::JSON` inclusion as it's duplicate + delegator_override_with ActiveModel::Serializers::JSON + delegator_override_with ActiveModel::Naming + delegator_override :include_root_in_json, :include_root_in_json? + + delegator_override :project_members def project_members super + converted_group_members end + delegator_override :description def description self.respond_to?(:override_description) ? override_description : super end + delegator_override :protected_branches def protected_branches project.exported_protected_branches end diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 13290a8e632..e3323b75188 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -5,7 +5,7 @@ module Projects class DeployKeysPresenter < Gitlab::View::Presenter::Simple include Gitlab::Utils::StrongMemoize - presents :project + presents ::Project, as: :project delegate :size, to: :enabled_keys, prefix: true delegate :size, to: :available_project_keys, prefix: true delegate :size, to: :available_public_keys, prefix: true diff --git a/app/presenters/prometheus_alert_presenter.rb b/app/presenters/prometheus_alert_presenter.rb index 99e24bdcdb9..714329ede71 100644 --- a/app/presenters/prometheus_alert_presenter.rb +++ b/app/presenters/prometheus_alert_presenter.rb @@ -3,7 +3,7 @@ class PrometheusAlertPresenter < Gitlab::View::Presenter::Delegated include ActionView::Helpers::UrlHelper - presents :prometheus_alert + presents ::PrometheusAlert, as: :prometheus_alert def humanized_text operator_text = diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb index ac27e997b41..c919c7f4c60 100644 --- a/app/presenters/release_presenter.rb +++ b/app/presenters/release_presenter.rb @@ -3,8 +3,10 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated include ActionView::Helpers::UrlHelper - presents :release + presents ::Release, as: :release + # TODO: Remove `delegate` as it's redundant due to SimpleDelegator. + delegator_override :tag, :project delegate :project, :tag, to: :release def commit_path @@ -51,6 +53,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated edit_project_release_url(project, release) end + delegator_override :assets_count def assets_count if can_download_code? release.assets_count @@ -59,6 +62,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated end end + delegator_override :name def name can_download_code? ? release.name : "Release-#{release.id}" end diff --git a/app/presenters/releases/evidence_presenter.rb b/app/presenters/releases/evidence_presenter.rb index a00cbacb7d8..bdc053a303b 100644 --- a/app/presenters/releases/evidence_presenter.rb +++ b/app/presenters/releases/evidence_presenter.rb @@ -4,7 +4,7 @@ module Releases class EvidencePresenter < Gitlab::View::Presenter::Delegated include ActionView::Helpers::UrlHelper - presents :evidence + presents ::Releases::Evidence, as: :evidence def filepath release = evidence.release diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb index ab43800b9f2..72f967b8beb 100644 --- a/app/presenters/search_service_presenter.rb +++ b/app/presenters/search_service_presenter.rb @@ -3,7 +3,7 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated include RendersCommits - presents :search_service + presents ::SearchService, as: :search_service SCOPE_PRELOAD_METHOD = { projects: :with_web_entity_associations, @@ -18,6 +18,7 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated SORT_ENABLED_SCOPES = %w(issues merge_requests epics).freeze + delegator_override :search_objects def search_objects @search_objects ||= begin objects = search_service.search_objects(SCOPE_PRELOAD_METHOD[scope.to_sym]) diff --git a/app/presenters/sentry_error_presenter.rb b/app/presenters/sentry_error_presenter.rb index 669bcb68b7c..5862e54dfc7 100644 --- a/app/presenters/sentry_error_presenter.rb +++ b/app/presenters/sentry_error_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SentryErrorPresenter < Gitlab::View::Presenter::Delegated - presents :error + presents nil, as: :error FrequencyStruct = Struct.new(:time, :count, keyword_init: true) diff --git a/app/presenters/service_hook_presenter.rb b/app/presenters/service_hook_presenter.rb index 8f2ba1a905f..91911eb3dff 100644 --- a/app/presenters/service_hook_presenter.rb +++ b/app/presenters/service_hook_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ServiceHookPresenter < Gitlab::View::Presenter::Delegated - presents :service_hook + presents ::ServiceHook, as: :service_hook def logs_details_path(log) project_service_hook_log_path(integration.project, integration, log) diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb index ab8fc0f905b..4072696eb89 100644 --- a/app/presenters/snippet_blob_presenter.rb +++ b/app/presenters/snippet_blob_presenter.rb @@ -3,6 +3,8 @@ class SnippetBlobPresenter < BlobPresenter include GitlabRoutingHelper + presents ::SnippetBlob + def rich_data return unless blob.rich_viewer diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb index 695aa266e2c..8badbe7f54a 100644 --- a/app/presenters/snippet_presenter.rb +++ b/app/presenters/snippet_presenter.rb @@ -1,16 +1,18 @@ # frozen_string_literal: true class SnippetPresenter < Gitlab::View::Presenter::Delegated - presents :snippet + presents ::Snippet, as: :snippet def raw_url url_builder.build(snippet, raw: true) end + delegator_override :ssh_url_to_repo def ssh_url_to_repo snippet.ssh_url_to_repo if snippet.repository_exists? end + delegator_override :http_url_to_repo def http_url_to_repo snippet.http_url_to_repo if snippet.repository_exists? end @@ -31,6 +33,7 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated snippet.submittable_as_spam_by?(current_user) end + delegator_override :blob def blob return snippet.blob if snippet.empty_repo? diff --git a/app/presenters/terraform/modules_presenter.rb b/app/presenters/terraform/modules_presenter.rb index 608f69e2019..9e9c6a5cd2b 100644 --- a/app/presenters/terraform/modules_presenter.rb +++ b/app/presenters/terraform/modules_presenter.rb @@ -4,7 +4,7 @@ module Terraform class ModulesPresenter < Gitlab::View::Presenter::Simple attr_accessor :packages, :system - presents :modules + presents nil, as: :modules def initialize(packages, system) @packages = packages diff --git a/app/presenters/todo_presenter.rb b/app/presenters/todo_presenter.rb index 291be7848e2..cb8d37be22d 100644 --- a/app/presenters/todo_presenter.rb +++ b/app/presenters/todo_presenter.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class TodoPresenter < Gitlab::View::Presenter::Delegated - presents :todo + presents ::Todo, as: :todo end diff --git a/app/presenters/tree_entry_presenter.rb b/app/presenters/tree_entry_presenter.rb index 216b3b0d4c9..0b313d81360 100644 --- a/app/presenters/tree_entry_presenter.rb +++ b/app/presenters/tree_entry_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class TreeEntryPresenter < Gitlab::View::Presenter::Delegated - presents :tree + presents nil, as: :tree def web_url Gitlab::Routing.url_helpers.project_tree_url(tree.repository.project, File.join(tree.commit_id, tree.path)) diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb index 7cd94082bac..5a99f10b6e7 100644 --- a/app/presenters/user_presenter.rb +++ b/app/presenters/user_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class UserPresenter < Gitlab::View::Presenter::Delegated - presents :user + presents ::User, as: :user def group_memberships should_be_private? ? GroupMember.none : user.group_members diff --git a/app/presenters/web_hook_log_presenter.rb b/app/presenters/web_hook_log_presenter.rb index fca03ddb5d7..a5166589073 100644 --- a/app/presenters/web_hook_log_presenter.rb +++ b/app/presenters/web_hook_log_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class WebHookLogPresenter < Gitlab::View::Presenter::Delegated - presents :web_hook_log + presents ::WebHookLog, as: :web_hook_log def details_path web_hook.present.logs_details_path(self) diff --git a/app/serializers/feature_flag_entity.rb b/app/serializers/feature_flag_entity.rb index 80cf869a389..196a4cd504f 100644 --- a/app/serializers/feature_flag_entity.rb +++ b/app/serializers/feature_flag_entity.rb @@ -24,8 +24,8 @@ class FeatureFlagEntity < Grape::Entity project_feature_flag_path(feature_flag.project, feature_flag) end - expose :scopes, with: FeatureFlagScopeEntity do |feature_flag| - feature_flag.scopes.sort_by(&:id) + expose :scopes do |_ff| + [] end expose :strategies, with: FeatureFlags::StrategyEntity do |feature_flag| diff --git a/app/serializers/feature_flag_scope_entity.rb b/app/serializers/feature_flag_scope_entity.rb deleted file mode 100644 index 0450797a545..00000000000 --- a/app/serializers/feature_flag_scope_entity.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class FeatureFlagScopeEntity < Grape::Entity - include RequestAwareEntity - - expose :id - expose :active - expose :environment_scope - expose :created_at - expose :updated_at - expose :strategies -end diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb index 5100a41638e..d7221109ecb 100644 --- a/app/serializers/member_entity.rb +++ b/app/serializers/member_entity.rb @@ -44,6 +44,8 @@ class MemberEntity < Grape::Entity MemberUserEntity.represent(member.user, source: options[:source]) end + expose :state + expose :invite, if: -> (member) { member.invite? } do expose :email do |member| member.invite_email @@ -56,6 +58,10 @@ class MemberEntity < Grape::Entity expose :can_resend do |member| member.can_resend_invite? end + + expose :user_state do |member| + member.respond_to?(:invited_user_state) ? member.invited_user_state : "" + end end end diff --git a/app/serializers/merge_request_metrics_helper.rb b/app/serializers/merge_request_metrics_helper.rb new file mode 100644 index 00000000000..fb1769d0aa6 --- /dev/null +++ b/app/serializers/merge_request_metrics_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module MergeRequestMetricsHelper + # There are cases where where metrics object doesn't exist and it needs to be rebuilt. + # TODO: Once https://gitlab.com/gitlab-org/gitlab/-/issues/342508 has been resolved and + # all merge requests have metrics we can remove this helper method. + def build_metrics(merge_request) + # There's no need to query and serialize metrics data for merge requests that are not + # merged or closed. + return unless merge_request.merged? || merge_request.closed? + return merge_request.metrics if merge_request.merged? && merge_request.metrics&.merged_by_id + return merge_request.metrics if merge_request.closed? && merge_request.metrics&.latest_closed_by_id + + build_metrics_from_events(merge_request) + end + + private + + def build_metrics_from_events(merge_request) + closed_event = merge_request.closed_event + merge_event = merge_request.merge_event + + MergeRequest::Metrics.new(latest_closed_at: closed_event&.updated_at, + latest_closed_by: closed_event&.author, + merged_at: merge_event&.updated_at, + merged_by: merge_event&.author) + end +end diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb index 7fba52cbe17..8b0f3c8eb74 100644 --- a/app/serializers/merge_request_poll_cached_widget_entity.rb +++ b/app/serializers/merge_request_poll_cached_widget_entity.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class MergeRequestPollCachedWidgetEntity < IssuableEntity + include MergeRequestMetricsHelper + expose :auto_merge_enabled expose :state expose :merged_commit_sha @@ -158,29 +160,6 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity @presenters ||= {} @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user) # rubocop: disable CodeReuse/Presenter end - - # Once SchedulePopulateMergeRequestMetricsWithEventsData fully runs, - # we can remove this method and just serialize MergeRequest#metrics - # instead. See https://gitlab.com/gitlab-org/gitlab-foss/issues/41587 - def build_metrics(merge_request) - # There's no need to query and serialize metrics data for merge requests that are not - # merged or closed. - return unless merge_request.merged? || merge_request.closed? - return merge_request.metrics if merge_request.merged? && merge_request.metrics&.merged_by_id - return merge_request.metrics if merge_request.closed? && merge_request.metrics&.latest_closed_by_id - - build_metrics_from_events(merge_request) - end - - def build_metrics_from_events(merge_request) - closed_event = merge_request.closed_event - merge_event = merge_request.merge_event - - MergeRequest::Metrics.new(latest_closed_at: closed_event&.updated_at, - latest_closed_by: closed_event&.author, - merged_at: merge_event&.updated_at, - merged_by: merge_event&.author) - end end MergeRequestPollCachedWidgetEntity.prepend_mod_with('MergeRequestPollCachedWidgetEntity') diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 1c033dee5ff..1e4289ce774 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -83,7 +83,10 @@ class MergeRequestWidgetEntity < Grape::Entity end expose :is_dismissed_suggest_pipeline do |_merge_request| - current_user && current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE) + next true unless current_user + next true unless Gitlab::CurrentSettings.suggest_pipeline_enabled? + + current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE) end expose :human_access do |merge_request| diff --git a/app/services/base_project_service.rb b/app/services/base_project_service.rb index fb466e61673..1bf4a235a79 100644 --- a/app/services/base_project_service.rb +++ b/app/services/base_project_service.rb @@ -2,6 +2,8 @@ # Base class, scoped by project class BaseProjectService < ::BaseContainerService + include ::Gitlab::Utils::StrongMemoize + attr_accessor :project def initialize(project:, current_user: nil, params: {}) @@ -11,4 +13,12 @@ class BaseProjectService < ::BaseContainerService end delegate :repository, to: :project + + private + + def project_group + strong_memoize(:project_group) do + project.group + end + end end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 9a3e3bc3bdb..6021d634f86 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -22,7 +22,7 @@ module Boards def order(items) return items.order_closed_date_desc if list&.closed? - items.order_by_position_and_priority(with_cte: params[:search].present?) + items.order_by_relative_position end def finder diff --git a/app/services/bulk_import_service.rb b/app/services/bulk_import_service.rb deleted file mode 100644 index 4e13e967dbd..00000000000 --- a/app/services/bulk_import_service.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -# Entry point of the BulkImport feature. -# This service receives a Gitlab Instance connection params -# and a list of groups to be imported. -# -# Process topography: -# -# sync | async -# | -# User +--> P1 +----> Pn +---+ -# | ^ | Enqueue new job -# | +-----+ -# -# P1 (sync) -# -# - Create a BulkImport record -# - Create a BulkImport::Entity for each group to be imported -# - Enqueue a BulkImportWorker job (P2) to import the given groups (entities) -# -# Pn (async) -# -# - For each group to be imported (BulkImport::Entity.with_status(:created)) -# - Import the group data -# - Create entities for each subgroup of the imported group -# - Enqueue a BulkImportService job (Pn) to import the new entities (subgroups) -# -class BulkImportService - attr_reader :current_user, :params, :credentials - - def initialize(current_user, params, credentials) - @current_user = current_user - @params = params - @credentials = credentials - end - - def execute - bulk_import = create_bulk_import - - BulkImportWorker.perform_async(bulk_import.id) - - ServiceResponse.success(payload: bulk_import) - rescue ActiveRecord::RecordInvalid => e - ServiceResponse.error( - message: e.message, - http_status: :unprocessable_entity - ) - end - - private - - def create_bulk_import - BulkImport.transaction do - bulk_import = BulkImport.create!(user: current_user, source_type: 'gitlab') - bulk_import.create_configuration!(credentials.slice(:url, :access_token)) - - params.each do |entity| - BulkImports::Entity.create!( - bulk_import: bulk_import, - source_type: entity[:source_type], - source_full_path: entity[:source_full_path], - destination_name: entity[:destination_name], - destination_namespace: entity[:destination_namespace] - ) - end - - bulk_import - end - end -end diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb new file mode 100644 index 00000000000..c1becbb5609 --- /dev/null +++ b/app/services/bulk_imports/create_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Entry point of the BulkImport feature. +# This service receives a Gitlab Instance connection params +# and a list of groups to be imported. +# +# Process topography: +# +# sync | async +# | +# User +--> P1 +----> Pn +---+ +# | ^ | Enqueue new job +# | +-----+ +# +# P1 (sync) +# +# - Create a BulkImport record +# - Create a BulkImport::Entity for each group to be imported +# - Enqueue a BulkImportWorker job (P2) to import the given groups (entities) +# +# Pn (async) +# +# - For each group to be imported (BulkImport::Entity.with_status(:created)) +# - Import the group data +# - Create entities for each subgroup of the imported group +# - Enqueue a BulkImports::CreateService job (Pn) to import the new entities (subgroups) +# +module BulkImports + class CreateService + attr_reader :current_user, :params, :credentials + + def initialize(current_user, params, credentials) + @current_user = current_user + @params = params + @credentials = credentials + end + + def execute + bulk_import = create_bulk_import + + BulkImportWorker.perform_async(bulk_import.id) + + ServiceResponse.success(payload: bulk_import) + rescue ActiveRecord::RecordInvalid => e + ServiceResponse.error( + message: e.message, + http_status: :unprocessable_entity + ) + end + + private + + def create_bulk_import + BulkImport.transaction do + bulk_import = BulkImport.create!( + user: current_user, + source_type: 'gitlab', + source_version: client.instance_version + ) + bulk_import.create_configuration!(credentials.slice(:url, :access_token)) + + params.each do |entity| + BulkImports::Entity.create!( + bulk_import: bulk_import, + source_type: entity[:source_type], + source_full_path: entity[:source_full_path], + destination_name: entity[:destination_name], + destination_namespace: entity[:destination_namespace] + ) + end + + bulk_import + end + end + + def client + @client ||= BulkImports::Clients::HTTP.new( + url: @credentials[:url], + token: @credentials[:access_token] + ) + end + end +end diff --git a/app/services/bulk_imports/file_export_service.rb b/app/services/bulk_imports/file_export_service.rb new file mode 100644 index 00000000000..a7e0f998666 --- /dev/null +++ b/app/services/bulk_imports/file_export_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module BulkImports + class FileExportService + include Gitlab::ImportExport::CommandLineUtil + + def initialize(portable, export_path, relation) + @portable = portable + @export_path = export_path + @relation = relation + end + + def execute + export_service.execute + + archive_exported_data + end + + def exported_filename + "#{relation}.tar" + end + + private + + attr_reader :export_path, :portable, :relation + + def export_service + case relation + when FileTransfer::ProjectConfig::UPLOADS_RELATION + UploadsExportService.new(portable, export_path) + else + raise BulkImports::Error, 'Unsupported relation export type' + end + end + + def archive_exported_data + archive_file = File.join(export_path, exported_filename) + + tar_cf(archive: archive_file, dir: export_path) + end + end +end diff --git a/app/services/bulk_imports/get_importable_data_service.rb b/app/services/bulk_imports/get_importable_data_service.rb new file mode 100644 index 00000000000..07e0b3976a1 --- /dev/null +++ b/app/services/bulk_imports/get_importable_data_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module BulkImports + class GetImportableDataService + def initialize(params, query_params, credentials) + @params = params + @query_params = query_params + @credentials = credentials + end + + def execute + { + version_validation: version_validation, + response: importables + } + end + + private + + def importables + client.get('groups', @query_params) + end + + def version_validation + { + features: { + project_migration: { + available: client.compatible_for_project_migration?, + min_version: BulkImport.min_gl_version_for_project_migration.to_s + }, + source_instance_version: client.instance_version.to_s + } + } + end + + def client + @client ||= BulkImports::Clients::HTTP.new( + url: @credentials[:url], + token: @credentials[:access_token], + per_page: @params[:per_page], + page: @params[:page] + ) + end + end +end diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb index 055f9cafd10..4718b3914b2 100644 --- a/app/services/bulk_imports/relation_export_service.rb +++ b/app/services/bulk_imports/relation_export_service.rb @@ -9,20 +9,23 @@ module BulkImports @portable = portable @relation = relation @jid = jid + @config = FileTransfer.config_for(portable) end def execute find_or_create_export! do |export| remove_existing_export_file!(export) - serialize_relation_to_file(export.relation_definition) + export_service.execute compress_exported_relation upload_compressed_file(export) end + ensure + FileUtils.remove_entry(config.export_path) end private - attr_reader :user, :portable, :relation, :jid + attr_reader :user, :portable, :relation, :jid, :config def find_or_create_export! validate_user_permissions! @@ -55,52 +58,28 @@ module BulkImports upload.save! end - def serialize_relation_to_file(relation_definition) - serializer.serialize_relation(relation_definition) - end - - def compress_exported_relation - gzip(dir: export_path, filename: ndjson_filename) + def export_service + @export_service ||= if config.tree_relation?(relation) + TreeExportService.new(portable, config.export_path, relation) + elsif config.file_relation?(relation) + FileExportService.new(portable, config.export_path, relation) + else + raise BulkImports::Error, 'Unsupported export relation' + end end def upload_compressed_file(export) - compressed_filename = File.join(export_path, "#{ndjson_filename}.gz") + compressed_file = File.join(config.export_path, "#{export_service.exported_filename}.gz") + upload = ExportUpload.find_or_initialize_by(export_id: export.id) # rubocop: disable CodeReuse/ActiveRecord - File.open(compressed_filename) { |file| upload.export_file = file } + File.open(compressed_file) { |file| upload.export_file = file } upload.save! end - def config - @config ||= FileTransfer.config_for(portable) - end - - def export_path - @export_path ||= config.export_path - end - - def portable_tree - @portable_tree ||= config.portable_tree - end - - # rubocop: disable CodeReuse/Serializer - def serializer - @serializer ||= ::Gitlab::ImportExport::Json::StreamingSerializer.new( - portable, - portable_tree, - json_writer, - exportable_path: '' - ) - end - # rubocop: enable CodeReuse/Serializer - - def json_writer - @json_writer ||= ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path) - end - - def ndjson_filename - @ndjson_filename ||= "#{relation}.ndjson" + def compress_exported_relation + gzip(dir: config.export_path, filename: export_service.exported_filename) end end end diff --git a/app/services/bulk_imports/tree_export_service.rb b/app/services/bulk_imports/tree_export_service.rb new file mode 100644 index 00000000000..b8e7ac4574b --- /dev/null +++ b/app/services/bulk_imports/tree_export_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module BulkImports + class TreeExportService + def initialize(portable, export_path, relation) + @portable = portable + @export_path = export_path + @relation = relation + @config = FileTransfer.config_for(portable) + end + + def execute + relation_definition = config.tree_relation_definition_for(relation) + + raise BulkImports::Error, 'Unsupported relation export type' unless relation_definition + + serializer.serialize_relation(relation_definition) + end + + def exported_filename + "#{relation}.ndjson" + end + + private + + attr_reader :export_path, :portable, :relation, :config + + # rubocop: disable CodeReuse/Serializer + def serializer + ::Gitlab::ImportExport::Json::StreamingSerializer.new( + portable, + config.portable_tree, + json_writer, + exportable_path: '' + ) + end + # rubocop: enable CodeReuse/Serializer + + def json_writer + ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path) + end + end +end diff --git a/app/services/bulk_imports/uploads_export_service.rb b/app/services/bulk_imports/uploads_export_service.rb new file mode 100644 index 00000000000..32cc48c152c --- /dev/null +++ b/app/services/bulk_imports/uploads_export_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module BulkImports + class UploadsExportService + include Gitlab::ImportExport::CommandLineUtil + + BATCH_SIZE = 100 + + def initialize(portable, export_path) + @portable = portable + @export_path = export_path + end + + def execute + portable.uploads.find_each(batch_size: BATCH_SIZE) do |upload| # rubocop: disable CodeReuse/ActiveRecord + uploader = upload.retrieve_uploader + + next unless upload.exist? + next unless uploader.file + + subdir_path = export_subdir_path(upload) + mkdir_p(subdir_path) + download_or_copy_upload(uploader, File.join(subdir_path, uploader.filename)) + rescue Errno::ENAMETOOLONG => e + # Do not fail entire export process if downloaded file has filename that exceeds 255 characters. + # Ignore raised exception, skip such upload, log the error and keep going with the export instead. + Gitlab::ErrorTracking.log_exception(e, portable_id: portable.id, portable_class: portable.class.name, upload_id: upload.id) + end + end + + private + + attr_reader :portable, :export_path + + def export_subdir_path(upload) + subdir = if upload.path == avatar_path + 'avatar' + else + upload.try(:secret).to_s + end + + File.join(export_path, subdir) + end + + def avatar_path + @avatar_path ||= portable.avatar&.upload&.path + end + end +end diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb index 995b58c6882..17cac38ace2 100644 --- a/app/services/ci/archive_trace_service.rb +++ b/app/services/ci/archive_trace_service.rb @@ -3,10 +3,15 @@ module Ci class ArchiveTraceService def execute(job, worker_name:) + unless job.trace.archival_attempts_available? + Sidekiq.logger.warn(class: worker_name, message: 'The job is out of archival attempts.', job_id: job.id) + + job.trace.attempt_archive_cleanup! + return + end + unless job.trace.can_attempt_archival_now? - Sidekiq.logger.warn(class: worker_name, - message: job.trace.archival_attempts_message, - job_id: job.id) + Sidekiq.logger.warn(class: worker_name, message: 'The job can not be archived right now.', job_id: job.id) return end diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb index dd5c8e0379f..476c7523d60 100644 --- a/app/services/ci/destroy_pipeline_service.rb +++ b/app/services/ci/destroy_pipeline_service.rb @@ -9,6 +9,9 @@ module Ci pipeline.cancel_running if pipeline.cancelable? + # Ci::Pipeline#destroy triggers `use_fast_destroy :job_artifacts` and + # ci_builds has ON DELETE CASCADE to ci_pipelines. The pipeline, the builds, + # job and pipeline artifacts all get destroyed here. pipeline.reset.destroy! ServiceResponse.success(message: 'Pipeline not found') diff --git a/app/services/ci/pipelines/add_job_service.rb b/app/services/ci/pipelines/add_job_service.rb index 53536b6fdf9..703bb22fb5d 100644 --- a/app/services/ci/pipelines/add_job_service.rb +++ b/app/services/ci/pipelines/add_job_service.rb @@ -16,15 +16,7 @@ module Ci def execute!(job, &block) assign_pipeline_attributes(job) - if Feature.enabled?(:ci_pipeline_add_job_with_lock, pipeline.project, default_enabled: :yaml) - in_lock("ci:pipelines:#{pipeline.id}:add-job", ttl: LOCK_TIMEOUT, sleep_sec: LOCK_SLEEP, retries: LOCK_RETRIES) do - Ci::Pipeline.transaction do - yield(job) - - job.update_older_statuses_retried! - end - end - else + in_lock("ci:pipelines:#{pipeline.id}:add-job", ttl: LOCK_TIMEOUT, sleep_sec: LOCK_SLEEP, retries: LOCK_RETRIES) do Ci::Pipeline.transaction do yield(job) diff --git a/app/services/ci/pipelines/hook_service.rb b/app/services/ci/pipelines/hook_service.rb new file mode 100644 index 00000000000..629ed7e1ebd --- /dev/null +++ b/app/services/ci/pipelines/hook_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Ci + module Pipelines + class HookService + include Gitlab::Utils::StrongMemoize + + HOOK_NAME = :pipeline_hooks + + def initialize(pipeline) + @pipeline = pipeline + end + + def execute + project.execute_hooks(hook_data, HOOK_NAME) if project.has_active_hooks?(HOOK_NAME) + project.execute_integrations(hook_data, HOOK_NAME) if project.has_active_integrations?(HOOK_NAME) + end + + private + + attr_reader :pipeline + + def project + @project ||= pipeline.project + end + + def hook_data + strong_memoize(:hook_data) do + Gitlab::DataBuilder::Pipeline.build(pipeline) + end + end + end + end +end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index fb26d5d3356..664915c5e2f 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -11,8 +11,6 @@ module Ci def execute increment_processing_counter - update_retried - Ci::PipelineProcessing::AtomicProcessingService .new(pipeline) .execute @@ -24,41 +22,6 @@ module Ci private - # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab - # This replicates what is db/post_migrate/20170416103934_upate_retried_for_ci_build.rb - # and ensures that functionality will not be broken before migration is run - # this updates only when there are data that needs to be updated, there are two groups with no retried flag - # rubocop: disable CodeReuse/ActiveRecord - def update_retried - return if Feature.enabled?(:ci_remove_update_retried_from_process_pipeline, pipeline.project, default_enabled: :yaml) - - # find the latest builds for each name - latest_statuses = pipeline.latest_statuses - .group(:name) - .having('count(*) > 1') - .pluck(Arel.sql('MAX(id)'), 'name') - - # mark builds that are retried - if latest_statuses.any? - updated_count = pipeline.latest_statuses - .where(name: latest_statuses.map(&:second)) - .where.not(id: latest_statuses.map(&:first)) - .update_all(retried: true) - - # This counter is temporary. It will be used to check whether if we still use this method or not - # after setting correct value of `GenericCommitStatus#retried`. - # More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50465#note_491657115 - if updated_count > 0 - Gitlab::AppJsonLogger.info(event: 'update_retried_is_used', - project_id: pipeline.project.id, - pipeline_id: pipeline.id) - - metrics.legacy_update_jobs_counter.increment - end - end - end - # rubocop: enable CodeReuse/ActiveRecord - def increment_processing_counter metrics.pipeline_processing_events_counter.increment end diff --git a/app/services/ci/queue/build_queue_service.rb b/app/services/ci/queue/build_queue_service.rb index 3276c427923..3c886cb023f 100644 --- a/app/services/ci/queue/build_queue_service.rb +++ b/app/services/ci/queue/build_queue_service.rb @@ -90,7 +90,7 @@ module Ci def runner_projects_relation if ::Feature.enabled?(:ci_pending_builds_project_runners_decoupling, runner, default_enabled: :yaml) - runner.runner_projects.select(:project_id) + runner.runner_projects.select('"ci_runner_projects"."project_id"::bigint') else runner.projects.without_deleted.with_builds_enabled end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index c46ddd22558..67ef4f10709 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -22,7 +22,8 @@ module Ci end def execute(params = {}) - db_all_caught_up = ::Gitlab::Database::LoadBalancing::Sticking.all_caught_up?(:runner, runner.id) + db_all_caught_up = + ::Ci::Runner.sticking.all_caught_up?(:runner, runner.id) @metrics.increment_queue_operation(:queue_attempt) @@ -103,42 +104,40 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def each_build(params, &blk) - ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339429') do - queue = ::Ci::Queue::BuildQueueService.new(runner) - - builds = begin - if runner.instance_type? - queue.builds_for_shared_runner - elsif runner.group_type? - queue.builds_for_group_runner - else - queue.builds_for_project_runner - end - end + queue = ::Ci::Queue::BuildQueueService.new(runner) - if runner.ref_protected? - builds = queue.builds_for_protected_runner(builds) + builds = begin + if runner.instance_type? + queue.builds_for_shared_runner + elsif runner.group_type? + queue.builds_for_group_runner + else + queue.builds_for_project_runner end + end - # pick builds that does not have other tags than runner's one - builds = queue.builds_matching_tag_ids(builds, runner.tags.ids) + if runner.ref_protected? + builds = queue.builds_for_protected_runner(builds) + end - # pick builds that have at least one tag - unless runner.run_untagged? - builds = queue.builds_with_any_tags(builds) - end + # pick builds that does not have other tags than runner's one + builds = queue.builds_matching_tag_ids(builds, runner.tags.ids) - # pick builds that older than specified age - if params.key?(:job_age) - builds = queue.builds_queued_before(builds, params[:job_age].seconds.ago) - end + # pick builds that have at least one tag + unless runner.run_untagged? + builds = queue.builds_with_any_tags(builds) + end - build_ids = retrieve_queue(-> { queue.execute(builds) }) + # pick builds that older than specified age + if params.key?(:job_age) + builds = queue.builds_queued_before(builds, params[:job_age].seconds.ago) + end - @metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type) + build_ids = retrieve_queue(-> { queue.execute(builds) }) - build_ids.each { |build_id| yield Ci::Build.find(build_id) } - end + @metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type) + + build_ids.each { |build_id| yield Ci::Build.find(build_id) } end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb index 1d329fe7b53..dfd97498fc8 100644 --- a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb +++ b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb @@ -9,7 +9,7 @@ module Ci free_resources = resource_group.resources.free.count - resource_group.processables.waiting_for_resource.take(free_resources).each do |processable| + resource_group.upcoming_processables.take(free_resources).each do |processable| processable.enqueue_waiting_for_resource end end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 08520c9514c..07cfbb9ce3c 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -17,7 +17,7 @@ module Ci def execute(build) build.ensure_scheduling_type! - reprocess!(build).tap do |new_build| + clone!(build).tap do |new_build| check_assignable_runners!(new_build) next if new_build.failed? @@ -31,7 +31,12 @@ module Ci end # rubocop: disable CodeReuse/ActiveRecord - def reprocess!(build) + def clone!(build) + # Cloning a build requires a strict type check to ensure + # the attributes being used for the clone are taken straight + # from the model and not overridden by other abstractions. + raise TypeError unless build.instance_of?(Ci::Build) + check_access!(build) new_build = clone_build(build) diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index 02ee40d2cf6..9ad46ca7585 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -9,20 +9,15 @@ module Ci raise Gitlab::Access::AccessDeniedError end - needs = Set.new - pipeline.ensure_scheduling_type! builds_relation(pipeline).find_each do |build| next unless can_be_retried?(build) - Ci::RetryBuildService.new(project, current_user) - .reprocess!(build) - - needs += build.needs.map(&:name) + Ci::RetryBuildService.new(project, current_user).clone!(build) end - pipeline.builds.latest.skipped.find_each do |skipped| + pipeline.processables.latest.skipped.find_each do |skipped| retry_optimistic_lock(skipped, name: 'ci_retry_pipeline') { |build| build.process(current_user) } end diff --git a/app/services/ci/stuck_builds/drop_service.rb b/app/services/ci/stuck_builds/drop_pending_service.rb index 3fee9a94381..4653e701973 100644 --- a/app/services/ci/stuck_builds/drop_service.rb +++ b/app/services/ci/stuck_builds/drop_pending_service.rb @@ -2,27 +2,21 @@ module Ci module StuckBuilds - class DropService + class DropPendingService include DropHelpers - BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour BUILD_PENDING_OUTDATED_TIMEOUT = 1.day - BUILD_SCHEDULED_OUTDATED_TIMEOUT = 1.hour BUILD_PENDING_STUCK_TIMEOUT = 1.hour BUILD_LOOKBACK = 5.days def execute - Gitlab::AppLogger.info "#{self.class}: Cleaning stuck builds" - - drop(running_timed_out_builds, failure_reason: :stuck_or_timeout_failure) + Gitlab::AppLogger.info "#{self.class}: Cleaning pending timed-out builds" drop( pending_builds(BUILD_PENDING_OUTDATED_TIMEOUT.ago), failure_reason: :stuck_or_timeout_failure ) - drop(scheduled_timed_out_builds, failure_reason: :stale_schedule) - drop_stuck( pending_builds(BUILD_PENDING_STUCK_TIMEOUT.ago), failure_reason: :stuck_or_timeout_failure @@ -43,20 +37,6 @@ module Ci end end # rubocop: enable CodeReuse/ActiveRecord - - def scheduled_timed_out_builds - Ci::Build.where(status: :scheduled).where( # rubocop: disable CodeReuse/ActiveRecord - 'ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?', - BUILD_SCHEDULED_OUTDATED_TIMEOUT.ago - ) - end - - def running_timed_out_builds - Ci::Build.running.where( # rubocop: disable CodeReuse/ActiveRecord - 'ci_builds.updated_at < ?', - BUILD_RUNNING_OUTDATED_TIMEOUT.ago - ) - end end end end diff --git a/app/services/ci/stuck_builds/drop_running_service.rb b/app/services/ci/stuck_builds/drop_running_service.rb new file mode 100644 index 00000000000..a79224cc231 --- /dev/null +++ b/app/services/ci/stuck_builds/drop_running_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Ci + module StuckBuilds + class DropRunningService + include DropHelpers + + BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour + + def execute + Gitlab::AppLogger.info "#{self.class}: Cleaning running, timed-out builds" + + drop(running_timed_out_builds, failure_reason: :stuck_or_timeout_failure) + end + + private + + def running_timed_out_builds + if Feature.enabled?(:ci_new_query_for_running_stuck_jobs, default_enabled: :yaml) + Ci::Build + .running + .created_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago) + .updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago) + .order(created_at: :asc, project_id: :asc) # rubocop:disable CodeReuse/ActiveRecord + else + Ci::Build.running.updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago) + end + end + end + end +end diff --git a/app/services/ci/stuck_builds/drop_scheduled_service.rb b/app/services/ci/stuck_builds/drop_scheduled_service.rb new file mode 100644 index 00000000000..d4f4252c2c0 --- /dev/null +++ b/app/services/ci/stuck_builds/drop_scheduled_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Ci + module StuckBuilds + class DropScheduledService + include DropHelpers + + BUILD_SCHEDULED_OUTDATED_TIMEOUT = 1.hour + + def execute + Gitlab::AppLogger.info "#{self.class}: Cleaning scheduled, timed-out builds" + + drop(scheduled_timed_out_builds, failure_reason: :stale_schedule) + end + + private + + def scheduled_timed_out_builds + Ci::Build.scheduled.scheduled_at_before(BUILD_SCHEDULED_OUTDATED_TIMEOUT.ago) + end + end + end +end diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb index abd50d2f110..3b403f92486 100644 --- a/app/services/ci/update_build_state_service.rb +++ b/app/services/ci/update_build_state_service.rb @@ -73,9 +73,11 @@ module Ci ::Gitlab::Ci::Trace::Checksum.new(build).then do |checksum| unless checksum.valid? metrics.increment_trace_operation(operation: :invalid) + metrics.increment_error_counter(type: :chunks_invalid_checksum) if checksum.corrupted? metrics.increment_trace_operation(operation: :corrupted) + metrics.increment_error_counter(type: :chunks_invalid_size) end next unless log_invalid_chunks? diff --git a/app/services/ci/update_pending_build_service.rb b/app/services/ci/update_pending_build_service.rb index dcba06e60bf..d546dbcfe3d 100644 --- a/app/services/ci/update_pending_build_service.rb +++ b/app/services/ci/update_pending_build_service.rb @@ -2,7 +2,7 @@ module Ci class UpdatePendingBuildService - VALID_PARAMS = %i[instance_runners_enabled].freeze + VALID_PARAMS = %i[instance_runners_enabled namespace_id namespace_traversal_ids].freeze InvalidParamsError = Class.new(StandardError) InvalidModelError = Class.new(StandardError) diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb new file mode 100644 index 00000000000..ae2617f510b --- /dev/null +++ b/app/services/clusters/agent_tokens/create_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Clusters + module AgentTokens + class CreateService < ::BaseContainerService + ALLOWED_PARAMS = %i[agent_id description name].freeze + + def execute + return error_no_permissions unless current_user.can?(:create_cluster, container) + + token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user)) + + if token.save + ServiceResponse.success(payload: { secret: token.token, token: token }) + else + ServiceResponse.error(message: token.errors.full_messages) + end + end + + private + + def error_no_permissions + ServiceResponse.error(message: s_('ClusterAgent|User has insufficient permissions to create a token for this project')) + end + + def filtered_params + params.slice(*ALLOWED_PARAMS) + end + end + end +end diff --git a/app/services/clusters/agents/create_service.rb b/app/services/clusters/agents/create_service.rb new file mode 100644 index 00000000000..568f168d63b --- /dev/null +++ b/app/services/clusters/agents/create_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Clusters + module Agents + class CreateService < BaseService + def execute(name:) + return error_no_permissions unless cluster_agent_permissions? + + agent = ::Clusters::Agent.new(name: name, project: project, created_by_user: current_user) + + if agent.save + success.merge(cluster_agent: agent) + else + error(agent.errors.full_messages) + end + end + + private + + def cluster_agent_permissions? + current_user.can?(:admin_pipeline, project) && current_user.can?(:create_cluster, project) + end + + def error_no_permissions + error(s_('ClusterAgent|You have insufficient permissions to create a cluster agent for this project')) + end + end + end +end diff --git a/app/services/clusters/agents/delete_service.rb b/app/services/clusters/agents/delete_service.rb new file mode 100644 index 00000000000..2132dffa606 --- /dev/null +++ b/app/services/clusters/agents/delete_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Clusters + module Agents + class DeleteService < ::BaseContainerService + def execute(cluster_agent) + return error_no_permissions unless current_user.can?(:admin_cluster, cluster_agent) + + if cluster_agent.destroy + ServiceResponse.success + else + ServiceResponse.error(message: cluster_agent.errors.full_messages) + end + end + + private + + def error_no_permissions + ServiceResponse.error(message: s_('ClusterAgent|You have insufficient permissions to delete this cluster agent')) + end + end + end +end diff --git a/app/services/clusters/agents/refresh_authorization_service.rb b/app/services/clusters/agents/refresh_authorization_service.rb index a9e3340dbf5..7f401eef720 100644 --- a/app/services/clusters/agents/refresh_authorization_service.rb +++ b/app/services/clusters/agents/refresh_authorization_service.rb @@ -99,7 +99,7 @@ module Clusters end def group_root_ancestor? - root_ancestor.group? + root_ancestor.group_namespace? end end end diff --git a/app/services/concerns/rate_limited_service.rb b/app/services/concerns/rate_limited_service.rb new file mode 100644 index 00000000000..87cba7814fe --- /dev/null +++ b/app/services/concerns/rate_limited_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module RateLimitedService + extend ActiveSupport::Concern + + RateLimitedNotSetupError = Class.new(StandardError) + + class RateLimitedError < StandardError + def initialize(key:, rate_limiter:) + @key = key + @rate_limiter = rate_limiter + end + + def headers + # TODO: This will be fleshed out in https://gitlab.com/gitlab-org/gitlab/-/issues/342370 + {} + end + + def log_request(request, current_user) + rate_limiter.class.log_request(request, "#{key}_request_limit".to_sym, current_user) + end + + private + + attr_reader :key, :rate_limiter + end + + class RateLimiterScopedAndKeyed + attr_reader :key, :opts, :rate_limiter_klass + + def initialize(key:, opts:, rate_limiter_klass:) + @key = key + @opts = opts + @rate_limiter_klass = rate_limiter_klass + end + + def rate_limit!(service) + evaluated_scope = evaluated_scope_for(service) + return if feature_flag_disabled?(evaluated_scope[:project]) + + rate_limiter = new_rate_limiter(evaluated_scope) + if rate_limiter.throttled? + raise RateLimitedError.new(key: key, rate_limiter: rate_limiter), _('This endpoint has been requested too many times. Try again later.') + end + end + + private + + def users_allowlist + @users_allowlist ||= opts[:users_allowlist] ? opts[:users_allowlist].call : [] + end + + def evaluated_scope_for(service) + opts[:scope].each_with_object({}) do |var, all| + all[var] = service.public_send(var) # rubocop: disable GitlabSecurity/PublicSend + end + end + + def feature_flag_disabled?(project) + Feature.disabled?("rate_limited_service_#{key}", project, default_enabled: :yaml) + end + + def new_rate_limiter(evaluated_scope) + rate_limiter_klass.new(key, **opts.merge(scope: evaluated_scope.values, users_allowlist: users_allowlist)) + end + end + + prepended do + attr_accessor :rate_limiter_bypassed + cattr_accessor :rate_limiter_scoped_and_keyed + + def self.rate_limit(key:, opts:, rate_limiter_klass: ::Gitlab::ApplicationRateLimiter) + self.rate_limiter_scoped_and_keyed = RateLimiterScopedAndKeyed.new(key: key, + opts: opts, + rate_limiter_klass: rate_limiter_klass) + end + end + + def execute_without_rate_limiting(*args, **kwargs) + self.rate_limiter_bypassed = true + execute(*args, **kwargs) + ensure + self.rate_limiter_bypassed = false + end + + def execute(*args, **kwargs) + raise RateLimitedNotSetupError if rate_limiter_scoped_and_keyed.nil? + + rate_limiter_scoped_and_keyed.rate_limit!(self) unless rate_limiter_bypassed + + super + end +end diff --git a/app/services/container_expiration_policies/cleanup_service.rb b/app/services/container_expiration_policies/cleanup_service.rb index cd988cdc5fe..0da5e552c48 100644 --- a/app/services/container_expiration_policies/cleanup_service.rb +++ b/app/services/container_expiration_policies/cleanup_service.rb @@ -4,7 +4,7 @@ module ContainerExpirationPolicies class CleanupService attr_reader :repository - SERVICE_RESULT_FIELDS = %i[original_size before_truncate_size after_truncate_size before_delete_size deleted_size].freeze + SERVICE_RESULT_FIELDS = %i[original_size before_truncate_size after_truncate_size before_delete_size deleted_size cached_tags_count].freeze def initialize(repository) @repository = repository @@ -24,8 +24,8 @@ module ContainerExpirationPolicies begin service_result = Projects::ContainerRepository::CleanupTagsService - .new(project, nil, policy_params.merge('container_expiration_policy' => true)) - .execute(repository) + .new(repository, nil, policy_params.merge('container_expiration_policy' => true)) + .execute rescue StandardError repository.cleanup_unfinished! diff --git a/app/services/customer_relations/contacts/base_service.rb b/app/services/customer_relations/contacts/base_service.rb new file mode 100644 index 00000000000..89f6f2c3f1f --- /dev/null +++ b/app/services/customer_relations/contacts/base_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module CustomerRelations + module Contacts + class BaseService < ::BaseGroupService + private + + def allowed? + current_user&.can?(:admin_contact, group) + end + + def error(message) + ServiceResponse.error(message: Array(message)) + end + end + end +end diff --git a/app/services/customer_relations/contacts/create_service.rb b/app/services/customer_relations/contacts/create_service.rb new file mode 100644 index 00000000000..7ff8b731e0d --- /dev/null +++ b/app/services/customer_relations/contacts/create_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module CustomerRelations + module Contacts + class CreateService < BaseService + def execute + return error_no_permissions unless allowed? + return error_organization_invalid unless organization_valid? + + contact = Contact.create(params.merge(group_id: group.id)) + + return error_creating(contact) unless contact.persisted? + + ServiceResponse.success(payload: contact) + end + + private + + def organization_valid? + return true unless params[:organization_id] + + organization = Organization.find(params[:organization_id]) + organization.group_id == group.id + rescue ActiveRecord::RecordNotFound + false + end + + def error_organization_invalid + error('The specified organization was not found or does not belong to this group') + end + + def error_no_permissions + error('You have insufficient permissions to create a contact for this group') + end + + def error_creating(contact) + error(contact&.errors&.full_messages || 'Failed to create contact') + end + end + end +end diff --git a/app/services/customer_relations/contacts/update_service.rb b/app/services/customer_relations/contacts/update_service.rb new file mode 100644 index 00000000000..473a80be262 --- /dev/null +++ b/app/services/customer_relations/contacts/update_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module CustomerRelations + module Contacts + class UpdateService < BaseService + def execute(contact) + return error_no_permissions unless allowed? + return error_updating(contact) unless contact.update(params) + + ServiceResponse.success(payload: contact) + end + + private + + def error_no_permissions + error('You have insufficient permissions to update a contact for this group') + end + + def error_updating(contact) + error(contact&.errors&.full_messages || 'Failed to update contact') + end + end + end +end diff --git a/app/services/customer_relations/organizations/base_service.rb b/app/services/customer_relations/organizations/base_service.rb index 63261534b37..8f8480d697c 100644 --- a/app/services/customer_relations/organizations/base_service.rb +++ b/app/services/customer_relations/organizations/base_service.rb @@ -10,7 +10,7 @@ module CustomerRelations end def error(message) - ServiceResponse.error(message: message) + ServiceResponse.error(message: Array(message)) end end end diff --git a/app/services/customer_relations/organizations/create_service.rb b/app/services/customer_relations/organizations/create_service.rb index 9c223796eaf..aad1b7e2ca4 100644 --- a/app/services/customer_relations/organizations/create_service.rb +++ b/app/services/customer_relations/organizations/create_service.rb @@ -7,9 +7,7 @@ module CustomerRelations def execute return error_no_permissions unless allowed? - params[:group_id] = group.id - - organization = Organization.create(params) + organization = Organization.create(params.merge(group_id: group.id)) return error_creating(organization) unless organization.persisted? diff --git a/app/services/dependency_proxy/auth_token_service.rb b/app/services/dependency_proxy/auth_token_service.rb index 16279ed12b0..c6c9eb534bb 100644 --- a/app/services/dependency_proxy/auth_token_service.rb +++ b/app/services/dependency_proxy/auth_token_service.rb @@ -12,10 +12,16 @@ module DependencyProxy JSONWebToken::HMACToken.decode(token, ::Auth::DependencyProxyAuthenticationService.secret).first end - class << self - def decoded_token_payload(token) - self.new(token).execute + def self.user_or_deploy_token_from_jwt(raw_jwt) + token_payload = self.new(raw_jwt).execute + + if token_payload['user_id'] + User.find(token_payload['user_id']) + elsif token_payload['deploy_token'] + DeployToken.active.find_by_token(token_payload['deploy_token']) end + rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature + nil end end end diff --git a/app/services/dependency_proxy/find_or_create_blob_service.rb b/app/services/dependency_proxy/find_or_create_blob_service.rb index f3dbf31dcdb..0a6db6e3d34 100644 --- a/app/services/dependency_proxy/find_or_create_blob_service.rb +++ b/app/services/dependency_proxy/find_or_create_blob_service.rb @@ -12,7 +12,7 @@ module DependencyProxy def execute from_cache = true file_name = @blob_sha.sub('sha256:', '') + '.gz' - blob = @group.dependency_proxy_blobs.find_or_build(file_name) + blob = @group.dependency_proxy_blobs.active.find_or_build(file_name) unless blob.persisted? from_cache = false @@ -30,6 +30,8 @@ module DependencyProxy blob.save! end + # Technical debt: change to read_at https://gitlab.com/gitlab-org/gitlab/-/issues/341536 + blob.touch if from_cache success(blob: blob, from_cache: from_cache) end diff --git a/app/services/dependency_proxy/find_or_create_manifest_service.rb b/app/services/dependency_proxy/find_or_create_manifest_service.rb index 0eb990ab7f8..1976d4d47f4 100644 --- a/app/services/dependency_proxy/find_or_create_manifest_service.rb +++ b/app/services/dependency_proxy/find_or_create_manifest_service.rb @@ -13,11 +13,16 @@ module DependencyProxy def execute @manifest = @group.dependency_proxy_manifests + .active .find_or_initialize_by_file_name_or_digest(file_name: @file_name, digest: @tag) head_result = DependencyProxy::HeadManifestService.new(@image, @tag, @token).execute - return success(manifest: @manifest, from_cache: true) if cached_manifest_matches?(head_result) + if cached_manifest_matches?(head_result) + @manifest.touch + + return success(manifest: @manifest, from_cache: true) + end pull_new_manifest respond(from_cache: false) @@ -46,6 +51,9 @@ module DependencyProxy def respond(from_cache: true) if @manifest.persisted? + # Technical debt: change to read_at https://gitlab.com/gitlab-org/gitlab/-/issues/341536 + @manifest.touch if from_cache + success(manifest: @manifest, from_cache: from_cache) else error('Failed to download the manifest from the external registry', 503) diff --git a/app/services/dependency_proxy/group_settings/update_service.rb b/app/services/dependency_proxy/group_settings/update_service.rb new file mode 100644 index 00000000000..ba43452def3 --- /dev/null +++ b/app/services/dependency_proxy/group_settings/update_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module DependencyProxy + module GroupSettings + class UpdateService < BaseContainerService + ALLOWED_ATTRIBUTES = %i[enabled].freeze + + def execute + return ServiceResponse.error(message: 'Access Denied', http_status: 403) unless allowed? + return ServiceResponse.error(message: 'Dependency proxy setting not found', http_status: 404) unless dependency_proxy_setting + + if dependency_proxy_setting.update(dependency_proxy_setting_params) + ServiceResponse.success(payload: { dependency_proxy_setting: dependency_proxy_setting }) + else + ServiceResponse.error( + message: dependency_proxy_setting.errors.full_messages.to_sentence || 'Bad request', + http_status: 400 + ) + end + end + + private + + def dependency_proxy_setting + container.dependency_proxy_setting + end + + def allowed? + Ability.allowed?(current_user, :admin_dependency_proxy, container) + end + + def dependency_proxy_setting_params + params.slice(*ALLOWED_ATTRIBUTES) + end + end + end +end diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb index 100d1267848..504b55b99ac 100644 --- a/app/services/deployments/older_deployments_drop_service.rb +++ b/app/services/deployments/older_deployments_drop_service.rb @@ -11,23 +11,23 @@ module Deployments def execute return unless @deployment&.running? - older_deployments.find_each do |older_deployment| - Gitlab::OptimisticLocking.retry_lock(older_deployment.deployable, name: 'older_deployments_drop') do |deployable| - deployable.drop(:forward_deployment_failure) + older_deployments_builds.each do |build| + Gitlab::OptimisticLocking.retry_lock(build, name: 'older_deployments_drop') do |build| + build.drop(:forward_deployment_failure) end rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, deployment_id: older_deployment.id) + Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, build_id: build.id) end end private - def older_deployments + def older_deployments_builds @deployment .environment .active_deployments .older_than(@deployment) - .with_deployable + .builds end end end diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb index 86c7791e759..1979816b88d 100644 --- a/app/services/error_tracking/list_issues_service.rb +++ b/app/services/error_tracking/list_issues_service.rb @@ -76,16 +76,21 @@ module ErrorTracking filter_opts = { status: opts[:issue_status], sort: opts[:sort], - limit: opts[:limit] + limit: opts[:limit], + cursor: opts[:cursor] } errors = ErrorTracking::ErrorsFinder.new(current_user, project, filter_opts).execute + pagination = {} + pagination[:next] = { cursor: errors.cursor_for_next_page } if errors.has_next_page? + pagination[:previous] = { cursor: errors.cursor_for_previous_page } if errors.has_previous_page? + # We use the same response format as project_error_tracking_setting # method below for compatibility with existing code. { issues: errors.map(&:to_sentry_error), - pagination: {} + pagination: pagination } else project_error_tracking_setting.list_sentry_issues(**opts) diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb index 9ae9ab4de63..ca0b6b89199 100644 --- a/app/services/feature_flags/base_service.rb +++ b/app/services/feature_flags/base_service.rb @@ -7,6 +7,8 @@ module FeatureFlags AUDITABLE_ATTRIBUTES = %w(name description active).freeze def success(**args) + audit_event = args.fetch(:audit_event) { audit_event(args[:feature_flag]) } + save_audit_event(audit_event) sync_to_jira(args[:feature_flag]) super end @@ -66,5 +68,11 @@ module FeatureFlags feature_flag_by_name.scopes.find_by_environment_scope(params[:environment_scope]) end end + + private + + def audit_message(feature_flag) + raise NotImplementedError, "This method should be overriden by subclasses" + end end end diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb index 65f8f8e33f6..ebbe71f39c7 100644 --- a/app/services/feature_flags/create_service.rb +++ b/app/services/feature_flags/create_service.rb @@ -10,8 +10,6 @@ module FeatureFlags feature_flag = project.operations_feature_flags.new(params) if feature_flag.save - save_audit_event(audit_event(feature_flag)) - success(feature_flag: feature_flag) else error(feature_flag.errors.full_messages, 400) diff --git a/app/services/feature_flags/destroy_service.rb b/app/services/feature_flags/destroy_service.rb index 986fe004db6..817a80940c0 100644 --- a/app/services/feature_flags/destroy_service.rb +++ b/app/services/feature_flags/destroy_service.rb @@ -13,8 +13,6 @@ module FeatureFlags ApplicationRecord.transaction do if feature_flag.destroy - save_audit_event(audit_event(feature_flag)) - success(feature_flag: feature_flag) else error(feature_flag.errors.full_messages) diff --git a/app/services/feature_flags/hook_service.rb b/app/services/feature_flags/hook_service.rb new file mode 100644 index 00000000000..6f77a70bd09 --- /dev/null +++ b/app/services/feature_flags/hook_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module FeatureFlags + class HookService + HOOK_NAME = :feature_flag_hooks + + def initialize(feature_flag, current_user) + @feature_flag = feature_flag + @current_user = current_user + end + + def execute + project.execute_hooks(hook_data, HOOK_NAME) + end + + private + + attr_reader :feature_flag, :current_user + + def project + @project ||= feature_flag.project + end + + def hook_data + Gitlab::DataBuilder::FeatureFlag.build(feature_flag, current_user) + end + end +end diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb index ccfd1b57d44..bcfd2c15189 100644 --- a/app/services/feature_flags/update_service.rb +++ b/app/services/feature_flags/update_service.rb @@ -7,6 +7,11 @@ module FeatureFlags 'parameters' => 'parameters' }.freeze + def success(**args) + execute_hooks_after_commit(args[:feature_flag]) + super + end + def execute(feature_flag) return error('Access Denied', 403) unless can_update?(feature_flag) return error('Not Found', 404) unless valid_user_list_ids?(feature_flag, user_list_ids(params)) @@ -20,16 +25,11 @@ module FeatureFlags end end + # We generate the audit event before the feature flag is saved as #changed_strategies_messages depends on the strategies' states before save audit_event = audit_event(feature_flag) - if feature_flag.active_changed? - feature_flag.execute_hooks(current_user) - end - if feature_flag.save - save_audit_event(audit_event) - - success(feature_flag: feature_flag) + success(feature_flag: feature_flag, audit_event: audit_event) else error(feature_flag.errors.full_messages, :bad_request) end @@ -38,6 +38,16 @@ module FeatureFlags private + def execute_hooks_after_commit(feature_flag) + return unless feature_flag.active_previously_changed? + + # The `current_user` method (defined in `BaseService`) is not available within the `run_after_commit` block + user = current_user + feature_flag.run_after_commit do + HookService.new(feature_flag, user).execute + end + end + def audit_message(feature_flag) changes = changed_attributes_messages(feature_flag) changes += changed_strategies_messages(feature_flag) diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index b7eae06b963..774f81b0a5e 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -29,6 +29,7 @@ module Groups update_group_attributes ensure_ownership update_integrations + update_pending_builds! end post_update_hooks(@updated_project_ids) @@ -139,6 +140,10 @@ module Groups # these records again. @updated_project_ids = projects_to_update.pluck(:id) + Namespaces::ProjectNamespace + .where(id: projects_to_update.select(:project_namespace_id)) + .update_all(visibility_level: @new_parent_group.visibility_level) + projects_to_update .update_all(visibility_level: @new_parent_group.visibility_level) end @@ -217,6 +222,15 @@ module Groups PropagateIntegrationWorker.perform_async(integration.id) end end + + def update_pending_builds! + update_params = { + namespace_traversal_ids: group.traversal_ids, + namespace_id: group.id + } + + ::Ci::UpdatePendingBuildService.new(group, update_params).execute + end end end diff --git a/app/services/import/validate_remote_git_endpoint_service.rb b/app/services/import/validate_remote_git_endpoint_service.rb new file mode 100644 index 00000000000..afccb5373a9 --- /dev/null +++ b/app/services/import/validate_remote_git_endpoint_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Import + class ValidateRemoteGitEndpointService + # Validates if the remote endpoint is a valid GIT repository + # Only smart protocol is supported + # Validation rules are taken from https://git-scm.com/docs/http-protocol#_smart_clients + + GIT_SERVICE_NAME = "git-upload-pack" + GIT_EXPECTED_FIRST_PACKET_LINE = "# service=#{GIT_SERVICE_NAME}" + GIT_BODY_MESSAGE_REGEXP = /^[0-9a-f]{4}#{GIT_EXPECTED_FIRST_PACKET_LINE}/.freeze + # https://github.com/git/git/blob/master/Documentation/technical/protocol-common.txt#L56-L59 + GIT_PROTOCOL_PKT_LEN = 4 + GIT_MINIMUM_RESPONSE_LENGTH = GIT_PROTOCOL_PKT_LEN + GIT_EXPECTED_FIRST_PACKET_LINE.length + EXPECTED_CONTENT_TYPE = "application/x-#{GIT_SERVICE_NAME}-advertisement" + + def initialize(params) + @params = params + end + + def execute + uri = Gitlab::Utils.parse_url(@params[:url]) + + return ServiceResponse.error(message: "#{@params[:url]} is not a valid URL") unless uri + + uri.fragment = nil + url = Gitlab::Utils.append_path(uri.to_s, "/info/refs?service=#{GIT_SERVICE_NAME}") + + response_body = '' + result = nil + Gitlab::HTTP.try_get(url, stream_body: true, follow_redirects: false, basic_auth: auth) do |fragment| + response_body += fragment + next if response_body.length < GIT_MINIMUM_RESPONSE_LENGTH + + result = if status_code_is_valid(fragment) && content_type_is_valid(fragment) && response_body_is_valid(response_body) + :success + else + :error + end + + # We are interested only in the first chunks of the response + # So we're using stream_body: true and breaking when receive enough body + break + end + + if result == :success + ServiceResponse.success + else + ServiceResponse.error(message: "#{uri} is not a valid HTTP Git repository") + end + end + + private + + def auth + unless @params[:user].to_s.blank? + { + username: @params[:user], + password: @params[:password] + } + end + end + + def status_code_is_valid(fragment) + fragment.http_response.code == '200' + end + + def content_type_is_valid(fragment) + fragment.http_response['content-type'] == EXPECTED_CONTENT_TYPE + end + + def response_body_is_valid(response_body) + response_body.match?(GIT_BODY_MESSAGE_REGEXP) + end + end +end diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb index 4a6b7540ded..4a2078a4e60 100644 --- a/app/services/issuable/import_csv/base_service.rb +++ b/app/services/issuable/import_csv/base_service.rb @@ -71,7 +71,14 @@ module Issuable # NOTE: CSV imports are performed by workers, so we do not have a request context in order # to create a SpamParams object to pass to the issuable create service. spam_params = nil - create_issuable_class.new(project: @project, current_user: @user, params: attributes, spam_params: spam_params).execute + create_service = create_issuable_class.new(project: @project, current_user: @user, params: attributes, spam_params: spam_params) + + # For now, if create_issuable_class prepends RateLimitedService let's bypass rate limiting + if create_issuable_class < RateLimitedService + create_service.execute_without_rate_limiting + else + create_service.execute + end end def email_results_to_user diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb index cb42334fe32..c675f957cd7 100644 --- a/app/services/issues/clone_service.rb +++ b/app/services/issues/clone_service.rb @@ -8,13 +8,7 @@ module Issues @target_project = target_project @with_notes = with_notes - unless issue.can_clone?(current_user, target_project) - raise CloneError, s_('CloneIssue|Cannot clone issue due to insufficient permissions!') - end - - if target_project.pending_delete? - raise CloneError, s_('CloneIssue|Cannot clone issue to target project as it is pending deletion.') - end + verify_can_clone_issue!(issue, target_project) super(issue, target_project) @@ -30,6 +24,20 @@ module Issues attr_reader :target_project attr_reader :with_notes + def verify_can_clone_issue!(issue, target_project) + unless issue.supports_move_and_clone? + raise CloneError, s_('CloneIssue|Cannot clone issues of \'%{issue_type}\' type.') % { issue_type: issue.issue_type } + end + + unless issue.can_clone?(current_user, target_project) + raise CloneError, s_('CloneIssue|Cannot clone issue due to insufficient permissions!') + end + + if target_project.pending_delete? + raise CloneError, s_('CloneIssue|Cannot clone issue to target project as it is pending deletion.') + end + end + def update_new_entity # we don't call `super` because we want to be able to decide whether or not to copy all comments over. update_new_entity_description diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index ea64239dd99..ac846c769a3 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -3,8 +3,8 @@ module Issues class CloseService < Issues::BaseService # Closes the supplied issue if the current user is able to do so. - def execute(issue, commit: nil, notifications: true, system_note: true) - return issue unless can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue) + def execute(issue, commit: nil, notifications: true, system_note: true, skip_authorization: false) + return issue unless can_close?(issue, skip_authorization: skip_authorization) close_issue(issue, closed_via: commit, @@ -24,7 +24,7 @@ module Issues return issue end - if project.issues_enabled? && issue.close(current_user) + if perform_close(issue) event_service.close_issue(issue, current_user) create_note(issue, closed_via) if system_note @@ -51,6 +51,15 @@ module Issues private + # Overridden on EE + def perform_close(issue) + issue.close(current_user) + end + + def can_close?(issue, skip_authorization: false) + skip_authorization || can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue) + end + def perform_incident_management_actions(issue) resolve_alert(issue) end @@ -82,11 +91,11 @@ module Issues end end - def store_first_mentioned_in_commit_at(issue, merge_request) + def store_first_mentioned_in_commit_at(issue, merge_request, max_commit_lookup: 100) metrics = issue.metrics return if metrics.nil? || metrics.first_mentioned_in_commit_at - first_commit_timestamp = merge_request.commits(limit: 1).first.try(:authored_date) + first_commit_timestamp = merge_request.commits(limit: max_commit_lookup).last.try(:authored_date) return unless first_commit_timestamp metrics.update!(first_mentioned_in_commit_at: first_commit_timestamp) diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index b15b3e49c9a..fcedd1c1c8d 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -3,6 +3,10 @@ module Issues class CreateService < Issues::BaseService include ResolveDiscussions + prepend RateLimitedService + + rate_limit key: :issues_create, + opts: { scope: [:project, :current_user], users_allowlist: -> { [User.support_bot.username] } } # NOTE: For Issues::CreateService, we require the spam_params and do not default it to nil, because # spam_checking is likely to be necessary. However, if there is not a request available in scope diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index ff78221c941..4418b4eb2bf 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -7,13 +7,7 @@ module Issues def execute(issue, target_project) @target_project = target_project - unless issue.can_move?(current_user, @target_project) - raise MoveError, s_('MoveIssue|Cannot move issue due to insufficient permissions!') - end - - if @project == @target_project - raise MoveError, s_('MoveIssue|Cannot move issue to project it originates from!') - end + verify_can_move_issue!(issue, target_project) super @@ -32,6 +26,20 @@ module Issues attr_reader :target_project + def verify_can_move_issue!(issue, target_project) + unless issue.supports_move_and_clone? + raise MoveError, s_('MoveIssue|Cannot move issues of \'%{issue_type}\' type.') % { issue_type: issue.issue_type } + end + + unless issue.can_move?(current_user, @target_project) + raise MoveError, s_('MoveIssue|Cannot move issue due to insufficient permissions!') + end + + if @project == @target_project + raise MoveError, s_('MoveIssue|Cannot move issue to project it originates from!') + end + end + def update_service_desk_sent_notifications return unless original_entity.from_service_desk? diff --git a/app/services/issues/relative_position_rebalancing_service.rb b/app/services/issues/relative_position_rebalancing_service.rb index 7d199f99a24..23bb409f3cd 100644 --- a/app/services/issues/relative_position_rebalancing_service.rb +++ b/app/services/issues/relative_position_rebalancing_service.rb @@ -82,7 +82,7 @@ module Issues collection.each do |project| caching.cache_current_project_id(project.id) index += 1 - scope = Issue.in_projects(project).reorder(custom_reorder).select(:id, :relative_position) + scope = Issue.in_projects(project).order_by_relative_position.with_non_null_relative_position.select(:id, :relative_position) with_retry(PREFETCH_ISSUES_BATCH_SIZE, 100) do |batch_size| Gitlab::Pagination::Keyset::Iterator.new(scope: scope).each_batch(of: batch_size) do |batch| @@ -166,10 +166,6 @@ module Issues @start_position ||= (RelativePositioning::START_POSITION - (gaps / 2) * gap_size).to_i end - def custom_reorder - ::Gitlab::Pagination::Keyset::Order.build([Issue.column_order_relative_position, Issue.column_order_id_asc]) - end - def with_retry(initial_batch_size, exit_batch_size) retries = 0 batch_size = initial_batch_size diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index 977b924ed72..4abd1dfbf4e 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -2,10 +2,10 @@ module Issues class ReopenService < Issues::BaseService - def execute(issue) - return issue unless can?(current_user, :reopen_issue, issue) + def execute(issue, skip_authorization: false) + return issue unless can_reopen?(issue, skip_authorization: skip_authorization) - if issue.reopen + if perform_reopen(issue) event_service.reopen_issue(issue, current_user) create_note(issue, 'reopened') notification_service.async.reopen_issue(issue, current_user) @@ -22,6 +22,15 @@ module Issues private + # Overriden on EE + def perform_reopen(issue) + issue.reopen + end + + def can_reopen?(issue, skip_authorization: false) + skip_authorization || can?(current_user, :reopen_issue, issue) + end + def perform_incident_management_actions(issue) end diff --git a/app/services/merge_requests/mergeability/check_base_service.rb b/app/services/merge_requests/mergeability/check_base_service.rb new file mode 100644 index 00000000000..d5ddcb4b828 --- /dev/null +++ b/app/services/merge_requests/mergeability/check_base_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +module MergeRequests + module Mergeability + class CheckBaseService + attr_reader :merge_request, :params + + def initialize(merge_request:, params:) + @merge_request = merge_request + @params = params + end + + def skip? + raise NotImplementedError + end + + # When this method is true, we need to implement a cache_key + def cacheable? + raise NotImplementedError + end + + def cache_key + raise NotImplementedError + end + + private + + def success(*args) + Gitlab::MergeRequests::Mergeability::CheckResult.success(*args) + end + + def failure(*args) + Gitlab::MergeRequests::Mergeability::CheckResult.failed(*args) + end + end + end +end diff --git a/app/services/merge_requests/mergeability/check_ci_status_service.rb b/app/services/merge_requests/mergeability/check_ci_status_service.rb new file mode 100644 index 00000000000..c0ef5ba1c30 --- /dev/null +++ b/app/services/merge_requests/mergeability/check_ci_status_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module MergeRequests + module Mergeability + class CheckCiStatusService < CheckBaseService + def execute + if merge_request.mergeable_ci_state? + success + else + failure + end + end + + def skip? + params[:skip_ci_check].present? + end + + def cacheable? + false + end + end + end +end diff --git a/app/services/merge_requests/mergeability/run_checks_service.rb b/app/services/merge_requests/mergeability/run_checks_service.rb new file mode 100644 index 00000000000..c1d65fb65cc --- /dev/null +++ b/app/services/merge_requests/mergeability/run_checks_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +module MergeRequests + module Mergeability + class RunChecksService + include Gitlab::Utils::StrongMemoize + + # We want to have the cheapest checks first in the list, + # that way we can fail fast before running the more expensive ones + CHECKS = [ + CheckCiStatusService + ].freeze + + def initialize(merge_request:, params:) + @merge_request = merge_request + @params = params + end + + def execute + CHECKS.each_with_object([]) do |check_class, results| + check = check_class.new(merge_request: merge_request, params: params) + + next if check.skip? + + check_result = run_check(check) + results << check_result + + break results if check_result.failed? + end + end + + private + + attr_reader :merge_request, :params + + def run_check(check) + return check.execute unless Feature.enabled?(:mergeability_caching, merge_request.project, default_enabled: :yaml) + return check.execute unless check.cacheable? + + cached_result = results.read(merge_check: check) + return cached_result if cached_result.respond_to?(:status) + + check.execute.tap do |result| + results.write(merge_check: check, result_hash: result.to_hash) + end + end + + def results + strong_memoize(:results) do + Gitlab::MergeRequests::Mergeability::ResultsStore.new(merge_request: merge_request) + end + end + end + end +end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index af041de5596..c5395138902 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -248,7 +248,7 @@ module MergeRequests def merge_from_quick_action(merge_request) last_diff_sha = params.delete(:merge) - MergeRequests::MergeOrchestrationService + ::MergeRequests::MergeOrchestrationService .new(project, current_user, { sha: last_diff_sha }) .execute(merge_request) end diff --git a/app/services/metrics/dashboard/annotations/create_service.rb b/app/services/metrics/dashboard/annotations/create_service.rb index 54f4e96378c..b86fa82a5e8 100644 --- a/app/services/metrics/dashboard/annotations/create_service.rb +++ b/app/services/metrics/dashboard/annotations/create_service.rb @@ -30,7 +30,7 @@ module Metrics options[:environment] = environment success(options) else - error(s_('Metrics::Dashboard::Annotation|You are not authorized to create annotation for selected environment')) + error(s_('MetricsDashboardAnnotation|You are not authorized to create annotation for selected environment')) end end @@ -39,7 +39,7 @@ module Metrics options[:cluster] = cluster success(options) else - error(s_('Metrics::Dashboard::Annotation|You are not authorized to create annotation for selected cluster')) + error(s_('MetricsDashboardAnnotation|You are not authorized to create annotation for selected cluster')) end end @@ -51,7 +51,7 @@ module Metrics success(options) rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError - error(s_('Metrics::Dashboard::Annotation|Dashboard with requested path can not be found')) + error(s_('MetricsDashboardAnnotation|Dashboard with requested path can not be found')) end def create(options) diff --git a/app/services/metrics/dashboard/annotations/delete_service.rb b/app/services/metrics/dashboard/annotations/delete_service.rb index 3efe6924a9b..3cb22f8d3da 100644 --- a/app/services/metrics/dashboard/annotations/delete_service.rb +++ b/app/services/metrics/dashboard/annotations/delete_service.rb @@ -27,7 +27,7 @@ module Metrics if Ability.allowed?(user, :delete_metrics_dashboard_annotation, annotation) success else - error(s_('Metrics::Dashboard::Annotation|You are not authorized to delete this annotation')) + error(s_('MetricsDashboardAnnotation|You are not authorized to delete this annotation')) end end @@ -35,7 +35,7 @@ module Metrics if annotation.destroy success else - error(s_('Metrics::Dashboard::Annotation|Annotation has not been deleted')) + error(s_('MetricsDashboardAnnotation|Annotation has not been deleted')) end end end diff --git a/app/services/metrics/users_starred_dashboards/create_service.rb b/app/services/metrics/users_starred_dashboards/create_service.rb index 9642df87861..0d028f120d3 100644 --- a/app/services/metrics/users_starred_dashboards/create_service.rb +++ b/app/services/metrics/users_starred_dashboards/create_service.rb @@ -35,7 +35,7 @@ module Metrics if Ability.allowed?(user, :create_metrics_user_starred_dashboard, project) success(user: user, project: project) else - error(s_('Metrics::UsersStarredDashboards|You are not authorized to add star to this dashboard')) + error(s_('MetricsUsersStarredDashboards|You are not authorized to add star to this dashboard')) end end @@ -44,7 +44,7 @@ module Metrics options[:dashboard_path] = dashboard_path success(options) else - error(s_('Metrics::UsersStarredDashboards|Dashboard with requested path can not be found')) + error(s_('MetricsUsersStarredDashboards|Dashboard with requested path can not be found')) end end diff --git a/app/services/packages/composer/create_package_service.rb b/app/services/packages/composer/create_package_service.rb index 8215a3385a4..0f5429f667e 100644 --- a/app/services/packages/composer/create_package_service.rb +++ b/app/services/packages/composer/create_package_service.rb @@ -17,10 +17,6 @@ module Packages }) end - unless Feature.enabled?(:remove_composer_v1_cache_code, project) - ::Packages::Composer::CacheUpdateWorker.perform_async(created_package.project_id, created_package.name, nil) - end - created_package end diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb index 953b386b754..a3d54bc6b58 100644 --- a/app/services/projects/after_rename_service.rb +++ b/app/services/projects/after_rename_service.rb @@ -12,6 +12,8 @@ module Projects # # Projects::AfterRenameService.new(project).execute class AfterRenameService + include BaseServiceUtility + # @return [String] The Project being renamed. attr_reader :project @@ -78,7 +80,7 @@ module Projects def execute_system_hooks project.old_path_with_namespace = full_path_before - SystemHooksService.new.execute_hooks_for(project, :rename) + system_hook_service.execute_hooks_for(project, :rename) end def update_repository_configuration @@ -110,7 +112,7 @@ module Projects end def log_completion - Gitlab::AppLogger.info( + log_info( "Project #{project.id} has been renamed from " \ "#{full_path_before} to #{full_path_after}" ) @@ -140,7 +142,7 @@ module Projects def rename_failed! error = "Repository #{full_path_before} could not be renamed to #{full_path_after}" - Gitlab::AppLogger.error(error) + log_error(error) raise RenameFailedError, error end diff --git a/app/services/projects/container_repository/cache_tags_created_at_service.rb b/app/services/projects/container_repository/cache_tags_created_at_service.rb new file mode 100644 index 00000000000..3a5346d7a23 --- /dev/null +++ b/app/services/projects/container_repository/cache_tags_created_at_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + class CacheTagsCreatedAtService + def initialize(container_repository) + @container_repository = container_repository + @cached_tag_names = Set.new + end + + def populate(tags) + return if tags.empty? + + # This will load all tags in one Redis roundtrip + # the maximum number of tags is configurable and is set to 200 by default. + # https://gitlab.com/gitlab-org/gitlab/blob/master/doc/user/packages/container_registry/index.md#set-cleanup-limits-to-conserve-resources + keys = tags.map(&method(:cache_key)) + cached_tags_count = 0 + + ::Gitlab::Redis::Cache.with do |redis| + tags.zip(redis.mget(keys)).each do |tag, created_at| + next unless created_at + + tag.created_at = DateTime.rfc3339(created_at) + @cached_tag_names << tag.name + cached_tags_count += 1 + end + end + + cached_tags_count + end + + def insert(tags, max_ttl_in_seconds) + return unless max_ttl_in_seconds + return if tags.empty? + + # tags with nil created_at are not cacheable + # tags already cached don't need to be cached again + cacheable_tags = tags.select do |tag| + tag.created_at.present? && !tag.name.in?(@cached_tag_names) + end + + return if cacheable_tags.empty? + + now = Time.zone.now + + ::Gitlab::Redis::Cache.with do |redis| + # we use a pipeline instead of a MSET because each tag has + # a specific ttl + redis.pipelined do + cacheable_tags.each do |tag| + created_at = tag.created_at + # ttl is the max_ttl_in_seconds reduced by the number + # of seconds that the tag has already existed + ttl = max_ttl_in_seconds - (now - created_at).seconds + ttl = ttl.to_i + redis.set(cache_key(tag), created_at.rfc3339, ex: ttl) if ttl > 0 + end + end + end + end + + private + + def cache_key(tag) + "container_repository:{#{@container_repository.id}}:tag:#{tag.name}:created_at" + end + end + end +end diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index 793d2fec033..3a60de0f1ee 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -2,116 +2,152 @@ module Projects module ContainerRepository - class CleanupTagsService < BaseService - def execute(container_repository) + class CleanupTagsService + include BaseServiceUtility + include ::Gitlab::Utils::StrongMemoize + + def initialize(container_repository, user = nil, params = {}) + @container_repository = container_repository + @current_user = user + @params = params.dup + + @project = container_repository.project + @tags = container_repository.tags + tags_size = @tags.size + @counts = { + original_size: tags_size, + cached_tags_count: 0 + } + end + + def execute return error('access denied') unless can_destroy? return error('invalid regex') unless valid_regex? - tags = container_repository.tags - original_size = tags.size + filter_out_latest + filter_by_name - tags = without_latest(tags) - tags = filter_by_name(tags) + truncate + populate_from_cache - before_truncate_size = tags.size - tags = truncate(tags) - after_truncate_size = tags.size + filter_keep_n + filter_by_older_than - tags = filter_keep_n(tags) - tags = filter_by_older_than(tags) - - delete_tags(container_repository, tags).tap do |result| - result[:original_size] = original_size - result[:before_truncate_size] = before_truncate_size - result[:after_truncate_size] = after_truncate_size - result[:before_delete_size] = tags.size + delete_tags.merge(@counts).tap do |result| + result[:before_delete_size] = @tags.size result[:deleted_size] = result[:deleted]&.size - result[:status] = :error if before_truncate_size != after_truncate_size + result[:status] = :error if @counts[:before_truncate_size] != @counts[:after_truncate_size] end end private - def delete_tags(container_repository, tags) - return success(deleted: []) unless tags.any? - - tag_names = tags.map(&:name) + def delete_tags + return success(deleted: []) unless @tags.any? service = Projects::ContainerRepository::DeleteTagsService.new( - container_repository.project, - current_user, - tags: tag_names, - container_expiration_policy: params['container_expiration_policy'] + @project, + @current_user, + tags: @tags.map(&:name), + container_expiration_policy: container_expiration_policy ) - service.execute(container_repository) + service.execute(@container_repository) end - def without_latest(tags) - tags.reject(&:latest?) + def filter_out_latest + @tags.reject!(&:latest?) end - def order_by_date(tags) + def order_by_date now = DateTime.current - tags.sort_by { |tag| tag.created_at || now }.reverse + @tags.sort_by! { |tag| tag.created_at || now } + .reverse! end - def filter_by_name(tags) - regex_delete = ::Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_delete'] || params['name_regex']}\\z") - regex_retain = ::Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_keep']}\\z") + def filter_by_name + regex_delete = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_delete || name_regex}\\z") + regex_retain = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_keep}\\z") - tags.select do |tag| + @tags.select! do |tag| # regex_retain will override any overlapping matches by regex_delete regex_delete.match?(tag.name) && !regex_retain.match?(tag.name) end end - def filter_keep_n(tags) - return tags unless params['keep_n'] + def filter_keep_n + return unless keep_n - tags = order_by_date(tags) - tags.drop(keep_n) + order_by_date + cache_tags(@tags.first(keep_n_as_integer)) + @tags = @tags.drop(keep_n_as_integer) end - def filter_by_older_than(tags) - return tags unless params['older_than'] + def filter_by_older_than + return unless older_than - older_than = ChronicDuration.parse(params['older_than']).seconds.ago + older_than_timestamp = older_than_in_seconds.ago - tags.select do |tag| - tag.created_at && tag.created_at < older_than + @tags, tags_to_keep = @tags.partition do |tag| + tag.created_at && tag.created_at < older_than_timestamp end + + cache_tags(tags_to_keep) end def can_destroy? - return true if params['container_expiration_policy'] + return true if container_expiration_policy - can?(current_user, :destroy_container_image, project) + can?(@current_user, :destroy_container_image, @project) end def valid_regex? %w(name_regex_delete name_regex name_regex_keep).each do |param_name| - regex = params[param_name] + regex = @params[param_name] ::Gitlab::UntrustedRegexp.new(regex) unless regex.blank? end true rescue RegexpError => e - ::Gitlab::ErrorTracking.log_exception(e, project_id: project.id) + ::Gitlab::ErrorTracking.log_exception(e, project_id: @project.id) false end - def truncate(tags) - return tags unless throttling_enabled? - return tags if max_list_size == 0 + def truncate + @counts[:before_truncate_size] = @tags.size + @counts[:after_truncate_size] = @tags.size + + return unless throttling_enabled? + return if max_list_size == 0 # truncate the list to make sure that after the #filter_keep_n # execution, the resulting list will be max_list_size - truncated_size = max_list_size + keep_n + truncated_size = max_list_size + keep_n_as_integer - return tags if tags.size <= truncated_size + return if @tags.size <= truncated_size + + @tags = @tags.sample(truncated_size) + @counts[:after_truncate_size] = @tags.size + end + + def populate_from_cache + @counts[:cached_tags_count] = cache.populate(@tags) if caching_enabled? + end + + def cache_tags(tags) + cache.insert(tags, older_than_in_seconds) if caching_enabled? + end + + def cache + strong_memoize(:cache) do + ::Projects::ContainerRepository::CacheTagsCreatedAtService.new(@container_repository) + end + end - tags.sample(truncated_size) + def caching_enabled? + container_expiration_policy && + older_than.present? && + Feature.enabled?(:container_registry_expiration_policies_caching, @project) end def throttling_enabled? @@ -123,7 +159,37 @@ module Projects end def keep_n - params['keep_n'].to_i + @params['keep_n'] + end + + def keep_n_as_integer + keep_n.to_i + end + + def older_than_in_seconds + strong_memoize(:older_than_in_seconds) do + ChronicDuration.parse(older_than).seconds + end + end + + def older_than + @params['older_than'] + end + + def name_regex_delete + @params['name_regex_delete'] + end + + def name_regex + @params['name_regex'] + end + + def name_regex_keep + @params['name_regex_keep'] + end + + def container_expiration_policy + @params['container_expiration_policy'] end end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index e717491b19d..1536f0a22b8 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -8,6 +8,7 @@ module Projects @current_user = user @params = params.dup @skip_wiki = @params.delete(:skip_wiki) + @initialize_with_sast = Gitlab::Utils.to_boolean(@params.delete(:initialize_with_sast)) @initialize_with_readme = Gitlab::Utils.to_boolean(@params.delete(:initialize_with_readme)) @import_data = @params.delete(:import_data) @relations_block = @params.delete(:relations_block) @@ -118,6 +119,7 @@ module Projects Projects::PostCreationWorker.perform_async(@project.id) create_readme if @initialize_with_readme + create_sast_commit if @initialize_with_sast end # Add an authorization for the current user authorizations inline @@ -160,6 +162,10 @@ module Projects Files::CreateService.new(@project, current_user, commit_attrs).execute end + def create_sast_commit + ::Security::CiConfiguration::SastCreateService.new(@project, current_user, {}, commit_on_default: true).execute + end + def readme_content @readme_template.presence || experiment(:new_project_readme_content, namespace: @project.namespace).run_with(@project) end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index afa8de04fca..27f813f4661 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -5,6 +5,7 @@ module Projects include Gitlab::ShellAdapter DestroyError = Class.new(StandardError) + BATCH_SIZE = 100 def async_execute project.update_attribute(:pending_delete, true) @@ -119,6 +120,12 @@ module Projects destroy_web_hooks! destroy_project_bots! + if ::Feature.enabled?(:ci_optimize_project_records_destruction, project, default_enabled: :yaml) && + Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml) + + destroy_ci_records! + end + # Rails attempts to load all related records into memory before # destroying: https://github.com/rails/rails/issues/22510 # This ensures we delete records in batches. @@ -133,6 +140,23 @@ module Projects log_info("Attempting to destroy #{project.full_path} (#{project.id})") end + def destroy_ci_records! + project.all_pipelines.find_each(batch_size: BATCH_SIZE) do |pipeline| # rubocop: disable CodeReuse/ActiveRecord + # Destroy artifacts, then builds, then pipelines + # All builds have already been dropped by Ci::AbortPipelinesService, + # so no Ci::Build-instantiating cancellations happen here. + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71342#note_691523196 + + ::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline) + end + + deleted_count = project.commit_statuses.delete_all + + if deleted_count > 0 + Gitlab::AppLogger.info "Projects::DestroyService - Project #{project.id} - #{deleted_count} leftover commit statuses" + end + end + # The project can have multiple webhooks with hundreds of thousands of web_hook_logs. # By default, they are removed with "DELETE CASCADE" option defined via foreign_key. # But such queries can exceed the statement_timeout limit and fail to delete the project. diff --git a/app/services/projects/group_links/update_service.rb b/app/services/projects/group_links/update_service.rb index 475ab17f1a1..a836b96cac3 100644 --- a/app/services/projects/group_links/update_service.rb +++ b/app/services/projects/group_links/update_service.rb @@ -20,19 +20,15 @@ module Projects attr_reader :group_link def refresh_authorizations - if Feature.enabled?(:specialized_worker_for_project_share_update_auth_recalculation) - AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id) - - # Until we compare the inconsistency rates of the new specialized worker and - # the old approach, we still run AuthorizedProjectsWorker - # but with some delay and lower urgency as a safety net. - group_link.group.refresh_members_authorized_projects( - blocking: false, - priority: UserProjectAccessChangedService::LOW_PRIORITY - ) - else - group_link.group.refresh_members_authorized_projects - end + AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id) + + # Until we compare the inconsistency rates of the new specialized worker and + # the old approach, we still run AuthorizedProjectsWorker + # but with some delay and lower urgency as a safety net. + group_link.group.refresh_members_authorized_projects( + blocking: false, + priority: UserProjectAccessChangedService::LOW_PRIORITY + ) end def requires_authorization_refresh?(params) diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index b5288aad6f0..4979af6dfe1 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -16,6 +16,8 @@ module Projects end def execute + track_start_import + add_repository_to_project download_lfs_objects @@ -25,16 +27,17 @@ module Projects after_execute_hook success - rescue Gitlab::UrlBlocker::BlockedUrlError => e - Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type) + rescue Gitlab::UrlBlocker::BlockedUrlError, StandardError => e + Gitlab::Import::ImportFailureService.track( + project_id: project.id, + error_source: self.class.name, + exception: e, + metrics: true + ) - error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: e.message }) - rescue StandardError => e message = Projects::ImportErrorFilter.filter_message(e.message) - - Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type) - - error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message }) + error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % + { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message }) end protected @@ -54,6 +57,10 @@ module Projects # Defined in EE::Projects::ImportService end + def track_start_import + has_importer? && importer_class.try(:track_start_import, project) + end + def add_repository_to_project if project.external_import? && !unknown_url? begin diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb index f35370c427f..2612001eb95 100644 --- a/app/services/projects/overwrite_project_service.rb +++ b/app/services/projects/overwrite_project_service.rb @@ -3,7 +3,7 @@ module Projects class OverwriteProjectService < BaseService def execute(source_project) - return unless source_project && source_project.namespace == @project.namespace + return unless source_project && source_project.namespace_id == @project.namespace_id start_time = ::Gitlab::Metrics::System.monotonic_time @@ -40,7 +40,7 @@ module Projects duration = ::Gitlab::Metrics::System.monotonic_time - start_time Gitlab::AppJsonLogger.info(class: self.class.name, - namespace_id: source_project.namespace.id, + namespace_id: source_project.namespace_id, project_id: source_project.id, duration_s: duration.to_f, error: exception.class.name) diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 228115d72b8..1616a8a4062 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -36,14 +36,17 @@ module Projects private def project_members_through_invited_groups - groups_with_ancestors_ids = Gitlab::ObjectHierarchy - .new(visible_groups) - .base_and_ancestors - .pluck_primary_key + groups_with_ancestors = if ::Feature.enabled?(:linear_participants_service_ancestor_scopes, current_user, default_enabled: :yaml) + visible_groups.self_and_ancestors + else + Gitlab::ObjectHierarchy + .new(visible_groups) + .base_and_ancestors + end GroupMember .active_without_invites_and_requests - .with_source_id(groups_with_ancestors_ids) + .with_source_id(groups_with_ancestors.pluck_primary_key) end def visible_groups diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 27376173f07..a69e6488ebc 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -81,7 +81,7 @@ module Projects # Apply changes to the project update_namespace_and_visibility(@new_namespace) - update_shared_runners_settings + project.reconcile_shared_runners_setting! project.save! # Notifications @@ -104,6 +104,8 @@ module Projects update_repository_configuration(@new_path) execute_system_hooks + + update_pending_builds! end post_update_hooks(project) @@ -154,19 +156,15 @@ module Projects user_ids = @old_namespace.user_ids_for_project_authorizations | @new_namespace.user_ids_for_project_authorizations - if Feature.enabled?(:specialized_worker_for_project_transfer_auth_recalculation) - AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id) - - # Until we compare the inconsistency rates of the new specialized worker and - # the old approach, we still run AuthorizedProjectsWorker - # but with some delay and lower urgency as a safety net. - UserProjectAccessChangedService.new(user_ids).execute( - blocking: false, - priority: UserProjectAccessChangedService::LOW_PRIORITY - ) - else - UserProjectAccessChangedService.new(user_ids).execute - end + AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id) + + # Until we compare the inconsistency rates of the new specialized worker and + # the old approach, we still run AuthorizedProjectsWorker + # but with some delay and lower urgency as a safety net. + UserProjectAccessChangedService.new(user_ids).execute( + blocking: false, + priority: UserProjectAccessChangedService::LOW_PRIORITY + ) end def rollback_side_effects @@ -189,7 +187,7 @@ module Projects end def execute_system_hooks - SystemHooksService.new.execute_hooks_for(project, :transfer) + system_hook_service.execute_hooks_for(project, :transfer) end def move_project_folders(project) @@ -241,18 +239,19 @@ module Projects "#{new_path}#{::Gitlab::GlRepository::DESIGN.path_suffix}" end - def update_shared_runners_settings - # If a project is being transferred to another group it means it can already - # have shared runners enabled but we need to check whether the new group allows that. - if project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable' - project.shared_runners_enabled = false - end - end - def update_integrations project.integrations.with_default_settings.delete_all Integration.create_from_active_default_integrations(project, :project_id) end + + def update_pending_builds! + update_params = { + namespace_id: new_namespace.id, + namespace_traversal_ids: new_namespace.traversal_ids + } + + ::Ci::UpdatePendingBuildService.new(project, update_params).execute + end end end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index dc75fe1014a..0000e713cb4 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -136,13 +136,11 @@ module Projects def validate_outdated_sha! return if latest? - if Feature.enabled?(:pages_smart_check_outdated_sha, project, default_enabled: :yaml) - # use pipeline_id in case the build is retried - last_deployed_pipeline_id = project.pages_metadatum&.pages_deployment&.ci_build&.pipeline_id + # use pipeline_id in case the build is retried + last_deployed_pipeline_id = project.pages_metadatum&.pages_deployment&.ci_build&.pipeline_id - return unless last_deployed_pipeline_id - return if last_deployed_pipeline_id <= build.pipeline_id - end + return unless last_deployed_pipeline_id + return if last_deployed_pipeline_id <= build.pipeline_id raise InvalidStateError, 'build SHA is outdated for this ref' end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index b87564fcaef..a32e80af4b1 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -105,7 +105,7 @@ module Projects end update_pages_config if changing_pages_related_config? - update_pending_builds if shared_runners_toggled? + update_pending_builds if runners_settings_toggled? end def after_rename_service(project) @@ -181,13 +181,36 @@ module Projects end def update_pending_builds - update_params = { instance_runners_enabled: project.shared_runners_enabled } + update_params = { + instance_runners_enabled: project.shared_runners_enabled?, + namespace_traversal_ids: group_runner_traversal_ids + } - ::Ci::UpdatePendingBuildService.new(project, update_params).execute + ::Ci::UpdatePendingBuildService + .new(project, update_params) + .execute end - def shared_runners_toggled? - project.previous_changes.include?('shared_runners_enabled') + def shared_runners_settings_toggled? + project.previous_changes.include?(:shared_runners_enabled) + end + + def group_runners_settings_toggled? + return false unless project.ci_cd_settings.present? + + project.ci_cd_settings.previous_changes.include?(:group_runners_enabled) + end + + def runners_settings_toggled? + shared_runners_settings_toggled? || group_runners_settings_toggled? + end + + def group_runner_traversal_ids + if project.group_runners_enabled? + project.namespace.traversal_ids + else + [] + end end end end diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index 33faf2d6698..cee59360b4b 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -24,7 +24,7 @@ module Search # rubocop: disable CodeReuse/ActiveRecord def projects - @projects ||= ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute.preload(:topics, :taggings) + @projects ||= ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute.preload(:topics, :project_topics) end def allowed_scopes diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb index adb45244adb..ea77cd98ba3 100644 --- a/app/services/security/ci_configuration/base_create_service.rb +++ b/app/services/security/ci_configuration/base_create_service.rb @@ -25,7 +25,7 @@ module Security rescue Gitlab::Git::PreReceiveError => e ServiceResponse.error(message: e.message) rescue StandardError - project.repository.rm_branch(current_user, branch_name) if project.repository.branch_exists?(branch_name) + remove_branch_on_exception raise end @@ -50,6 +50,10 @@ module Security Gitlab::Routing.url_helpers.project_new_merge_request_url(project, merge_request: merge_request_params) end + def remove_branch_on_exception + project.repository.rm_branch(current_user, branch_name) if project.repository.branch_exists?(branch_name) + end + def track_event(attributes_for_commit) action = attributes_for_commit[:actions].first diff --git a/app/services/security/ci_configuration/sast_create_service.rb b/app/services/security/ci_configuration/sast_create_service.rb index f495cac18f8..47e01847b17 100644 --- a/app/services/security/ci_configuration/sast_create_service.rb +++ b/app/services/security/ci_configuration/sast_create_service.rb @@ -5,15 +5,28 @@ module Security class SastCreateService < ::Security::CiConfiguration::BaseCreateService attr_reader :params - def initialize(project, current_user, params) + def initialize(project, current_user, params, commit_on_default: false) super(project, current_user) @params = params + + @commit_on_default = commit_on_default + @branch_name = project.default_branch if @commit_on_default end private + def remove_branch_on_exception + super unless @commit_on_default + end + def action - Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_gitlab_ci_content).generate + existing_content = begin + existing_gitlab_ci_content # this can fail on the very first commit + rescue StandardError + nil + end + + Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_content).generate end def next_branch diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb index 3417ce4f583..63e01603d47 100644 --- a/app/services/service_ping/submit_service.rb +++ b/app/services/service_ping/submit_service.rb @@ -78,7 +78,7 @@ module ServicePing def store_metrics(response) metrics = response['conv_index'] || response['dev_ops_score'] # leaving dev_ops_score here, as the response data comes from the gitlab-version-com - return unless metrics.present? + return unless metrics.except('usage_data_id').present? DevOpsReport::Metric.create!( metrics.slice(*METRICS) diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb index e9a13cee764..f13477b8b34 100644 --- a/app/services/terraform/remote_state_handler.rb +++ b/app/services/terraform/remote_state_handler.rb @@ -2,8 +2,6 @@ module Terraform class RemoteStateHandler < BaseService - include Gitlab::OptimisticLocking - StateLockedError = Class.new(StandardError) UnauthorizedError = Class.new(StandardError) @@ -60,7 +58,7 @@ module Terraform private def retrieve_with_lock(find_only: false) - create_or_find!(find_only: find_only).tap { |state| retry_optimistic_lock(state, name: 'terraform_remote_state_handler_retrieve') { |state| yield state } } + create_or_find!(find_only: find_only).tap { |state| state.with_lock { yield state } } end def create_or_find!(find_only:) diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb index 5f48f410bf7..5bba986f4ad 100644 --- a/app/services/user_project_access_changed_service.rb +++ b/app/services/user_project_access_changed_service.rb @@ -30,7 +30,7 @@ class UserProjectAccessChangedService end end - ::Gitlab::Database::LoadBalancing::Sticking.bulk_stick(:user, @user_ids) + ::User.sticking.bulk_stick(:user, @user_ids) result end diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index 23c67231a29..c3df9b153a1 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -5,15 +5,18 @@ module Users include NewUserNotifier attr_reader :user, :identity_params + ATTRS_REQUIRING_PASSWORD_CHECK = %w[email].freeze + def initialize(current_user, params = {}) @current_user = current_user + @validation_password = params.delete(:validation_password) @user = params.delete(:user) @status_params = params.delete(:status) @identity_params = params.slice(*identity_attributes) @params = params.dup end - def execute(validate: true, &block) + def execute(validate: true, check_password: false, &block) yield(@user) if block_given? user_exists = @user.persisted? @@ -21,6 +24,11 @@ module Users discard_read_only_attributes assign_attributes + + if check_password && require_password_check? && !@user.valid_password?(@validation_password) + return error(s_("Profiles|Invalid password")) + end + assign_identity build_canonical_email @@ -32,8 +40,8 @@ module Users end end - def execute!(*args, &block) - result = execute(*args, &block) + def execute!(*args, **kargs, &block) + result = execute(*args, **kargs, &block) raise ActiveRecord::RecordInvalid, @user unless result[:status] == :success @@ -42,6 +50,14 @@ module Users private + def require_password_check? + return false unless @user.persisted? + return false if @user.password_automatically_set? + + changes = @user.changed + ATTRS_REQUIRING_PASSWORD_CHECK.any? { |param| changes.include?(param) } + end + def build_canonical_email return unless @user.email_changed? diff --git a/app/services/users/upsert_credit_card_validation_service.rb b/app/services/users/upsert_credit_card_validation_service.rb index 70a96b3ec6b..86b5b923418 100644 --- a/app/services/users/upsert_credit_card_validation_service.rb +++ b/app/services/users/upsert_credit_card_validation_service.rb @@ -7,6 +7,14 @@ module Users end def execute + @params = { + user_id: params.fetch(:user_id), + credit_card_validated_at: params.fetch(:credit_card_validated_at), + expiration_date: get_expiration_date(params), + last_digits: Integer(params.fetch(:credit_card_mask_number), 10), + holder_name: params.fetch(:credit_card_holder_name) + } + ::Users::CreditCardValidation.upsert(@params) ServiceResponse.success(message: 'CreditCardValidation was set') @@ -16,5 +24,14 @@ module Users Gitlab::ErrorTracking.track_exception(e, params: @params, class: self.class.to_s) ServiceResponse.error(message: "Could not set CreditCardValidation: #{e.message}") end + + private + + def get_expiration_date(params) + year = params.fetch(:credit_card_expiration_year) + month = params.fetch(:credit_card_expiration_month) + + Date.new(year, month, -1) # last day of the month + end end end diff --git a/app/uploaders/dependency_proxy/file_uploader.rb b/app/uploaders/dependency_proxy/file_uploader.rb index 5154f180454..f0222d4cf06 100644 --- a/app/uploaders/dependency_proxy/file_uploader.rb +++ b/app/uploaders/dependency_proxy/file_uploader.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class DependencyProxy::FileUploader < GitlabUploader + extend Workhorse::UploadPath include ObjectStorage::Concern before :cache, :set_content_type diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml index fab3ce584f0..96fb848b568 100644 --- a/app/views/admin/application_settings/_abuse.html.haml +++ b/app/views/admin/application_settings/_abuse.html.haml @@ -5,7 +5,5 @@ .form-group = f.label :abuse_notification_email, _('Abuse reports notification email'), class: 'label-bold' = f.text_field :abuse_notification_email, class: 'form-control gl-form-input' - .form-text.text-muted - = _('Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.') = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index eb30efabb98..19c38d7be62 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -23,11 +23,11 @@ .form-group = f.label :max_import_size, _('Maximum import size (MB)'), class: 'label-light' = f.number_field :max_import_size, class: 'form-control gl-form-input qa-receive-max-import-size-field', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' } - %span.form-text.text-muted= _('0 for unlimited, only effective with remote storage enabled.') + %span.form-text.text-muted= _('Only effective when remote storage is enabled. Set to 0 for no size limit.') .form-group = f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light' = f.number_field :session_expire_delay, class: 'form-control gl-form-input', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' } - %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes.') + %span.form-text.text-muted#session_expire_delay_help_block= _('Restart GitLab to apply changes.') = render_if_exists 'admin/application_settings/git_two_factor_session_expiry', form: f = render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f @@ -45,13 +45,13 @@ .form-check = f.check_box :user_default_external, class: 'form-check-input' = f.label :user_default_external, class: 'form-check-label' do - = _('Newly registered users will by default be external') + = _('Newly-registered users are external by default') .gl-mt-3 = _('Internal users') = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-form-input gl-mt-2' .help-block - = _('Specify an e-mail address regex pattern to identify default internal users.') - = link_to _('More information'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), + = _('Specify an email address regex pattern to identify default internal users.') + = link_to _('Learn more'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), target: '_blank' - unless Gitlab.com? .form-group @@ -59,11 +59,13 @@ .form-check = f.check_box :deactivate_dormant_users, class: 'form-check-input' = f.label :deactivate_dormant_users, class: 'form-check-label' do - = _('Deactivate dormant users after 90 days of inactivity. Users can return to active status by signing in to their account. While inactive, a user is not counted as an active user in the instance.') - = link_to _('More information'), help_page_path('user/admin_area/moderate_users', anchor: 'automatically-deactivate-dormant-users'), target: '_blank' + = _('Deactivate dormant users after 90 days of inactivity') + .help-block + = _('Users can reactivate their account by signing in.') + = link_to _('Learn more'), help_page_path('user/admin_area/moderate_users', anchor: 'automatically-deactivate-dormant-users'), target: '_blank' .form-group = f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light' - = f.text_field :personal_access_token_prefix, placeholder: _('Max 20 characters'), class: 'form-control gl-form-input' + = f.text_field :personal_access_token_prefix, placeholder: _('Maximum 20 characters'), class: 'form-control gl-form-input' .form-group = f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold' .form-check diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index fea116bd419..8026ec4702b 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -69,5 +69,12 @@ %p.form-text.text-muted = _("The default CI/CD configuration file and path for new projects.").html_safe = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'specify-a-custom-cicd-configuration-file'), target: '_blank' + .form-group + .form-check + = f.check_box :suggest_pipeline_enabled, class: 'form-check-input' + = f.label :suggest_pipeline_enabled, class: 'form-check-label' do + = s_('AdminSettings|Enable pipeline suggestion banner') + .form-text.text-muted + = s_('AdminSettings|Display a banner on merge requests in projects with no pipelines to initiate steps to add a .gitlab-ci.yml file.') = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_files_limits.html.haml b/app/views/admin/application_settings/_files_limits.html.haml deleted file mode 100644 index 9cd12fa1caa..00000000000 --- a/app/views/admin/application_settings/_files_limits.html.haml +++ /dev/null @@ -1,34 +0,0 @@ -= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-files-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) - - %fieldset - %legend.h5.gl-border-none - = _('Unauthenticated API request rate limit') - .form-group - = f.gitlab_ui_checkbox_component :throttle_unauthenticated_files_api_enabled, - _('Enable unauthenticated API request rate limit'), - help_text: _('Helps reduce request volume (e.g. from crawlers or abusive bots)'), - checkbox_options: { data: { qa_selector: 'throttle_unauthenticated_files_api_checkbox' } } - .form-group - = f.label :throttle_unauthenticated_files_api_requests_per_period, 'Max unauthenticated API requests per period per IP', class: 'label-bold' - = f.number_field :throttle_unauthenticated_files_api_requests_per_period, class: 'form-control gl-form-input' - .form-group - = f.label :throttle_unauthenticated_files_api_period_in_seconds, 'Unauthenticated API rate limit period in seconds', class: 'label-bold' - = f.number_field :throttle_unauthenticated_files_api_period_in_seconds, class: 'form-control gl-form-input' - - %fieldset - %legend.h5.gl-border-none - = _('Authenticated API request rate limit') - .form-group - = f.gitlab_ui_checkbox_component :throttle_authenticated_files_api_enabled, - _('Enable authenticated API request rate limit'), - help_text: _('Helps reduce request volume (e.g. from crawlers or abusive bots)'), - checkbox_options: { data: { qa_selector: 'throttle_authenticated_files_api_checkbox' } } - .form-group - = f.label :throttle_authenticated_files_api_requests_per_period, 'Max authenticated API requests per period per user', class: 'label-bold' - = f.number_field :throttle_authenticated_files_api_requests_per_period, class: 'form-control gl-form-input' - .form-group - = f.label :throttle_authenticated_files_api_period_in_seconds, 'Authenticated API rate limit period in seconds', class: 'label-bold' - = f.number_field :throttle_authenticated_files_api_period_in_seconds, class: 'form-control gl-form-input' - - = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml index ecf3203df9a..cd7eaa1896a 100644 --- a/app/views/admin/application_settings/_help_page.html.haml +++ b/app/views/admin/application_settings/_help_page.html.haml @@ -18,11 +18,10 @@ = f.text_field :help_page_support_url, class: 'form-control gl-form-input', placeholder: 'https://company.example.com/getting-help', :'aria-describedby' => 'support_help_block' %span.form-text.text-muted#support_help_block= _('Alternate support URL for Help page and Help dropdown.') - - if show_documentation_base_url_field? - .form-group - = f.label :help_page_documentation_base_url, _('Documentation pages URL'), class: 'label-bold' - = f.text_field :help_page_documentation_base_url, class: 'form-control gl-form-input', placeholder: 'https://docs.gitlab.com' - - docs_link_url = help_page_path('user/admin_area/settings/help_page', anchor: 'destination-requirements') - - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url } - %span.form-text.text-muted#support_help_block= html_escape(_('Requests for pages at %{code_start}%{help_text_url}%{code_end} redirect to the URL. The destination must meet certain requirements. %{docs_link_start}Learn more.%{docs_link_end}')) % { code_start: '<code>'.html_safe, help_text_url: help_url, code_end: '</code>'.html_safe, docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } + .form-group + = f.label :help_page_documentation_base_url, _('Documentation pages URL'), class: 'gl-font-weight-bold' + = f.text_field :help_page_documentation_base_url, class: 'form-control gl-form-input', placeholder: 'https://docs.gitlab.com' + - docs_link_url = help_page_path('user/admin_area/settings/help_page', anchor: 'destination-requirements') + - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url } + %span.form-text.text-muted#support_help_block= html_escape(_('Requests for pages at %{code_start}%{help_text_url}%{code_end} redirect to the URL. The destination must meet certain requirements. %{docs_link_start}Learn more.%{docs_link_end}')) % { code_start: '<code>'.html_safe, help_text_url: help_url, code_end: '</code>'.html_safe, docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml index 40b4d5cac6d..ad9e84ffdab 100644 --- a/app/views/admin/application_settings/_mailgun.html.haml +++ b/app/views/admin/application_settings/_mailgun.html.haml @@ -6,7 +6,7 @@ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _('Configure the %{link} integration.').html_safe % { link: link_to(_('Mailgun events'), 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', target: '_blank') } + = _('Configure the %{link} integration.').html_safe % { link: link_to(_('Mailgun events'), 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', target: '_blank', rel: 'noopener noreferrer') } .settings-content = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-mailgun-settings'), html: { class: 'fieldset-form', id: 'mailgun-settings' } do |f| = form_errors(@application_setting) if expanded diff --git a/app/views/admin/application_settings/_network_rate_limits.html.haml b/app/views/admin/application_settings/_network_rate_limits.html.haml new file mode 100644 index 00000000000..f1857a9749a --- /dev/null +++ b/app/views/admin/application_settings/_network_rate_limits.html.haml @@ -0,0 +1,33 @@ += gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: anchor), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + = _("Rate limits can help reduce request volume (like from crawlers or abusive bots).") + + %fieldset + .form-group + = f.gitlab_ui_checkbox_component :"throttle_unauthenticated_#{setting_fragment}_enabled", + _('Enable unauthenticated API request rate limit'), + checkbox_options: { data: { qa_selector: "throttle_unauthenticated_#{setting_fragment}_checkbox" } }, + label_options: { class: 'label-bold' } + .form-group + = f.label :"throttle_unauthenticated_#{setting_fragment}_requests_per_period", _('Maximum unauthenticated API requests per rate limit period per IP'), class: 'label-bold' + = f.number_field :"throttle_unauthenticated_#{setting_fragment}_requests_per_period", class: 'form-control gl-form-input' + .form-group + = f.label :"throttle_unauthenticated_#{setting_fragment}_period_in_seconds", _('Unauthenticated API rate limit period in seconds'), class: 'label-bold' + = f.number_field :"throttle_unauthenticated_#{setting_fragment}_period_in_seconds", class: 'form-control gl-form-input' + + %fieldset + .form-group + = f.gitlab_ui_checkbox_component :"throttle_authenticated_#{setting_fragment}_enabled", + _('Enable authenticated API request rate limit'), + checkbox_options: { data: { qa_selector: "throttle_authenticated_#{setting_fragment}_checkbox" } }, + label_options: { class: 'label-bold' } + .form-group + = f.label :"throttle_authenticated_#{setting_fragment}_requests_per_period", _('Maximum authenticated API requests per rate limit period per user'), class: 'label-bold' + = f.number_field :"throttle_authenticated_#{setting_fragment}_requests_per_period", class: 'form-control gl-form-input' + .form-group + = f.label :"throttle_authenticated_#{setting_fragment}_period_in_seconds", _('Authenticated API rate limit period in seconds'), class: 'label-bold' + = f.number_field :"throttle_authenticated_#{setting_fragment}_period_in_seconds", class: 'form-control gl-form-input' + + = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_package_registry_limits.html.haml b/app/views/admin/application_settings/_package_registry_limits.html.haml deleted file mode 100644 index 8769171c9e0..00000000000 --- a/app/views/admin/application_settings/_package_registry_limits.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-packages-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) - - %fieldset - = _("The package registry rate limits can help reduce request volume (like from crawlers or abusive bots).") - - %fieldset - .form-group - .form-check - = f.check_box :throttle_unauthenticated_packages_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_unauthenticated_packages_api_checkbox' } - = f.label :throttle_unauthenticated_packages_api_enabled, class: 'form-check-label label-bold' do - = _('Enable unauthenticated API request rate limit') - .form-group - = f.label :throttle_unauthenticated_packages_api_requests_per_period, _('Maximum unauthenticated API requests per rate limit period per IP'), class: 'label-bold' - = f.number_field :throttle_unauthenticated_packages_api_requests_per_period, class: 'form-control gl-form-input' - .form-group - = f.label :throttle_unauthenticated_packages_api_period_in_seconds, _('Unauthenticated API rate limit period in seconds'), class: 'label-bold' - = f.number_field :throttle_unauthenticated_packages_api_period_in_seconds, class: 'form-control gl-form-input' - %hr - .form-group - .form-check - = f.check_box :throttle_authenticated_packages_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_packages_api_checkbox' } - = f.label :throttle_authenticated_packages_api_enabled, class: 'form-check-label label-bold' do - = _('Enable authenticated API request rate limit') - .form-group - = f.label :throttle_authenticated_packages_api_requests_per_period, _('Maximum authenticated API requests per rate limit period per user'), class: 'label-bold' - = f.number_field :throttle_authenticated_packages_api_requests_per_period, class: 'form-control gl-form-input' - .form-group - = f.label :throttle_authenticated_packages_api_period_in_seconds, _('Authenticated API rate limit period in seconds'), class: 'label-bold' - = f.number_field :throttle_authenticated_packages_api_period_in_seconds, class: 'form-control gl-form-input' - - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml index 50fc11ec7f3..82e56cf8b81 100644 --- a/app/views/admin/application_settings/_performance.html.haml +++ b/app/views/admin/application_settings/_performance.html.haml @@ -6,29 +6,24 @@ .form-check = f.check_box :authorized_keys_enabled, class: 'form-check-input' = f.label :authorized_keys_enabled, class: 'form-check-label' do - = _('Write to "authorized_keys" file') + = _('Use authorized_keys file to authenticate SSH keys') .form-text.text-muted - By default, we write to the "authorized_keys" file to support Git - over SSH without additional configuration. GitLab can be optimized - to authenticate SSH keys via the database file. Only uncheck this - if you have configured your OpenSSH server to use the - AuthorizedKeysCommand. Click on the help icon for more details. - = link_to sprite_icon('question-o'), help_page_path('administration/operations/fast_ssh_key_lookup') - + = _('Authenticate user SSH keys without requiring additional configuration. Performance of GitLab can be improved by using the GitLab database instead.') + = link_to _('How do I configure authentication using the GitLab database?'), help_page_path('administration/operations/fast_ssh_key_lookup'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.label :raw_blob_request_limit, _('Raw blob request rate limit per minute'), class: 'label-bold' = f.number_field :raw_blob_request_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _('Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0.') + = _('Maximum number of requests per minute for each raw path (default is 300). Set to 0 to disable throttling.') .form-group = f.label :push_event_hooks_limit, class: 'label-bold' = f.number_field :push_event_hooks_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _("Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value.") + = _('Maximum number of changes (branches or tags) in a single push for which webhooks and services trigger (default is 3).') .form-group = f.label :push_event_activities_limit, class: 'label-bold' = f.number_field :push_event_activities_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _('Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push event will be created. Bulk push event will be created if it surpasses that value.') + = _('Threshold number of changes (branches or tags) in a single push above which a bulk push event is created (default is 3).') = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index 8c98778147e..756c0e770a6 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -7,7 +7,7 @@ = expanded ? _('Collapse') : _('Expand') %p - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('development/snowplow/index') } - = html_escape(_('Configure %{link} to track events. %{link_start}Learn more.%{link_end}')) % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank').html_safe, link_start: link_start, link_end: '</a>'.html_safe } + = html_escape(_('Configure %{link} to track events. %{link_start}Learn more.%{link_end}')) % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank', rel: 'noopener noreferrer').html_safe, link_start: link_start, link_end: '</a>'.html_safe } .settings-content = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form', id: 'snowplow-settings' } do |f| = form_errors(@application_setting) if expanded diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml index 011bce3ca99..53ca4d4aa79 100644 --- a/app/views/admin/application_settings/_spam.html.haml +++ b/app/views/admin/application_settings/_spam.html.haml @@ -2,6 +2,11 @@ = form_errors(@application_setting) %fieldset + %h5 + = _('reCAPTCHA') + %p + = _('reCAPTCHA helps prevent credential stuffing.') + = link_to _('Only reCAPTCHA v2 is supported:'), 'https://developers.google.com/recaptcha/docs/versions', target: '_blank', rel: 'noopener noreferrer' .form-group .form-check = f.check_box :recaptcha_enabled, class: 'form-check-input' @@ -9,25 +14,31 @@ = _("Enable reCAPTCHA") %span.form-text.text-muted#recaptcha_help_block = _('Helps prevent bots from creating accounts.') + = link_to _('How do I configure it?'), help_page_path('integration/recaptcha.md'), target: '_blank', rel: 'noopener noreferrer' .form-group .form-check = f.check_box :login_recaptcha_protection_enabled, class: 'form-check-input' = f.label :login_recaptcha_protection_enabled, class: 'form-check-label' do - = _("Enable reCAPTCHA for login") + = _('Enable reCAPTCHA for login.') %span.form-text.text-muted#recaptcha_help_block = _('Helps prevent bots from brute-force attacks.') .form-group - = f.label :recaptcha_site_key, _('reCAPTCHA Site Key'), class: 'label-bold' + = f.label :recaptcha_site_key, _('reCAPTCHA site key'), class: 'label-bold' = f.text_field :recaptcha_site_key, class: 'form-control gl-form-input' .form-text.text-muted = _("Generate site and private keys at") %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha .form-group - = f.label :recaptcha_private_key, _('reCAPTCHA Private Key'), class: 'label-bold' - .form-group + = f.label :recaptcha_private_key, _('reCAPTCHA private key'), class: 'label-bold' = f.text_field :recaptcha_private_key, class: 'form-control gl-form-input' + %h5 + = _('Invisible Captcha') + %p + = _('Invisible Captcha helps prevent the creation of spam accounts. It adds a honeypot field and time-sensitive form submission to the account signup form.') + = link_to _('Read their documentation.'), 'https://github.com/markets/invisible_captcha', target: '_blank', rel: 'noopener noreferrer' + .form-group .form-check = f.check_box :invisible_captcha_enabled, class: 'form-check-input' @@ -36,12 +47,18 @@ %span.form-text.text-muted = _('Helps prevent bots from creating accounts.') + %h5 + = _('Akismet') + %p + = _('Akismet helps prevent the creation of spam issues in public projects.') + = link_to _('How do I configure Akismet?'), help_page_path('integration/akismet.md'), target: '_blank', rel: 'noopener noreferrer' + .form-group .form-check = f.check_box :akismet_enabled, class: 'form-check-input' = f.label :akismet_enabled, class: 'form-check-label' do Enable Akismet - %span.form-text.text-muted#akismet_help_block= _("Helps prevent bots from creating issues") + %span.form-text.text-muted#akismet_help_block= _("Helps prevent bots from creating issues.") .form-group = f.label :akismet_api_key, _('Akismet API Key'), class: 'label-bold' @@ -50,25 +67,31 @@ Generate API key at %a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com + %h5 + = _('IP address restrictions') + .form-group .form-check = f.check_box :unique_ips_limit_enabled, class: 'form-check-input' = f.label :unique_ips_limit_enabled, class: 'form-check-label' do - = _("Limit sign in from multiple ips") + = _("Limit sign in from multiple IP addresses") %span.form-text.text-muted#unique_ip_help_block - = _("Helps prevent malicious users hide their activity") + = _("Helps prevent malicious users hide their activity.") .form-group - = f.label :unique_ips_limit_per_user, _('IPs per user'), class: 'label-bold' + = f.label :unique_ips_limit_per_user, _('IP addresses per user'), class: 'label-bold' = f.number_field :unique_ips_limit_per_user, class: 'form-control gl-form-input' .form-text.text-muted - = _("Maximum number of unique IPs per user") + = _("Maximum number of unique IP addresses per user.") .form-group - = f.label :unique_ips_limit_time_window, _('IP expiration time'), class: 'label-bold' + = f.label :unique_ips_limit_time_window, _('IP address expiration time'), class: 'label-bold' = f.number_field :unique_ips_limit_time_window, class: 'form-control gl-form-input' .form-text.text-muted - = _("How many seconds an IP will be counted towards the limit") + = _("How many seconds an IP counts toward the IP address limit.") + + %h5 + = _('Spam Check') .form-group .form-check @@ -79,8 +102,8 @@ = f.label :spam_check_endpoint_url, _('URL of the external Spam Check endpoint'), class: 'label-bold' = f.text_field :spam_check_endpoint_url, class: 'form-control gl-form-input' .form-group - = f.label :spam_check_api_key, _('Spam Check API Key'), class: 'gl-font-weight-bold' + = f.label :spam_check_api_key, _('Spam Check API key'), class: 'gl-font-weight-bold' = f.text_field :spam_check_api_key, class: 'form-control gl-form-input' - .form-text.text-muted= _('The API key used by GitLab for accessing the Spam Check service endpoint') + .form-text.text-muted= _('The API key used by GitLab for accessing the Spam Check service endpoint.') = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml index d6e31a24cf6..c53f63e124b 100644 --- a/app/views/admin/application_settings/_terminal.html.haml +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -6,5 +6,5 @@ = f.label :terminal_max_session_time, _('Max session time'), class: 'label-bold' = f.number_field :terminal_max_session_time, class: 'form-control gl-form-input' .form-text.text-muted - = _('Maximum time for web terminal websocket connection (in seconds). 0 for unlimited.') + = _('Maximum time, in seconds, for a web terminal websocket connection. 0 for unlimited.') = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index ddd0abb4c34..5bdad50c161 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -10,21 +10,21 @@ = f.label :version_check_enabled, class: 'form-check-label' do = _("Enable version check") .form-text.text-muted - = _("GitLab will inform you if a new version is available.") - = _("%{link_start}Learn more%{link_end} about what information is shared with GitLab Inc.").html_safe % { link_start: "<a href='#{help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check")}'>".html_safe, link_end: '</a>'.html_safe } + = _("GitLab informs you if a new version is available.") + = _("%{link_start}What information does GitLab Inc. collect?%{link_end}").html_safe % { link_start: "<a href='#{help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check")}'>".html_safe, link_end: '</a>'.html_safe } .form-group - can_be_configured = @application_setting.usage_ping_can_be_configured? .form-check = f.check_box :usage_ping_enabled, disabled: !can_be_configured, class: 'form-check-input' = f.label :usage_ping_enabled, class: 'form-check-label' do - = _('Enable service ping') + = _('Enable Service Ping') .form-text.text-muted - if can_be_configured - %p.mb-2= _('To help improve GitLab and its user experience, GitLab will periodically collect usage information.') + %p.mb-2= _('To help improve GitLab and its user experience, GitLab periodically collects usage information.') - - service_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'service-ping') + - service_ping_path = help_page_path('development/service_ping/index.md') - service_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: service_ping_path } - %p.mb-2= s_('%{service_ping_link_start}Learn more%{service_ping_link_end} about what information is shared with GitLab Inc.').html_safe % { service_ping_link_start: service_ping_link_start, service_ping_link_end: '</a>'.html_safe } + %p.mb-2= s_('%{service_ping_link_start}What information is shared with GitLab Inc.?%{service_ping_link_end}').html_safe % { service_ping_link_start: service_ping_link_start, service_ping_link_end: '</a>'.html_safe } %button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } } .gl-spinner.js-spinner.gl-display-none.gl-mr-2 @@ -46,15 +46,23 @@ - if usage_ping_enabled %p.gl-mb-3.text-muted{ id: 'service_ping_features_helper_text' }= _('You can enable Registration Features because Service Ping is enabled. To continue using Registration Features in the future, you will also need to register with GitLab via a new cloud licensing service.') - else - %p.gl-mb-3.text-muted{ id: 'service_ping_features_helper_text' }= _('To enable Registration Features, make sure "Enable service ping" is checked.') + %p.gl-mb-3.text-muted{ id: 'service_ping_features_helper_text' }= _('To enable Registration Features, first enable Service Ping.') %p.gl-mb-3.text-muted= _('Registration Features include:') .form-text - email_from_gitlab_path = help_page_path('tools/email.md') + - repo_size_limit_path = help_page_path('user/admin_area/settings/account_and_limit_settings.md', anchor: 'repository-size-limit') + - restrict_ip_path = help_page_path('user/group/index.md', anchor: 'restrict-group-access-by-ip-address') - link_end = '</a>'.html_safe - email_from_gitlab_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: email_from_gitlab_path } + - repo_size_limit_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: repo_size_limit_path } + - restrict_ip_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: restrict_ip_path } %ul %li = _('Email from GitLab - email users right from the Admin Area. %{link_start}Learn more%{link_end}.').html_safe % { link_start: email_from_gitlab_link, link_end: link_end } + %li + = _('Limit project size at a global, group, and project level. %{link_start}Learn more%{link_end}.').html_safe % { link_start: repo_size_limit_link, link_end: link_end } + %li + = _('Restrict group access by IP address. %{link_start}Learn more%{link_end}.').html_safe % { link_start: restrict_ip_link, link_end: link_end } = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/appearances/preview_sign_in.html.haml b/app/views/admin/application_settings/appearances/preview_sign_in.html.haml index 77c37abbeef..2e4ab714048 100644 --- a/app/views/admin/application_settings/appearances/preview_sign_in.html.haml +++ b/app/views/admin/application_settings/appearances/preview_sign_in.html.haml @@ -1,12 +1,13 @@ = render 'devise/shared/tab_single', tab_title: _('Sign in preview') .login-box %form.gl-show-field-errors + - title = _('This form is disabled in preview') .form-group = label_tag :login - = text_field_tag :login, nil, class: "form-control gl-form-input top", title: _('Please provide your username or email address.') + = text_field_tag :login, nil, disabled: true, class: "form-control gl-form-input top", title: title .form-group = label_tag :password - = password_field_tag :password, nil, class: "form-control gl-form-input bottom", title: _('This field is required.') + = password_field_tag :password, nil, disabled: true, class: "form-control gl-form-input bottom", title: title .form-group - = button_tag _("Sign in"), class: "btn gl-button btn-confirm", type: "button" + = button_tag _("Sign in"), disabled: true, class: "btn gl-button btn-confirm", type: "button", title: title diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 9102769cc6e..a72c96bb577 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -79,7 +79,8 @@ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Set max session time for web terminal.') + = _('Set the maximum session time for a web terminal.') + = link_to _('How do I use a web terminal?'), help_page_path('ci/environments/index.md', anchor: 'web-terminals'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'terminal' diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index f1e37c76130..6087551d7c7 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -49,7 +49,7 @@ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Enable or disable version check and service ping.') + = _('Enable or disable version check and Service Ping.') .settings-content = render 'usage' diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 8dff2bc36cb..58e3f3f1136 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -35,9 +35,10 @@ = _('Set rate limits for package registry API requests that supersede the general user and IP rate limits.') = link_to _('Learn more.'), help_page_path('user/admin_area/settings/package_registry_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content - = render 'package_registry_limits' + = render partial: 'network_rate_limits', locals: { anchor: 'js-packages-limits-settings', setting_fragment: 'packages_api' } + - if Feature.enabled?(:files_api_throttling, default_enabled: :yaml) - %section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'files-limits-settings' } } + %section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 = _('Files API Rate Limits') @@ -46,7 +47,19 @@ %p = _('Configure specific limits for Files API requests that supersede the general user and IP rate limits.') .settings-content - = render 'files_limits' + = render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' } + +%section.settings.as-deprecated-limits.no-animate#js-deprecated-limits-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Deprecated API rate limits') + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Configure specific limits for deprecated API requests that supersede the general user and IP rate limits.') + = link_to _('Which API requests are affected?'), help_page_path('user/admin_area/settings/deprecated_api_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + = render partial: 'network_rate_limits', locals: { anchor: 'js-deprecated-limits-settings', setting_fragment: 'deprecated_api' } %section.settings.as-git-lfs-limits.no-animate#js-git-lfs-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'git_lfs_limits_content' } } .settings-header diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml index 914a09ff5db..d2e118f0624 100644 --- a/app/views/admin/application_settings/reporting.html.haml +++ b/app/views/admin/application_settings/reporting.html.haml @@ -9,9 +9,7 @@ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - - recaptcha_v2_link_url = 'https://developers.google.com/recaptcha/docs/versions' - - recaptcha_v2_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: recaptcha_v2_link_url } - = _('Enable reCAPTCHA, Invisible Captcha, Akismet and set IP limits. For reCAPTCHA, we currently only support %{recaptcha_v2_link_start}v2%{recaptcha_v2_link_end}').html_safe % { recaptcha_v2_link_start: recaptcha_v2_link_start, recaptcha_v2_link_end: '</a>'.html_safe } + = _('Configure CAPTCHAs, IP address limits, and other anti-spam measures.') .settings-content = render 'spam' @@ -22,6 +20,7 @@ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Set notification email for abuse reports.') + = _('Receive notification of abuse reports by email.') + = link_to _('Learn more.'), help_page_path('user/admin_area/review_abuse_reports.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'abuse' diff --git a/app/views/admin/dashboard/_security_newsletter_callout.html.haml b/app/views/admin/dashboard/_security_newsletter_callout.html.haml new file mode 100644 index 00000000000..ece0f7ca4d9 --- /dev/null +++ b/app/views/admin/dashboard/_security_newsletter_callout.html.haml @@ -0,0 +1,14 @@ +- return unless show_security_newsletter_user_callout? + += render 'shared/global_alert', + title: s_('AdminArea|Get security updates from GitLab and stay up to date'), + variant: :tip, + alert_class: 'js-security-newsletter-callout', + is_contained: true, + alert_data: { feature_id: UserCalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: user_callouts_path, defer_links: 'true' }, + close_button_data: { testid: 'close-security-newsletter-callout' } do + .gl-alert-body + = s_('AdminArea|Sign up for the GitLab Security Newsletter to get notified for security updates.') + .gl-alert-actions + = link_to 'https://about.gitlab.com/company/preference-center/', target: '_blank', rel: 'noreferrer noopener', class: 'deferred-link gl-alert-action btn-confirm btn-md gl-button' do + = s_('AdminArea|Sign up for the GitLab newsletter') diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 97b3a757a3f..681e7ccb613 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -4,6 +4,7 @@ - billable_users_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: billable_users_url } = render_if_exists 'shared/qrtly_reconciliation_alert' += render 'admin/dashboard/security_newsletter_callout' - if @notices - @notices.each do |notice| diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml index a7f947f96ea..6a46b0b3510 100644 --- a/app/views/admin/hook_logs/_index.html.haml +++ b/app/views/admin/hook_logs/_index.html.haml @@ -1,37 +1,11 @@ +- docs_link_url = help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks') +- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url } +- link_end = '</a>'.html_safe + .row.gl-mt-3.gl-mb-3 .col-lg-3 %h4.gl-mt-0 - = _('Recent Deliveries') - %p= _('When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.') + = _('Recent events') + %p= _('GitLab events trigger webhooks. Use the request details of a webhook to help troubleshoot problems. %{link_start}How do I troubleshoot?%{link_end}').html_safe % { link_start: link_start, link_end: link_end } .col-lg-9 - - if hook_logs.present? - %table.table - %thead - %tr - %th= _('Status') - %th= _('Trigger') - %th= _('URL') - %th= _('Elapsed time') - %th= _('Request time') - %th - - hook_logs.each do |hook_log| - %tr - %td - = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } - %td.d-none.d-sm-block - %span.badge.badge-gray.deploy-project-label - = hook_log.trigger.singularize.titleize - %td - = truncate(hook_log.url, length: 50) - %td.light - #{number_with_precision(hook_log.execution_duration, precision: 2)} sec - %td.light - = time_ago_with_tooltip(hook_log.created_at) - %td - = link_to _('View details'), admin_hook_hook_log_path(hook, hook_log) - - = paginate hook_logs, theme: 'gitlab' - - - else - .settings-message.text-center - = _("You don't have any webhooks deliveries") + = render partial: 'shared/hook_logs/recent_deliveries_table', locals: { hook: hook, hook_logs: hook_logs } diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index 5ebfd296e2b..f947e174990 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -1,33 +1,24 @@ - page_title _('Projects') - params[:visibility_level] ||= [] -- active_tab_classes = 'active gl-tab-nav-item-active gl-tab-nav-item-active-indigo' .top-area.scrolling-tabs-container.inner-page-scroll-tabs - %ul.nav.gl-tabs-nav.gl-overflow-x-auto.gl-display-flex.gl-flex-grow-1.gl-flex-shrink-1.gl-border-b-0.gl-flex-nowrap.gl-webkit-scrollbar-display-none - = nav_link(html_options: { class: "nav-item" } ) do - = link_to _('All'), admin_projects_path, class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level].empty?}" - = nav_link(html_options: { class: "nav-item" } ) do - = link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE), class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level] == Gitlab::VisibilityLevel::PRIVATE.to_s}" - = nav_link(html_options: { class: "nav-item" } ) do - = link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL), class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level] == Gitlab::VisibilityLevel::INTERNAL.to_s}" - = nav_link(html_options: { class: "nav-item" } ) do - = link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC), class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level] == Gitlab::VisibilityLevel::PUBLIC.to_s}" + = gl_tabs_nav({ class: 'gl-border-b-0 gl-overflow-x-auto gl-flex-grow-1 gl-flex-nowrap gl-webkit-scrollbar-display-none' }) do + = gl_tab_link_to _('All'), admin_projects_path(visibility_level: nil), { item_active: params[:visibility_level].empty? } + = gl_tab_link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + = gl_tab_link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + = gl_tab_link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC) .nav-controls .search-holder = render 'shared/projects/search_form', autofocus: true, admin_view: true - .dropdown - - toggle_text = _('Namespace') - - if params[:namespace_id].present? - = hidden_field_tag :namespace_id, params[:namespace_id] - - namespace = Namespace.find(params[:namespace_id]) - - toggle_text = "#{namespace.kind}: #{namespace.full_path}" - = dropdown_toggle(toggle_text, { toggle: 'dropdown', is_filter: 'true' }, { toggle_class: 'js-namespace-select large' }) - .dropdown-menu.dropdown-select.dropdown-menu-right - = dropdown_title(_('Namespaces')) - = dropdown_filter(_("Search for Namespace")) - = dropdown_content - = dropdown_loading + - current_namespace = _('Namespace') + - if params[:namespace_id].present? + - namespace = Namespace.find(params[:namespace_id]) + - current_namespace = "#{namespace.kind}: #{namespace.full_path}" + %button.dropdown-menu-toggle.btn.btn-default.btn-md.gl-button.js-namespace-select{ data: { show_any: 'true', field_name: 'namespace_id', placeholder: current_namespace, update_location: 'true' }, type: 'button' } + %span.gl-new-dropdown-button-text + = current_namespace + = render 'shared/projects/dropdown' = link_to new_project_path, class: 'gl-button btn btn-confirm' do = _('New Project') diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 1a87b21351c..3069aab2710 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -143,13 +143,10 @@ .col-sm-3.col-form-label = f.label :new_namespace_id, _("Namespace") .col-sm-9 - .dropdown - = dropdown_toggle(_('Search for Namespace'), { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' }) - .dropdown-menu.dropdown-select - = dropdown_title(_('Namespaces')) - = dropdown_filter(_('Search for Namespace')) - = dropdown_content - = dropdown_loading + - placeholder = _('Search for Namespace') + %button.dropdown-menu-toggle.btn.btn-default.btn-md.gl-button.js-namespace-select{ data: { field_name: 'new_namespace_id', placeholder: placeholder }, type: 'button' } + %span.gl-new-dropdown-button-text + = placeholder .form-group.row .offset-sm-3.col-sm-9 diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 59523ed3a0c..808b2bb4f8e 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -9,7 +9,7 @@ .row .col-md-6 %h4= _('Restrict projects for this runner') - - if @runner.projects.any? + - if @runner.runner_projects.any? %table.table{ data: { testid: 'assigned-projects' } } %thead %tr diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml deleted file mode 100644 index a3e1ccc5d4a..00000000000 --- a/app/views/admin/serverless/domains/_form.html.haml +++ /dev/null @@ -1,99 +0,0 @@ -- form_name = 'js-serverless-domain-settings' -- form_url = @domain.persisted? ? admin_serverless_domain_path(@domain.id, anchor: form_name) : admin_serverless_domains_path(anchor: form_name) -- show_certificate_card = @domain.persisted? && @domain.errors.blank? -= form_for @domain, url: form_url, html: { class: 'fieldset-form' } do |f| - = form_errors(@domain) - - %fieldset - - if @domain.persisted? - - dns_record = "*.#{@domain.domain} CNAME #{Settings.pages.host}." - - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}" - .form-group.row - .col-sm-6.position-relative - = f.label :domain, _('Domain'), class: 'label-bold' - = f.text_field :domain, class: 'form-control has-floating-status-badge', readonly: true - .status-badge.floating-status-badge - - text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success'] - .badge{ class: status } - = text - = link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "gl-button btn has-tooltip", title: _("Retry verification") - - .col-sm-6 - = f.label :serverless_domain_dns, _('DNS'), class: 'label-bold' - .input-group - = text_field_tag :serverless_domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true - .input-group-append - = clipboard_button(target: '#serverless_domain_dns', class: 'btn-default input-group-text d-none d-sm-block') - - .col-sm-12.form-text.text-muted - = _("To access this domain create a new DNS record") - - .form-group - = f.label :serverless_domain_verification, _('Verification status'), class: 'label-bold' - .input-group - = text_field_tag :serverless_domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true - .input-group-append - = clipboard_button(target: '#serverless_domain_verification', class: 'btn-default d-none d-sm-block') - %p.form-text.text-muted - - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership')) - = _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration.").html_safe % { link_to_help: link_to_help } - - - else - .form-group - = f.label :domain, _('Domain'), class: 'label-bold' - = f.text_field :domain, class: 'form-control' - - - if show_certificate_card - .card.js-domain-cert-show - .card-header - = _('Certificate') - .d-flex.justify-content-between.align-items-center.p-3 - %span - = @domain.subject || _('missing') - %button.gl-button.btn.btn-danger.btn-sm.js-domain-cert-replace-btn{ type: 'button' } - = _('Replace') - - .js-domain-cert-inputs{ class: ('hidden' if show_certificate_card) } - .form-group - = f.label :user_provided_certificate, _('Certificate (PEM)'), class: 'label-bold' - = f.text_area :user_provided_certificate, rows: 5, class: 'form-control', value: '' - %span.form-text.text-muted - = _("Upload a certificate for your domain with all intermediates") - .form-group - = f.label :user_provided_key, _('Key (PEM)'), class: 'label-bold' - = f.text_area :user_provided_key, rows: 5, class: 'form-control', value: '' - %span.form-text.text-muted - = _("Upload a private key for your certificate") - - = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "gl-button btn btn-confirm js-serverless-domain-submit", disabled: @domain.persisted? - - if @domain.persisted? - %button.gl-button.btn.btn-danger{ type: 'button', data: { toggle: 'modal', target: "#modal-delete-domain" } } - = _('Delete domain') - --# haml-lint:disable NoPlainNodes -- if @domain.persisted? - - domain_attached = @domain.serverless_domain_clusters.count > 0 - .modal{ id: "modal-delete-domain", tabindex: -1 } - .modal-dialog - .modal-content - .modal-header - %h3.page-title= _('Delete serverless domain?') - %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": "true" } × - - .modal-body - - if domain_attached - = _("You must disassociate %{domain} from all clusters it is attached to before deletion.").html_safe % { domain: "<code>#{@domain.domain}</code>".html_safe } - - else - = _("You are about to delete %{domain} from your instance. This domain will no longer be available to any Knative application.").html_safe % { domain: "<code>#{@domain.domain}</code>".html_safe } - - .modal-footer - %a{ href: '#', data: { dismiss: 'modal' }, class: 'gl-button btn btn-default' } - = _('Cancel') - - = link_to _('Delete domain'), - admin_serverless_domain_path(@domain.id), - title: _('Delete'), - method: :delete, - class: "gl-button btn btn-danger", - disabled: domain_attached diff --git a/app/views/admin/serverless/domains/index.html.haml b/app/views/admin/serverless/domains/index.html.haml deleted file mode 100644 index c2b6baed4de..00000000000 --- a/app/views/admin/serverless/domains/index.html.haml +++ /dev/null @@ -1,25 +0,0 @@ -- breadcrumb_title _("Operations") -- page_title _("Operations") -- @content_class = "limit-container-width" unless fluid_layout - --# normally expanded_by_default? is used here, but since this is the only panel --# in this settings page, let's leave it always open by default -- expanded = true - -%section.settings.as-serverless-domain.no-animate#js-serverless-domain-settings{ class: ('expanded' if expanded) } - .settings-header - %h4 - = _('Serverless domain') - %button.gl-button.btn.btn-default.js-settings-toggle{ type: 'button' } - = expanded ? _('Collapse') : _('Expand') - %p - = _('Set an instance-wide domain that will be available to all clusters when installing Knative.') - .settings-content - - if Gitlab.config.pages.enabled - = render 'form' - - else - .card - .card-header - = s_('GitLabPages|Domains') - .nothing-here-block - = s_("GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it.") diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index 47ef4f26889..c9b002a4dd2 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -1,7 +1,7 @@ = form_tag(admin_session_path, method: :post, class: 'new_user gl-show-field-errors', 'aria-live': 'assertive') do .form-group = label_tag :user_password, _('Password'), class: 'label-bold' - = password_field_tag 'user[password]', nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } + = password_field_tag 'user[password]', nil, class: 'form-control', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } .submit-container.move-submit-down = submit_tag _('Enter Admin Mode'), class: 'gl-button btn btn-success', data: { qa_selector: 'enter_admin_mode_button' } diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml new file mode 100644 index 00000000000..21a1d74a8c6 --- /dev/null +++ b/app/views/admin/topics/_form.html.haml @@ -0,0 +1,40 @@ += gitlab_ui_form_for @topic, url: url, html: { multipart: true, class: 'js-project-topic-form gl-show-field-errors common-note-form js-quick-submit js-requires-input' }, authenticity_token: true do |f| + = form_errors(@topic) + + .form-group + = f.label :name do + = _("Topic name") + = f.text_field :name, placeholder: _('My topic'), class: 'form-control input-lg', data: { qa_selector: 'topic_name_field' }, + required: true, + title: _('Please fill in a name for your topic.'), + autofocus: true + + .form-group + = f.label :description, _("Description") + = render layout: 'shared/md_preview', locals: { url: preview_markdown_admin_topics_path, referenced_users: false } do + = render 'shared/zen', f: f, attr: :description, + classes: 'note-textarea', + placeholder: _('Write a description…'), + supports_quick_actions: false, + supports_autocomplete: false, + qa_selector: 'topic_form_description' + = render 'shared/notes/hints', supports_file_upload: false + + .form-group.gl-mt-3.gl-mb-3 + = f.label :avatar, _('Topic avatar'), class: 'gl-display-block' + - if @topic.avatar? + .avatar-container.rect-avatar.s90 + = topic_icon(@topic, alt: _('Topic avatar'), class: 'avatar topic-avatar s90') + = render 'shared/choose_avatar_button', f: f + - if @topic.avatar? + = link_to _('Remove avatar'), admin_topic_avatar_path(@topic), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'gl-button btn btn-danger-secondary gl-mt-2' + + - if @topic.new_record? + .form-actions + = f.submit _('Create topic'), class: "gl-button btn btn-confirm" + = link_to _('Cancel'), admin_topics_path, class: "gl-button btn btn-default btn-cancel" + + - else + .form-actions + = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = link_to _('Cancel'), admin_topics_path, class: "gl-button btn btn-cancel" diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml new file mode 100644 index 00000000000..abf3cffa422 --- /dev/null +++ b/app/views/admin/topics/_topic.html.haml @@ -0,0 +1,17 @@ +- topic = local_assigns.fetch(:topic) + +%li.topic-row.gl-py-3.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'topic_row_content' } } + .avatar-container.rect-avatar.s40.gl-flex-shrink-0 + = topic_icon(topic, class: "avatar s40") + + .gl-min-w-0.gl-flex-grow-1 + .title + = topic.name + + .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex + %span.gl-ml-5.has-tooltip{ title: n_('%d project', '%d projects', topic.total_projects_count) % topic.total_projects_count } + = sprite_icon('bookmark', css_class: 'gl-vertical-align-text-bottom') + = number_with_delimiter(topic.total_projects_count) + + .controls.gl-flex-shrink-0.gl-ml-5 + = link_to _('Edit'), edit_admin_topic_path(topic), id: "edit_#{dom_id(topic)}", class: 'btn gl-button btn-default' diff --git a/app/views/admin/topics/edit.html.haml b/app/views/admin/topics/edit.html.haml new file mode 100644 index 00000000000..4416bb0fe18 --- /dev/null +++ b/app/views/admin/topics/edit.html.haml @@ -0,0 +1,4 @@ +- page_title _("Edit"), @topic.name, _("Topics") +%h3.page-title= _('Edit topic: %{topic_name}') % { topic_name: @topic.name } +%hr += render 'form', url: admin_topic_path(@topic) diff --git a/app/views/admin/topics/index.html.haml b/app/views/admin/topics/index.html.haml new file mode 100644 index 00000000000..6485b8aa411 --- /dev/null +++ b/app/views/admin/topics/index.html.haml @@ -0,0 +1,19 @@ +- page_title _("Topics") + += form_tag admin_topics_path, method: :get do |f| + .gl-py-3.gl-display-flex.gl-flex-direction-column-reverse.gl-md-flex-direction-row.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 + .gl-flex-grow-1.gl-mt-3.gl-md-mt-0 + .inline.gl-w-full.gl-md-w-auto + - search = params.fetch(:search, nil) + .search-field-holder + = search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name'), data: { qa_selector: 'topic_search_field' } + = sprite_icon('search', css_class: 'search-icon') + .nav-controls + = link_to new_admin_topic_path, class: "gl-button btn btn-confirm gl-w-full gl-md-w-auto" do + = _('New topic') +%ul.content-list + = render partial: 'topic', collection: @topics + += paginate_collection @topics +- if @topics.empty? + = render 'shared/empty_states/topics' diff --git a/app/views/admin/topics/new.html.haml b/app/views/admin/topics/new.html.haml new file mode 100644 index 00000000000..8b4a8ac269e --- /dev/null +++ b/app/views/admin/topics/new.html.haml @@ -0,0 +1,4 @@ +- page_title _("New topic") +%h3.page-title= _('New topic') +%hr += render 'form', url: admin_topics_path(@topic) diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index aeb274fe2cb..6a5f07dd2db 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -19,22 +19,20 @@ .col-sm-10 - editing_current_user = (current_user == @user) - = f.radio_button :access_level, :regular, disabled: editing_current_user - = f.label :access_level_regular, class: 'font-weight-bold' do - = s_('AdminUsers|Regular') - %p.light - = s_('AdminUsers|Regular users have access to their groups and projects') + = f.gitlab_ui_radio_component :access_level, :regular, + s_('AdminUsers|Regular'), + radio_options: { disabled: editing_current_user }, + help_text: s_('AdminUsers|Regular users have access to their groups and projects.') = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user - = f.radio_button :access_level, :admin, disabled: editing_current_user - = f.label :access_level_admin, class: 'font-weight-bold' do - = s_('AdminUsers|Admin') - %p.light - = s_('AdminUsers|Administrators have access to all groups, projects and users and can manage all features in this installation') - - if editing_current_user - %p.light - = s_('AdminUsers|You cannot remove your own admin rights.') + - help_text = s_('AdminUsers|Administrators have access to all groups, projects and users and can manage all features in this installation.') + - help_text += ' ' + s_('AdminUsers|You cannot remove your own admin rights.') if editing_current_user + = f.gitlab_ui_radio_component :access_level, :admin, + s_('AdminUsers|Admin'), + radio_options: { disabled: editing_current_user }, + help_text: help_text + .form-group.row .col-sm-2.col-form-label.gl-pt-0 diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 9d62c19e2fc..3869a2b6dcd 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -1,5 +1,5 @@ .user_new - = form_for [:admin, @user], html: { class: 'fieldset-form' } do |f| + = gitlab_ui_form_for [:admin, @user], html: { class: 'fieldset-form' } do |f| = form_errors(@user) %fieldset @@ -39,12 +39,12 @@ .col-sm-2.col-form-label = f.label :password .col-sm-10 - = f.password_field :password, disabled: f.object.force_random_password, class: 'form-control gl-form-input' + = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input' .form-group.row .col-sm-2.col-form-label = f.label :password_confirmation .col-sm-10 - = f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control gl-form-input' + = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input' = render partial: 'access_levels', locals: { f: f } diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index f4b1a2853f1..bafb2085589 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -33,16 +33,11 @@ - if can_force_email_confirmation?(@user) %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: confirm_user_data(@user) } = _('Confirm user') -%ul.nav-links.nav.nav-tabs - = nav_link(path: 'users#show') do - = link_to _("Account"), admin_user_path(@user) - = nav_link(path: 'users#projects') do - = link_to _("Groups and projects"), projects_admin_user_path(@user) - = nav_link(path: 'users#keys') do - = link_to _("SSH keys"), keys_admin_user_path(@user) - = nav_link(controller: :identities) do - = link_to _("Identities"), admin_user_identities_path(@user) += gl_tabs_nav do + = gl_tab_link_to _("Account"), admin_user_path(@user) + = gl_tab_link_to _("Groups and projects"), projects_admin_user_path(@user) + = gl_tab_link_to _("SSH keys"), keys_admin_user_path(@user) + = gl_tab_link_to _("Identities"), admin_user_identities_path(@user) - if impersonation_enabled? - = nav_link(controller: :impersonation_tokens) do - = link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user) + = gl_tab_link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user) .gl-mb-3 diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index ad8d9d1f04f..2a9b4694e7b 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -61,7 +61,6 @@ = _('Disabled') = render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace - = render_if_exists 'admin/users/credit_card_info', user: @user %li %span.light= _('External User:') @@ -139,6 +138,8 @@ = render_if_exists 'namespaces/shared_runner_status', namespace: @user.namespace + = render_if_exists 'admin/users/credit_card_info', user: @user, link_to_match_page: true + = render 'shared/custom_attributes', custom_attributes: @user.custom_attributes -# Rendered on desktop only so order of cards can be different on desktop vs mobile diff --git a/app/views/authentication/_authenticate.html.haml b/app/views/authentication/_authenticate.html.haml index 5a2ae3f44c2..7dcec50573f 100644 --- a/app/views/authentication/_authenticate.html.haml +++ b/app/views/authentication/_authenticate.html.haml @@ -1,14 +1,17 @@ #js-authenticate-token-2fa %a.gl-button.btn.btn-block.btn-confirm#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code") +-# haml-lint:disable InlineJavaScript %script#js-authenticate-token-2fa-in-progress{ type: "text/template" } %p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.") +-# haml-lint:disable InlineJavaScript %script#js-authenticate-token-2fa-error{ type: "text/template" } %div %p <%= error_message %> (<%= error_name %>) %a.btn.btn-default.gl-button.btn-block#js-token-2fa-try-again= _("Try again?") +-# haml-lint:disable InlineJavaScript %script#js-authenticate-token-2fa-authenticated{ type: "text/template" } %div %p= _("We heard back from your device. You have been authenticated.") diff --git a/app/views/authentication/_register.html.haml b/app/views/authentication/_register.html.haml index 678fd3c8e8c..5eed969ed35 100644 --- a/app/views/authentication/_register.html.haml +++ b/app/views/authentication/_register.html.haml @@ -1,8 +1,10 @@ #js-register-token-2fa +-# haml-lint:disable InlineJavaScript %script#js-register-2fa-message{ type: "text/template" } %p <%= message %> +-# haml-lint:disable InlineJavaScript %script#js-register-token-2fa-setup{ type: "text/template" } - if current_user.two_factor_otp_enabled? .row.gl-mb-3 @@ -17,12 +19,14 @@ .col-md-8 %p= _("You need to register a two-factor authentication app before you can set up a device.") +-# haml-lint:disable InlineJavaScript %script#js-register-token-2fa-error{ type: "text/template" } %div %p %span <%= error_message %> (<%= error_name %>) %a.btn.btn-default.gl-button#js-token-2fa-try-again= _("Try again?") +-# haml-lint:disable InlineJavaScript %script#js-register-token-2fa-registered{ type: "text/template" } .row.gl-mb-3 .col-md-12 diff --git a/app/views/clusters/clusters/_advanced_settings_tab.html.haml b/app/views/clusters/clusters/_advanced_settings_tab.html.haml index b491a64e43d..8c9d98604dd 100644 --- a/app/views/clusters/clusters/_advanced_settings_tab.html.haml +++ b/app/views/clusters/clusters/_advanced_settings_tab.html.haml @@ -1,6 +1,5 @@ - active = params[:tab] == 'settings' - if can_admin_cluster?(current_user, @cluster) - %li.nav-item{ role: 'presentation' } - %a#cluster-settings-tab.nav-link{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'settings'}) } - %span= _('Advanced Settings') + = gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'settings' }), { item_active: active } do + = _('Advanced Settings') diff --git a/app/views/clusters/clusters/_details_tab.html.haml b/app/views/clusters/clusters/_details_tab.html.haml index 564c5103d34..734910686e7 100644 --- a/app/views/clusters/clusters/_details_tab.html.haml +++ b/app/views/clusters/clusters/_details_tab.html.haml @@ -1,5 +1,4 @@ - active = params[:tab] == 'details' || !params[:tab].present? -%li.nav-item{ role: 'presentation' } - %a#cluster-details-tab.nav-link.qa-details{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'details'}) } - %span= _('Details') += gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'details' }), { item_active: active } do + = _('Details') diff --git a/app/views/clusters/clusters/_health_tab.html.haml b/app/views/clusters/clusters/_health_tab.html.haml index fda392693f6..4292066cc6f 100644 --- a/app/views/clusters/clusters/_health_tab.html.haml +++ b/app/views/clusters/clusters/_health_tab.html.haml @@ -1,5 +1,4 @@ - active = params[:tab] == 'health' -%li.nav-item{ role: 'presentation' } - %a#cluster-health-tab.nav-link.qa-health{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'health'}) } - %span= _('Health') += gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'health' }), { item_active: active, data: { testid: 'cluster-health-tab' } } do + = _('Health') diff --git a/app/views/clusters/clusters/_integrations_tab.html.haml b/app/views/clusters/clusters/_integrations_tab.html.haml index 77b8b6ca3e6..e229c1fbe1e 100644 --- a/app/views/clusters/clusters/_integrations_tab.html.haml +++ b/app/views/clusters/clusters/_integrations_tab.html.haml @@ -1,6 +1,4 @@ -- tab_name = 'integrations' -- active = params[:tab] == tab_name +- active = params[:tab] == 'integrations' -%li.nav-item{ role: 'presentation' } - %a#cluster-apps-tab.nav-link{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: tab_name}) } - %span= _('Integrations') += gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'integrations' }), { item_active: active } do + = _('Integrations') diff --git a/app/views/clusters/clusters/_namespace.html.haml b/app/views/clusters/clusters/_namespace.html.haml index 9b728e7a89b..6412972e195 100644 --- a/app/views/clusters/clusters/_namespace.html.haml +++ b/app/views/clusters/clusters/_namespace.html.haml @@ -1,7 +1,6 @@ - managed_namespace_help_text = s_('ClusterIntegration|Set a prefix for your namespaces. If not set, defaults to your project path. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') - non_managed_namespace_help_text = s_('ClusterIntegration|The namespace associated with your project. This will be used for deploy boards, logs, and Web terminals.') -- managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/index.md', - anchor: 'gitlab-managed-clusters'), target: '_blank' +- managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank' .js-namespace-prefixed = platform_field.text_field :namespace, diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml index 93db7db06b3..f6d50410e9a 100644 --- a/app/views/clusters/clusters/aws/_new.html.haml +++ b/app/views/clusters/clusters/aws/_new.html.haml @@ -12,6 +12,6 @@ 'role-arn' => @aws_role.role_arn, 'instance-types' => @instance_types, 'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'), - 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'create-a-new-certificate-based-eks-cluster'), - 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'create-a-new-certificate-based-eks-cluster'), + 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'how-to-create-a-new-cluster-on-eks-through-cluster-certificates-deprecated'), + 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'how-to-create-a-new-cluster-on-eks-through-cluster-certificates-deprecated'), 'external-link-icon' => sprite_icon('external-link') } } diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 0a482f1eb01..2a09d8d8cc0 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -15,7 +15,7 @@ provider_type: @cluster.provider_type, help_path: help_page_path('user/project/clusters/index.md'), environments_help_path: help_page_path('ci/environments/index.md', anchor: 'create-a-static-environment'), - clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'), + clusters_help_path: help_page_path('user/project/clusters/deploy_to_cluster.md'), deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'), cluster_id: @cluster.id } } @@ -24,11 +24,10 @@ .js-serverless-survey-banner{ data: { user_name: current_user.name, user_email: current_user.email } } - .d-flex.my-3 - %p.badge.badge-light.p-2.mr-2 + %h4.gl-my-5 + = @cluster.name + %span.badge.badge-info.badge-pill.gl-badge.md.gl-vertical-align-middle = cluster_type_label(@cluster.cluster_type) - %h4.m-0 - = @cluster.name = render 'banner' @@ -42,12 +41,12 @@ - if cluster_created?(@cluster) .js-toggle-container - %ul.nav-links.mobile-separator.nav.nav-tabs{ role: 'tablist' } - = render 'details_tab' + = gl_tabs_nav do + = render 'clusters/clusters/details_tab' = render_if_exists 'clusters/clusters/environments_tab' = render 'clusters/clusters/health_tab' - = render 'integrations_tab' - = render 'advanced_settings_tab' + = render 'clusters/clusters/integrations_tab' + = render 'clusters/clusters/advanced_settings_tab' .tab-content.py-3 .tab-pane.active{ role: 'tabpanel' } diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml index 0daadd20f54..c65b947d1ba 100644 --- a/app/views/dashboard/_activity_head.html.haml +++ b/app/views/dashboard/_activity_head.html.haml @@ -2,13 +2,7 @@ %h1.page-title= _('Activity') .top-area - %ul.nav-links.nav.nav-tabs - %li{ class: active_when(params[:filter].nil?) }> - = link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do - = _('Your projects') - %li{ class: active_when(params[:filter] == 'starred') }> - = link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do - = _('Starred projects') - %li{ class: active_when(params[:filter] == 'followed') }> - = link_to activity_dashboard_path(filter: 'followed'), data: {placement: 'right'} do - = _('Followed users') + = gl_tabs_nav({ class: 'gl-border-b-0', data: { testid: 'dashboard-activity-tabs' } }) do + = gl_tab_link_to _("Your projects"), activity_dashboard_path, { item_active: params[:filter].nil? } + = gl_tab_link_to _("Starred projects"), activity_dashboard_path(filter: 'starred') + = gl_tab_link_to _("Followed users"), activity_dashboard_path(filter: 'followed') diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml index b92f35c108c..7b1d25b9b43 100644 --- a/app/views/dashboard/_groups_head.html.haml +++ b/app/views/dashboard/_groups_head.html.haml @@ -6,13 +6,9 @@ = link_to _("New group"), new_group_path, class: "gl-button btn btn-confirm", data: { testid: "new-group-button" } .top-area - %ul.nav-links.mobile-separator.nav.nav-tabs - = nav_link(page: dashboard_groups_path) do - = link_to dashboard_groups_path, title: _("Your groups") do - Your groups - = nav_link(page: explore_groups_path) do - = link_to explore_groups_path, title: _("Explore public groups") do - Explore public groups + = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do + = gl_tab_link_to _("Your groups"), dashboard_groups_path + = gl_tab_link_to _("Explore public groups"), explore_groups_path .nav-controls = render 'shared/groups/search_form' = render 'shared/groups/dropdown' diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml index 037b2f247c1..9fb0fb734f9 100644 --- a/app/views/devise/confirmations/almost_there.haml +++ b/app/views/devise/confirmations/almost_there.haml @@ -1,16 +1,15 @@ - user_email = "(#{params[:email]})" if params[:email].present? +- request_link_start = '<a href="%{new_user_confirmation_path}">'.html_safe % { new_user_confirmation_path: new_user_confirmation_path } +- request_link_end = '</a>'.html_safe .well-confirmation.gl-text-center.gl-mb-6 %h1.gl-mt-0 = _("Almost there...") - %p.lead.gl-mb-6 + %p{ class: 'gl-mb-6 gl-font-lg!' } = _('Please check your email %{email} to confirm your account') % { email: user_email } %hr - if Gitlab::CurrentSettings.after_sign_up_text.present? .well-confirmation.gl-text-center = markdown_field(Gitlab::CurrentSettings, :after_sign_up_text) -%p.text-center - = _("No confirmation email received? Please check your spam folder or") -.gl-mb-6.prepend-top-20.gl-text-center - %a.gl-link{ href: new_user_confirmation_path } - = _("Request new confirmation email") +%p.gl-text-center + = _("No confirmation email received? Check your spam folder or %{request_link_start}request new confirmation email%{request_link_end}.").html_safe % { request_link_start: request_link_start, request_link_end: request_link_end } diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb index ab46aaaca1a..32e88047a9c 100644 --- a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb +++ b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb @@ -1,4 +1,4 @@ -<%= _(" %{name}, confirm your email address now! ") % { name: @resource.user.name } %> +<%= _("%{name}, confirm your email address now!") % { name: @resource.user.name } %> <%= _("Use the link below to confirm your email address (%{email})") % { email: @resource.email } %> diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 10c04423589..56bd30fac73 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -7,10 +7,10 @@ = f.hidden_field :reset_password_token .form-group = f.label _('New password'), for: "user_password" - = f.password_field :password, class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'} + = f.password_field :password, autocomplete: 'new-password', class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'} .form-group = f.label _('Confirm new password'), for: "user_password_confirmation" - = f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true + = f.password_field :password_confirmation, autocomplete: 'new-password', class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true .clearfix = f.submit _("Change your password"), class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' } diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 4ec3fcde337..87108c8ea78 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -2,6 +2,7 @@ - add_page_specific_style 'page_bundles/signup' - content_for :page_specific_javascripts do = render "layouts/google_tag_manager_head" + = render "layouts/one_trust" = render "layouts/google_tag_manager_body" .signup-page diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 82c0df354d4..b40373ecc37 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -4,12 +4,12 @@ = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' } .form-group = f.label :password, class: 'label-bold' - = f.password_field :password, class: 'form-control gl-form-input bottom', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } + = f.password_field :password, class: 'form-control gl-form-input bottom', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } - if devise_mapping.rememberable? - .remember-me + %div %label{ for: 'user_remember_me' } - = f.check_box :remember_me, class: 'remember-me-checkbox' - %span Remember me + = f.check_box :remember_me + %span= _('Remember me') .float-right - if unconfirmed_email? = link_to _('Resend confirmation email'), new_user_confirmation_path diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml index 769268748f4..fb4c011dd49 100644 --- a/app/views/devise/sessions/_new_crowd.html.haml +++ b/app/views/devise/sessions/_new_crowd.html.haml @@ -4,7 +4,7 @@ = text_field_tag :username, nil, { class: "form-control top", title: _("This field is required."), autofocus: "autofocus", required: true } .form-group = label_tag :password - = password_field_tag :password, nil, { class: "form-control bottom", title: _("This field is required."), required: true } + = password_field_tag :password, nil, { autocomplete: 'current-password', class: "form-control bottom", title: _("This field is required."), required: true } - if devise_mapping.rememberable? .remember-me %label{ for: "remember_me" } diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index f599a652b71..fea58779c17 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -8,7 +8,7 @@ = text_field_tag :username, nil, { class: "form-control gl-form-input top", title: _("This field is required."), autofocus: "autofocus", data: { qa_selector: 'username_field' }, required: true } .form-group = label_tag :password - = password_field_tag :password, nil, { class: "form-control gl-form-input bottom", title: _("This field is required."), data: { qa_selector: 'password_field' }, required: true } + = password_field_tag :password, nil, { autocomplete: 'current-password', class: "form-control gl-form-input bottom", title: _("This field is required."), data: { qa_selector: 'password_field' }, required: true } - if !hide_remember_me && devise_mapping.rememberable? .remember-me %label{ for: "remember_me" } diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index 74f3e3e7e34..da6232b2a2b 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -1,6 +1,7 @@ - page_title _("Sign in") - content_for :page_specific_javascripts do = render "layouts/google_tag_manager_head" + = render "layouts/one_trust" = render "layouts/google_tag_manager_body" #signin-container diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 1752a43b032..bd7fe41ae8d 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -1,20 +1,20 @@ - hide_remember_me = local_assigns.fetch(:hide_remember_me, false) .omniauth-container.gl-mt-5 - %label.label-bold.d-block + %label.gl-font-weight-bold = _('Sign in with') - providers = enabled_button_based_providers - .d-flex.justify-content-between.flex-wrap + .gl-display-flex.gl-justify-content-between.gl-flex-wrap - providers.each do |provider| - has_icon = provider_has_icon?(provider) - = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default omniauth-btn oauth-login #{qa_class_for_provider(provider)}", form: { class: 'gl-w-full' } do + = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-w-full js-oauth-login #{qa_class_for_provider(provider)}", form: { class: 'gl-w-full gl-mb-3' } do - if has_icon = provider_image_tag(provider) %span.gl-button-text = label_for_provider(provider) - unless hide_remember_me - %fieldset.remember-me + %fieldset %label - = check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox' + = check_box_tag :remember_me, nil, false %span = _('Remember me') diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index f9649875538..15143684b8b 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -53,6 +53,7 @@ = f.password_field :password, class: 'form-control gl-form-input bottom', data: { qa_selector: 'new_user_password_field' }, + autocomplete: 'new-password', required: true, pattern: ".{#{@minimum_password_length},}", title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index 43e0802ee2a..c24e8770f05 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -1,9 +1,9 @@ -%label.label-bold.d-block +%label.gl-font-weight-bold = _("Create an account using:") -.d-flex.justify-content-between.flex-wrap +.gl-display-flex.gl-justify-content-between.gl-flex-wrap - providers.each do |provider| - = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn gl-button btn-default gl-display-flex gl-align-items-center gl-text-left gl-mb-2 gl-p-2 omniauth-btn oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do + = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-3 js-oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do - if provider_has_icon?(provider) = provider_image_tag(provider) - %span.ml-2 + %span.gl-button-text = label_for_provider(provider) diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml index a653d44d694..30a54ab86a6 100644 --- a/app/views/devise/shared/_signup_omniauth_providers.haml +++ b/app/views/devise/shared/_signup_omniauth_providers.haml @@ -1,3 +1,3 @@ -.omniauth-divider.d-flex.align-items-center.text-center +.omniauth-divider.gl-display-flex.gl-align-items-center = _("or") = render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers diff --git a/app/views/devise/shared/_signup_omniauth_providers_top.haml b/app/views/devise/shared/_signup_omniauth_providers_top.haml index 9a2629443ed..8eb22c0b023 100644 --- a/app/views/devise/shared/_signup_omniauth_providers_top.haml +++ b/app/views/devise/shared/_signup_omniauth_providers_top.haml @@ -1,3 +1,3 @@ = render 'devise/shared/signup_omniauth_provider_list', providers: popular_enabled_button_based_providers -.omniauth-divider.d-flex.align-items-center.text-center +.omniauth-divider.gl-display-flex.gl-align-items-center = _("or") diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml index ea191449fe3..ab6861b5f24 100644 --- a/app/views/groups/_group_admin_settings.html.haml +++ b/app/views/groups/_group_admin_settings.html.haml @@ -36,4 +36,4 @@ .offset-sm-2.col-sm-10 .form-check = f.text_field :two_factor_grace_period, class: 'form-control' - .form-text.text-muted= _("Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication") + .form-text.text-muted= _("Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.") diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 0352f366f5d..e530d9c60b6 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -5,17 +5,19 @@ .group-home-panel .row.mb-3 .home-panel-title-row.col-md-12.col-lg-6.d-flex - .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none + .avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.float-none{ class: 'gl-mr-3!' } = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo') .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline - %h1.home-panel-title.gl-mt-3.gl-mb-2{ itemprop: 'name' } + %h1.home-panel-title.gl-mt-3.gl-mb-2.gl-ml-3{ itemprop: 'name' } = @group.name %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } = visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) - .home-panel-metadata.text-secondary - %span - = _("Group ID: %{group_id}") % { group_id: @group.id } + .home-panel-metadata.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal + - if can?(current_user, :read_group, @group) + - button_class = "btn gl-button btn-sm btn-tertiary btn-default-tertiary home-panel-metadata" + - button_text = s_('GroupPage|Group ID: %{group_id}') % { group_id: @group.id } + = clipboard_button(title: s_('GroupPage|Copy group ID'), text: @group.id, hide_button_icon: true, button_text: button_text, class: button_class, qa_selector: 'group_id_content', itemprop: 'identifier') - if current_user %span.gl-ml-3 = render 'shared/members/access_request_links', source: @group diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml index 2b9277c67e9..06a86c2465f 100644 --- a/app/views/groups/_import_group_from_another_instance_panel.html.haml +++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml @@ -1,16 +1,15 @@ = form_with url: configure_import_bulk_imports_path, class: 'group-form gl-show-field-errors' do |f| .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5 - %h4.gl-display-flex - = s_('GroupsNew|Import groups from another instance of GitLab') - %span.badge.badge-info.badge-pill.gl-badge.md.gl-ml-3 - = _('Beta') + .gl-display-flex.gl-align-items-center + %h4.gl-display-flex + = s_('GroupsNew|Import groups from another instance of GitLab') + = link_to _('History'), history_import_bulk_imports_path, class: 'gl-link gl-ml-auto' .gl-alert.gl-alert-warning{ role: 'alert' } = sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title') .gl-alert-body - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') } - - feedback_link_start = '<a href="https://gitlab.com/gitlab-org/gitlab/-/issues/284495" target="_blank" rel="noopener noreferrer">'.html_safe - - link_end = '</a>'.html_safe - = s_('GroupsNew|Not all related objects are migrated, as %{docs_link_start}described here%{docs_link_end}. Please %{feedback_link_start}leave feedback%{feedback_link_end} on this feature.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end, feedback_link_start: feedback_link_start, feedback_link_end: link_end } + - docs_link_end = '</a>'.html_safe + = s_('GroupsNew|Not all related objects are migrated. %{docs_link_start}More info%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end } %p.gl-mt-3 = s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.') .form-group.gl-display-flex.gl-flex-direction-column diff --git a/app/views/groups/dependency_proxies/_url.html.haml b/app/views/groups/dependency_proxies/_url.html.haml deleted file mode 100644 index 9a76da63a72..00000000000 --- a/app/views/groups/dependency_proxies/_url.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -- proxy_url = @group.dependency_proxy_image_prefix - -%h5.prepend-top-20= _('Dependency proxy image prefix') - -.row - .col-lg-8.col-md-12.input-group - = text_field_tag :url, "#{proxy_url}", class: 'js-dependency-proxy-url form-control', readonly: true - = clipboard_button(text: "#{proxy_url}", title: _("Copy %{proxy_url}") % { proxy_url: proxy_url }) - -.row - .col-12.help-block.gl-mt-3{ data: { qa_selector: 'dependency_proxy_count' } } - = _('Contains %{count} blobs of images (%{size})') % { count: @blobs_count, size: number_to_human_size(@blobs_total_size) } diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml index 177018af830..8936c4dcbb4 100644 --- a/app/views/groups/dependency_proxies/show.html.haml +++ b/app/views/groups/dependency_proxies/show.html.haml @@ -1,30 +1,5 @@ - page_title _("Dependency Proxy") +- dependency_proxy_available = Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true) || @group.public? -.settings-header - %h4= _('Dependency proxy') - - %p - - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/packages/dependency_proxy/index') } - = _('Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - -- if Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true) || @group.public? - - if can?(current_user, :admin_dependency_proxy, @group) - = form_for(@dependency_proxy, method: :put, url: group_dependency_proxy_path(@group)) do |f| - .form-group - %h5.prepend-top-20= _('Enable proxy') - .js-dependency-proxy-toggle-area - = render "shared/buttons/project_feature_toggle", is_checked: @dependency_proxy.enabled?, label: s_("DependencyProxy|Toggle Dependency Proxy"), data: { qa_selector: 'dependency_proxy_setting_toggle' } do - = f.hidden_field :enabled, { class: 'js-project-feature-toggle-input'} - - - if @dependency_proxy.enabled - = render 'groups/dependency_proxies/url' - - - else - - if @dependency_proxy.enabled - = render 'groups/dependency_proxies/url' -- else - .gl-alert.gl-alert-info - .gl-alert-container - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - = _('Dependency proxy feature is limited to public groups for now.') +#js-dependency-proxy{ data: { group_path: @group.full_path, + dependency_proxy_available: dependency_proxy_available.to_s } } diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 6e355d31204..420771257c9 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title _("General Settings") -- page_title _("General Settings") +- breadcrumb_title _("General settings") +- page_title _("General settings") - @content_class = "limit-container-width" unless fluid_layout - expanded = expanded_by_default? @@ -13,6 +13,7 @@ = _('Collapse') %p = _('Update your group name, description, avatar, and visibility.') + = link_to s_('Learn more about groups.'), help_page_path('user/group/index') .settings-content = render 'groups/settings/general' @@ -23,7 +24,7 @@ %button.btn.gl-button.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _('Advanced permissions, Large File Storage and Two-Factor authentication settings.') + = _('Configure advanced permissions, Large File Storage, and two-factor authentication settings.') .settings-content = render 'groups/settings/permissions' diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 1f746484b7d..0c6776a6038 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -6,7 +6,7 @@ = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues") - if Feature.enabled?(:vue_issues_list, @group, default_enabled: :yaml) - .js-issues-list{ data: group_issues_list_data(@group, current_user, @issues) } + .js-issues-list{ data: group_issues_list_data(@group, current_user, @issues, @projects) } - if @can_bulk_update = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues - else diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index fa6bd021e45..2901c8fa46b 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -16,7 +16,8 @@ is_group_page: "true", "group_path": @group.full_path, "gid_prefix": container_repository_gid_prefix, - character_error: @character_error.to_s, + connection_error: (!!@connection_error).to_s, + invalid_path_error: (!!@invalid_path_error).to_s, user_callouts_path: user_callouts_path, user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s } } diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml index 66ffef98553..a76701ea5d2 100644 --- a/app/views/groups/runners/_runner.html.haml +++ b/app/views/groups/runners/_runner.html.haml @@ -39,7 +39,7 @@ - if runner.group_type? = _('n/a') - else - = runner.projects.count(:all) + = runner.runner_projects.count(:all) .table-section.section-5 .table-mobile-header{ role: 'rowheader' }= _('Jobs') diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 7a2d5c91af6..ed76a9fe253 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -14,8 +14,9 @@ .row.gl-mt-3 .form-group.col-md-9 - = f.label :description, _('Group description (optional)'), class: 'label-bold' + = f.label :description, _('Group description'), class: 'label-bold' = f.text_area :description, class: 'form-control', rows: 3, maxlength: 250 + .form-text.text-muted= _('Optional.') = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group diff --git a/app/views/groups/settings/_lfs.html.haml b/app/views/groups/settings/_lfs.html.haml index 1255a2901ea..9f04b579a97 100644 --- a/app/views/groups/settings/_lfs.html.haml +++ b/app/views/groups/settings/_lfs.html.haml @@ -3,10 +3,10 @@ %h5= _('Large File Storage') -%p= s_('Check the %{docs_link_start}documentation%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } +%p= s_('%{docs_link_start}What is Large File Storage?%{docs_link_end}').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } .form-group.gl-mb-3 = f.gitlab_ui_checkbox_component :lfs_enabled, _('Allow projects within this group to use Git LFS'), - help_text: _('This setting can be overridden in each project.'), + help_text: _('Can be overridden in each project.'), checkbox_options: { checked: @group.lfs_enabled?, data: { qa_selector: 'lfs_checkbox' } } diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 8f428909e60..eb38aa43881 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -7,7 +7,7 @@ - if @group.root? .form-group.gl-mb-3 = f.gitlab_ui_checkbox_component :prevent_sharing_groups_outside_hierarchy, - s_('GroupSettings|Prevent members from sending invitations to groups outside of %{group} and its subgroups.').html_safe % { group: link_to_group(@group) }, + s_('GroupSettings|Prevent members from sending invitations to groups outside of %{group} and its subgroups').html_safe % { group: link_to_group(@group) }, help_text: prevent_sharing_groups_outside_hierarchy_help_text(@group), checkbox_options: { disabled: !can_change_prevent_sharing_groups_outside_hierarchy?(@group) } @@ -21,13 +21,13 @@ = f.gitlab_ui_checkbox_component :emails_disabled, s_('GroupSettings|Disable email notifications'), checkbox_options: { checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group) }, - help_text: s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.') + help_text: s_('GroupSettings|Overrides user notification preferences for all members of the group, subgroups, and projects.') .form-group.gl-mb-3 = f.gitlab_ui_checkbox_component :mentions_disabled, s_('GroupSettings|Disable group mentions'), checkbox_options: { checked: @group.mentions_disabled? }, - help_text: s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.') + help_text: s_('GroupSettings|Prevents group members from being notified if the group is mentioned.') = render 'groups/settings/project_access_token_creation', f: f, group: @group = render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group diff --git a/app/views/groups/settings/_two_factor_auth.html.haml b/app/views/groups/settings/_two_factor_auth.html.haml index 8204cafcb44..f86bcb24e63 100644 --- a/app/views/groups/settings/_two_factor_auth.html.haml +++ b/app/views/groups/settings/_two_factor_auth.html.haml @@ -4,16 +4,16 @@ %h5= _('Two-factor authentication') -%p= s_('Check the %{docs_link_start}documentation%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } +%p= s_('%{docs_link_start}What is two-factor authentication?%{docs_link_end}').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } .form-group = f.gitlab_ui_checkbox_component :require_two_factor_authentication, - _('Require all users in this group to setup two-factor authentication'), + _('Require all users in this group to set up two-factor authentication'), checkbox_options: { data: { qa_selector: 'require_2fa_checkbox' } } .form-group - = f.label :two_factor_grace_period, _('Time before enforced'), class: 'label-bold' - = f.text_field :two_factor_grace_period, class: 'form-control form-control-sm w-auto' - .form-text.text-muted= _('Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication') + = f.label :two_factor_grace_period, _('Time before enforced') + = f.text_field :two_factor_grace_period, class: 'form-control form-control-sm w-auto gl-form-input gl-mb-3' + .form-text.text-muted= _('Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.') - unless group.has_parent? .form-group = f.gitlab_ui_checkbox_component :allow_mfa_for_subgroups, diff --git a/app/views/groups/settings/packages_and_registries/show.html.haml b/app/views/groups/settings/packages_and_registries/show.html.haml index 1a12ad4902b..7be6dc73c49 100644 --- a/app/views/groups/settings/packages_and_registries/show.html.haml +++ b/app/views/groups/settings/packages_and_registries/show.html.haml @@ -1,5 +1,8 @@ - breadcrumb_title _('Packages & Registries') - page_title _('Packages & Registries') - @content_class = 'limit-container-width' unless fluid_layout +- dependency_proxy_available = Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true) || @group.public? -%section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s, group_path: @group.full_path } } +%section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s, + group_path: @group.full_path, + dependency_proxy_available: dependency_proxy_available.to_s } } diff --git a/app/views/help/instance_configuration/_gitlab_pages.html.haml b/app/views/help/instance_configuration/_gitlab_pages.html.haml index 51835c202d6..1d8958c93e8 100644 --- a/app/views/help/instance_configuration/_gitlab_pages.html.haml +++ b/app/views/help/instance_configuration/_gitlab_pages.html.haml @@ -7,7 +7,7 @@ = _('GitLab Pages') %p - - link_to_gitlab_pages = link_to(_('GitLab Pages'), gitlab_pages[:url], target: '_blank') + - link_to_gitlab_pages = link_to(_('GitLab Pages'), gitlab_pages[:url], target: '_blank', rel: 'noopener noreferrer') = _('Below are the settings for %{link_to_gitlab_pages}.').html_safe % { link_to_gitlab_pages: link_to_gitlab_pages } .table-responsive %table diff --git a/app/views/help/instance_configuration/_rate_limits.html.haml b/app/views/help/instance_configuration/_rate_limits.html.haml index d72bd845c5b..ed71b5a609c 100644 --- a/app/views/help/instance_configuration/_rate_limits.html.haml +++ b/app/views/help/instance_configuration/_rate_limits.html.haml @@ -24,6 +24,7 @@ = render 'help/instance_configuration/rate_limit_row', title: _('Protected Paths: requests'), rate_limit: rate_limits[:protected_paths] = render 'help/instance_configuration/rate_limit_row', title: _('Package Registry: unauthenticated API requests'), rate_limit: rate_limits[:unauthenticated_packages_api], public_visible: true = render 'help/instance_configuration/rate_limit_row', title: _('Package Registry: authenticated API requests'), rate_limit: rate_limits[:authenticated_packages_api] + = render 'help/instance_configuration/rate_limit_row', title: _('Authenticated Git LFS requests'), rate_limit: rate_limits[:authenticated_git_lfs_api] = render 'help/instance_configuration/rate_limit_row', title: _('Issue creation requests'), rate_limit: rate_limits[:issue_creation] = render 'help/instance_configuration/rate_limit_row', title: _('Note creation requests'), rate_limit: rate_limits[:note_creation] = render 'help/instance_configuration/rate_limit_row', title: _('Project export requests'), rate_limit: rate_limits[:project_export] diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index 02a8f3142c6..8f18d68fd55 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -6,7 +6,7 @@ - provider_title = Gitlab::ImportSources.title(provider) - header_title _("New project"), new_project_path -- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') #import-projects-mount-element{ data: { provider: provider, provider_title: provider_title, can_select_namespace: current_user.can_select_namespace?.to_s, diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml index ce6bdd7a2fb..721447186a6 100644 --- a/app/views/import/bitbucket_server/new.html.haml +++ b/app/views/import/bitbucket_server/new.html.haml @@ -1,6 +1,6 @@ - page_title _('Bitbucket Server Import') - header_title _("New project"), new_project_path -- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/import/bulk_imports/history.html.haml b/app/views/import/bulk_imports/history.html.haml new file mode 100644 index 00000000000..80eb0c7a764 --- /dev/null +++ b/app/views/import/bulk_imports/history.html.haml @@ -0,0 +1,6 @@ +- add_to_breadcrumbs _('New group'), new_group_path +- add_to_breadcrumbs _('Import group'), new_group_path(anchor: 'import-group-pane') +- add_page_specific_style 'page_bundles/import' +- page_title _('Import history') + +#import-history-mount-element diff --git a/app/views/import/fogbugz/new.html.haml b/app/views/import/fogbugz/new.html.haml index 51156797270..d716d08529c 100644 --- a/app/views/import/fogbugz/new.html.haml +++ b/app/views/import/fogbugz/new.html.haml @@ -1,6 +1,6 @@ - page_title _("FogBugz Import") - header_title _("New project"), new_project_path -- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 4281d77e833..93572e14a65 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -1,6 +1,6 @@ - page_title _('User map'), _('FogBugz import') - header_title _("New project"), new_project_path -- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml index 288ae5f1cec..de717ce87eb 100644 --- a/app/views/import/gitea/new.html.haml +++ b/app/views/import/gitea/new.html.haml @@ -1,6 +1,6 @@ - page_title _("Gitea Import") - header_title _("New project"), new_project_path -- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') %h3.page-title = custom_icon('gitea_logo') diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 3407f9202bf..3f7f929f766 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -1,7 +1,7 @@ - title = _('Authenticate with GitHub') - page_title title - header_title _("New project"), new_project_path -- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') %h3.page-title = title diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index 028268482cd..533d0d13be3 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -1,6 +1,6 @@ - page_title _("GitLab Import") - header_title _("New project"), new_project_path -- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/import/manifest/new.html.haml b/app/views/import/manifest/new.html.haml index bfaff3bb300..a949e14e273 100644 --- a/app/views/import/manifest/new.html.haml +++ b/app/views/import/manifest/new.html.haml @@ -1,6 +1,6 @@ - page_title _("Manifest file import") - header_title _("New project"), new_project_path -- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') %h3.page-title diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml index 9596fdb615a..0249dc446e4 100644 --- a/app/views/import/phabricator/new.html.haml +++ b/app/views/import/phabricator/new.html.haml @@ -1,6 +1,6 @@ - page_title _('Phabricator Server Import') - header_title _("New project"), new_project_path -- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/layouts/_one_trust.html.haml b/app/views/layouts/_one_trust.html.haml new file mode 100644 index 00000000000..4fab017d273 --- /dev/null +++ b/app/views/layouts/_one_trust.html.haml @@ -0,0 +1,16 @@ +- if one_trust_enabled? + - one_trust_id = sanitize(extra_config.one_trust_id, scrubber: Rails::Html::TextOnlyScrubber.new) + + <!-- OneTrust --> + = javascript_include_tag "https://cdn.cookielaw.org/consent/#{one_trust_id}/OtAutoBlock.js" + = javascript_tag nonce: content_security_policy_nonce do + :plain + const oneTrustScript = document.createElement('script'); + oneTrustScript.src = 'https://cdn.cookielaw.org/scripttemplates/otSDKStub.js'; + oneTrustScript.dataset.domainScript = '#{one_trust_id}'; + oneTrustScript.nonce = '#{content_security_policy_nonce}' + oneTrustScript.charset = 'UTF-8'; + oneTrustScript.defer = true; + document.head.appendChild(oneTrustScript); + + function OptanonWrapper() { } diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index ec2904245d3..dff1b5e3d04 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -17,6 +17,7 @@ = render_two_factor_auth_recovery_settings_check = render_if_exists "layouts/header/ee_subscribable_banner" = render_if_exists "shared/namespace_storage_limit_alert" + = render_if_exists "shared/namespace_user_cap_reached_alert" = render_if_exists "shared/new_user_signups_cap_reached_alert" = yield :page_level_alert = yield :customize_homepage_banner diff --git a/app/views/layouts/_startup_js.html.haml b/app/views/layouts/_startup_js.html.haml index 35cd191c600..0bf9c16b0d2 100644 --- a/app/views/layouts/_startup_js.html.haml +++ b/app/views/layouts/_startup_js.html.haml @@ -8,20 +8,30 @@ if (gl.startup_calls && window.fetch) { Object.keys(gl.startup_calls).forEach(apiCall => { - // fetch won’t send cookies in older browsers, unless you set the credentials init option. - // We set to `same-origin` which is default value in modern browsers. - // See https://github.com/whatwg/fetch/pull/585 for more information. - gl.startup_calls[apiCall] = { - fetchCall: fetch(apiCall, { credentials: 'same-origin' }) + gl.startup_calls[apiCall] = { + fetchCall: fetch(apiCall, { + // Emulate XHR for Rails AJAX request checks + headers: { + 'X-Requested-With': 'XMLHttpRequest' + }, + // fetch won’t send cookies in older browsers, unless you set the credentials init option. + // We set to `same-origin` which is default value in modern browsers. + // See https://github.com/whatwg/fetch/pull/585 for more information. + credentials: 'same-origin' + }) }; }); } if (gl.startup_graphql_calls && window.fetch) { + const headers = #{page_startup_graphql_headers.to_json}; const url = `#{api_graphql_url}` const opts = { method: "POST", - headers: { "Content-Type": "application/json", 'X-CSRF-Token': "#{form_authenticity_token}" }, + headers: { + "Content-Type": "application/json", + ...headers, + } }; gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({ diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 3e7155b2c0e..8d28823bfa4 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -34,7 +34,8 @@ #js-header-search.header-search{ data: { 'search-context' => search_context.to_json, 'search-path' => search_path, 'issues-path' => issues_dashboard_path, - 'mr-path' => merge_requests_dashboard_path } } + 'mr-path' => merge_requests_dashboard_path, + 'autocomplete-path' => search_autocomplete_path } } %input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input' } - else = render 'layouts/search' diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml index c111714f552..02a37dac158 100644 --- a/app/views/layouts/nav/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/_breadcrumbs.html.haml @@ -19,8 +19,9 @@ = render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :after - unless @skip_current_level_breadcrumb %li - %h2.breadcrumbs-sub-title + %h2.breadcrumbs-sub-title{ data: { qa_selector: 'breadcrumb_sub_title_content' } } = link_to @breadcrumb_title, breadcrumb_title_link + -# haml-lint:disable InlineJavaScript %script{ type: 'application/ld+json' } :plain #{schema_breadcrumb_json} diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index d0b73a3364a..842fb23d24a 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -1,13 +1,13 @@ %aside.nav-sidebar.qa-admin-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation') } .nav-sidebar-inner-scroll .context-header - = link_to admin_root_path, title: _('Admin Overview') do + = link_to admin_root_path, title: _('Admin Overview'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do %span{ class: ['avatar-container', 'settings-avatar', 'rect-avatar', 's32'] } = sprite_icon('admin', size: 18) %span.sidebar-context-title = _('Admin Area') %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } } - = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts), html_options: {class: 'home'}) do + = nav_link(controller: %w(dashboard admin admin/projects users groups admin/topics jobs runners gitaly_servers cohorts), html_options: {class: 'home'}) do = link_to admin_root_path, class: 'has-sub-items' do .nav-icon-container = sprite_icon('overview') @@ -35,6 +35,10 @@ = link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'groups_overview_link' } do %span = _('Groups') + = nav_link(controller: [:admin, 'admin/topics']) do + = link_to admin_topics_path, title: _('Topics'), data: { qa_selector: 'topics_overview_link' } do + %span + = _('Topics') = nav_link path: 'jobs#index' do = link_to admin_jobs_path, title: _('Jobs') do %span @@ -257,11 +261,6 @@ = link_to ci_cd_admin_application_settings_path, title: _('CI/CD') do %span = _('CI/CD') - - if Feature.enabled?(:serverless_domain) - = nav_link(path: 'application_settings#operations') do - = link_to admin_serverless_domains_path, title: _('Operations') do - %span - = _('Operations') = nav_link(path: 'application_settings#reporting') do = link_to reporting_admin_application_settings_path, title: _('Reporting') do %span diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index 4db1e532ba5..16c0c00ad3f 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -1,7 +1,7 @@ %aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(current_user), 'aria-label': _('User settings') } .nav-sidebar-inner-scroll .context-header - = link_to profile_path, title: _('Profile Settings') do + = link_to profile_path, title: _('Profile Settings'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do %span{ class: ['avatar-container', 'settings-avatar', 's32'] } = image_tag avatar_icon_for_user(current_user, 32), class: ['avatar', 'avatar-tile', 'js-sidebar-user-avatar', 's32'], alt: current_user.name, data: { testid: 'sidebar-user-avatar' } %span.sidebar-context-title= _('User Settings') diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml index bc678c2c429..1057e96f442 100644 --- a/app/views/profiles/_email_settings.html.haml +++ b/app/views/profiles/_email_settings.html.haml @@ -3,8 +3,11 @@ - email_change_disabled = local_assigns.fetch(:email_change_disabled, nil) - read_only_help_text = readonly ? s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } : user_email_help_text(@user) - help_text = email_change_disabled ? s_("Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO.") % { group_name: @user.managing_group.name } : read_only_help_text +- password_automatically_set = @user.password_automatically_set? = form.text_field :email, required: true, class: 'input-lg gl-form-input', value: (@user.email unless @user.temp_oauth_email?), help: help_text.html_safe, readonly: readonly || email_change_disabled +- unless password_automatically_set + = hidden_field_tag 'user[validation_password]', :validation_password, class: 'js-password-prompt-field', help: s_("Profiles|Enter your password to confirm the email change") = form.select :public_email, options_for_select(@user.public_verified_emails, selected: @user.public_email), { help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") }, control_class: 'select2 input-lg', disabled: email_change_disabled diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 2cc919fc70e..5d3e0720176 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -19,16 +19,16 @@ - unless @user.password_automatically_set? .form-group - = f.label :current_password, _('Current password'), class: 'label-bold' - = f.password_field :current_password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } + = f.label :password, _('Current password'), class: 'label-bold' + = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } %p.form-text.text-muted = _('You must provide your current password in order to change it.') .form-group - = f.label :password, _('New password'), class: 'label-bold' - = f.password_field :password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' } + = f.label :new_password, _('New password'), class: 'label-bold' + = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' } .form-group = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold' - = f.password_field :password_confirmation, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' } + = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' } .gl-mt-3.gl-mb-3 = f.submit _('Save password'), class: "gl-button btn btn-confirm gl-mr-3", data: { qa_selector: 'save_password_button' } - unless @user.password_automatically_set? diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml index 7780ffe0cb4..9154c94abb6 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/profiles/passwords/new.html.haml @@ -14,18 +14,18 @@ - unless @user.password_automatically_set? .form-group.row .col-sm-2.col-form-label - = f.label :current_password, _('Current password') + = f.label :password, _('Current password') .col-sm-10 - = f.password_field :current_password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } + = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } .form-group.row .col-sm-2.col-form-label - = f.label :password, _('New password') + = f.label :new_password, _('New password') .col-sm-10 - = f.password_field :password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' } + = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' } .form-group.row .col-sm-2.col-form-label = f.label :password_confirmation, _('Password confirmation') .col-sm-10 - = f.password_field :password_confirmation, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' } + = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' } .form-actions = f.submit _('Set new password'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'set_new_password_button' } diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 0bb4859dd1e..3e41f107e04 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -5,7 +5,7 @@ - availability = availability_values - custom_emoji = show_status_emoji?(@user.status) -= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f| += bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| = form_errors(@user) .row.js-search-settings-section @@ -80,7 +80,7 @@ %p= s_("Profiles|Set your local time zone") .col-lg-8 %h5= _("Time zone") - = dropdown_tag(_("Select a time zone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg', title: _("Select a time zone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } ) + = dropdown_tag(_("Select a time zone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg gl-w-full!', title: _("Select a time zone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } ) %input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone } .col-lg-12 %hr @@ -124,9 +124,11 @@ .help-block = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information") %hr - = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3' + = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3 js-password-prompt-btn' = link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-default btn-cancel' +#password-prompt-modal + .modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } } .modal-dialog .modal-content diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index bd3cb7e60f0..00df8608957 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -50,7 +50,7 @@ - if current_password_required? .form-group = label_tag :current_password, _('Current password'), class: 'label-bold' - = password_field_tag :current_password, nil, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } + = password_field_tag :current_password, nil, autocomplete: 'current-password', required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } %p.form-text.text-muted = _('Your current password is required to register a two-factor authenticator app.') .gl-mt-3 diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 597a22bf34a..cdcc98552f9 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -20,5 +20,6 @@ = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true #js-tree-list{ data: vue_file_list_data(project, ref) } - - if can_edit_tree? + - if !Feature.enabled?(:new_dir_modal, default_enabled: :yaml) && can_edit_tree? = render 'projects/blob/new_dir' + diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index f2cee618849..1f2c16324fb 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -45,11 +45,10 @@ - if can?(current_user, :download_code, @project) = cache_if(cache_enabled, [@project, :download_code], expires_in: 1.minute) do %nav.project-stats - .nav-links.quick-links - - if @project.empty_repo? - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors - - else - = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) + - if @project.empty_repo? + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors + - else + = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) .home-panel-home-desc.mt-1 - if @project.description.present? diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index 815a3cf6966..81d9726fcdc 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -83,7 +83,7 @@ .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } - = form_for @project, html: { class: 'new_project' } do |f| + = form_for @project, html: { class: 'new_project gl-show-field-errors' } do |f| %hr = render "shared/import_form", f: f = render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index fb7a7ef8985..256c3ebad0a 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -16,7 +16,12 @@ - if current_user.can_select_namespace? - namespace_id = namespace_id_from(params) - if Feature.enabled?(:paginatable_namespace_drop_down_for_project_creation, current_user, default_enabled: :yaml) - .js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path, namespace_id: namespace_id, root_url: root_url, track_label: track_label } } + .js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path, + namespace_id: namespace_id, + root_url: root_url, + track_label: track_label, + user_namespace_full_path: current_user.namespace.full_path, + user_namespace_id: current_user.namespace.id } } - else .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url } .input-group-text @@ -53,15 +58,36 @@ = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false - if !hide_init_with_readme - .form-group.row.initialize-with-readme-setting - %div{ :class => "col-sm-12" } - .form-check - = check_box_tag 'project[initialize_with_readme]', '1', true, class: 'form-check-input', data: { qa_selector: "initialize_with_readme_checkbox", track_label: "#{track_label}", track_action: "activate_form_input", track_property: "init_with_readme", track_value: "" } - = label_tag 'project[initialize_with_readme]', class: 'form-check-label' do - .option-title - %strong= s_('ProjectsNew|Initialize repository with a README') - .option-description - = s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.') + = f.label :project_configuration, class: 'label-bold' do + = s_('ProjectsNew|Project Configuration') + + .form-group + .form-check.gl-mb-3 + = check_box_tag 'project[initialize_with_readme]', '1', true, class: 'form-check-input', data: { qa_selector: 'initialize_with_readme_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_readme' } + = label_tag 'project[initialize_with_readme]', s_('ProjectsNew|Initialize repository with a README'), class: 'form-check-label' + .form-text.text-muted + = s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.') + + - experiment(:new_project_sast_enabled, user: current_user) do |e| + - e.try do + .form-group + .form-check.gl-mb-3 + = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } + = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do + = s_('ProjectsNew|Enable Static Application Security Testing (SAST)') + .form-text.text-muted + = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.') + = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name } + - e.try(:free_indicator) do + .form-group + .form-check.gl-mb-3 + = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } + = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do + = s_('ProjectsNew|Enable Static Application Security Testing (SAST)') + %span.badge.badge-info.badge-pill.gl-badge.sm= _('Free') + .form-text.text-muted + = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.') + = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name } = f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" } = link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" } diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 66e9badbafb..168b240c657 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -18,3 +18,4 @@ = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put = render partial: 'pipeline_tour_success' if show_suggest_pipeline_creation_celebration? += render 'shared/web_ide_path' diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index b1d465d0e75..6733db69c34 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -1,19 +1,12 @@ - page_title _('Branches') - add_to_breadcrumbs(_('Repository'), project_tree_path(@project)) -.top-area.adjust - %ul.nav-links.issues-state-filters.nav.nav-tabs - %li{ class: active_when(@mode == 'overview') }> - = link_to s_('Branches|Overview'), project_branches_path(@project), title: s_('Branches|Show overview of the branches') - - %li{ class: active_when(@mode == 'active') }> - = link_to s_('Branches|Active'), project_branches_filtered_path(@project, state: 'active'), title: s_('Branches|Show active branches') - - %li{ class: active_when(@mode == 'stale') }> - = link_to s_('Branches|Stale'), project_branches_filtered_path(@project, state: 'stale'), title: s_('Branches|Show stale branches') - - %li{ class: active_when(!%w[overview active stale].include?(@mode)) }> - = link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), title: s_('Branches|Show all branches') +.top-area.gl-border-0 + = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-b-0' }) do + = gl_tab_link_to s_('Branches|Overview'), project_branches_path(@project), { item_active: @mode == 'overview', title: s_('Branches|Show overview of the branches') } + = gl_tab_link_to s_('Branches|Active'), project_branches_filtered_path(@project, state: 'active'), { title: s_('Branches|Show active branches') } + = gl_tab_link_to s_('Branches|Stale'), project_branches_filtered_path(@project, state: 'stale'), { title: s_('Branches|Show stale branches') } + = gl_tab_link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), { item_active: !%w[overview active stale].include?(@mode), title: s_('Branches|Show all branches') } .nav-controls #js-branches-sort-dropdown{ data: { project_branches_filtered_path: project_branches_path(@project, state: 'all'), sort_options: branches_sort_options_hash.to_json, mode: @mode } } @@ -38,7 +31,10 @@ %h5 = s_('Branches|Protected branches can be managed in %{project_settings_link}.').html_safe % { project_settings_link: project_settings_link } -- if @mode == 'overview' && (@active_branches.any? || @stale_branches.any?) +- if @gitaly_unavailable + = render 'shared/errors/gitaly_unavailable', reason: s_('Branches|Unable to load branches') + +- elsif @mode == 'overview' && (@active_branches.any? || @stale_branches.any?) = render "projects/branches/panel", branches: @active_branches, state: 'active', panel_title: s_('Branches|Active branches'), show_more_text: s_('Branches|Show more active branches'), project: @project, overview_max_branches: @overview_max_branches = render "projects/branches/panel", branches: @stale_branches, state: 'stale', panel_title: s_('Branches|Stale branches'), show_more_text: s_('Branches|Show more stale branches'), project: @project, overview_max_branches: @overview_max_branches diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index 27858932e5e..8ee7910de4b 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -31,4 +31,5 @@ .form-actions = button_tag 'Create branch', class: 'gl-button btn btn-confirm' = link_to _('Cancel'), project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel' +-# haml-lint:disable InlineJavaScript %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml index 674765e9f89..ce6f7553ab4 100644 --- a/app/views/projects/ci/pipeline_editor/show.html.haml +++ b/app/views/projects/ci/pipeline_editor/show.html.haml @@ -1,3 +1,5 @@ +- add_page_specific_style 'page_bundles/pipelines' + - page_title s_('Pipelines|Pipeline Editor') - content_for :prefetch_asset_tags do - webpack_preload_asset_tag('monaco') diff --git a/app/views/projects/cluster_agents/show.html.haml b/app/views/projects/cluster_agents/show.html.haml new file mode 100644 index 00000000000..a2d3426d99c --- /dev/null +++ b/app/views/projects/cluster_agents/show.html.haml @@ -0,0 +1,4 @@ +- add_to_breadcrumbs _('Kubernetes'), project_clusters_path(@project) +- page_title @agent_name + +#js-cluster-agent-details{ data: js_cluster_agent_details_data(@agent_name, @project) } diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index bc0d14743b9..62ed50f5a0c 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -39,8 +39,14 @@ .committer - commit_author_link = commit_author_link(commit, avatar: false, size: 24) - - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom') - - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } + - commit_authored_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom') + - if commit.different_committer? && commit.committer + - commit_committer_link = commit_committer_link(commit) + - commit_committer_timeago = time_ago_with_tooltip(commit.committed_date, placement: 'bottom') + - commit_committer_avatar = commit_committer_avatar(commit.committer, size: 18, has_tooltip: false) + - commit_text = _('%{commit_author_link} authored %{commit_authored_timeago} and %{commit_committer_avatar} %{commit_committer_link} committed %{commit_committer_timeago}') % { commit_author_link: commit_author_link, commit_authored_timeago: commit_authored_timeago, commit_committer_avatar: commit_committer_avatar, commit_committer_link: commit_committer_link, commit_committer_timeago: commit_committer_timeago } + - else + - commit_text = _('%{commit_author_link} authored %{commit_authored_timeago}') % { commit_author_link: commit_author_link, commit_authored_timeago: commit_authored_timeago } #{ commit_text.html_safe } = render_if_exists 'projects/commits/project_namespace', show_project_name: show_project_name, project: project diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index 8270477ed3f..57dfcb8cf4a 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -27,7 +27,7 @@ = link_to deployment_path(deployment), class: 'build-link' do #{deployment.deployable.name} (##{deployment.deployable.id}) - else - .badge.badge-info.suggestion-help-hover{ title: s_('Deployment|This deployment was created using the API') } + .badge.badge-info.gl-cursor-help{ title: s_('Deployment|This deployment was created using the API') } = s_('Deployment|API') .table-section.section-10{ role: 'gridcell' } diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml index c1c9f58265d..ac8c0575077 100644 --- a/app/views/projects/feature_flags/edit.html.haml +++ b/app/views/projects/feature_flags/edit.html.haml @@ -4,10 +4,4 @@ - breadcrumb_title @feature_flag.name - page_title s_('FeatureFlags|Edit Feature Flag') -#js-edit-feature-flag{ data: { endpoint: project_feature_flag_path(@project, @feature_flag), - project_id: @project.id, - feature_flags_path: project_feature_flags_path(@project), - environments_endpoint: search_project_environments_path(@project, format: :json), - strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'), - environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs'), - feature_flag_issues_endpoint: feature_flag_issues_links_endpoint(@project, @feature_flag, current_user) } } +#js-edit-feature-flag{ data: edit_feature_flag_data } diff --git a/app/views/projects/google_cloud/index.html.haml b/app/views/projects/google_cloud/index.html.haml new file mode 100644 index 00000000000..4fc66e17810 --- /dev/null +++ b/app/views/projects/google_cloud/index.html.haml @@ -0,0 +1,83 @@ +- breadcrumb_title _('Google Cloud') +- page_title _('Google Cloud') + +- @content_class = "limit-container-width" unless fluid_layout + +#js-google-cloud + + %h1.gl-font-size-h1 Google Cloud + + %section#js-section-google-cloud-service-accounts + + %h2.gl-font-size-h2 Service Accounts + + %p= _('Service Accounts keys are required to authorize GitLab to deploy your Google Cloud project.') + + %table.table.b-table.gl-table + + %thead + %tr + %th Environment + %th GCP Project ID + %th Service Account Key + + %tbody + + %tr + %td * + %td serving-salutes-453 + %td ..... + + %tr + %td production + %td crimson-corey-234 + %td ..... + + %tr + %td review/* + %td roving-river-379 + %td ..... + + %a.gl-button.btn.btn-primary= _('Add new service account') + + %br + + %section#js-section-google-cloud-deployments + + .row.row-fluid + + .col-lg-4 + %h2.gl-font-size-h2 Deployments + %p= _('Google Cloud offers several deployment targets. Select the one most suitable for your project.') + %p + = _('Deployments to Google Kubernetes Engine can be ') + %a{ href: '#' }= _('managed') + = _('in Infrastructure :: Kubernetes clusters') + + .col-lg-8 + + %br + + .gl-card.gl-mb-6 + .gl-card-body + .gl-display-flex.gl-align-items-baseline + %strong.gl-font-lg App Engine + .gl-ml-auto.gl-text-gray-500 Disabled + %p= _('App Engine description and apps that are suitable for this deployment target') + %button.gl-button.btn.btn-default= _('Configure via Merge Request') + + .gl-card.gl-mb-6 + .gl-card-body + .gl-display-flex.gl-align-items-baseline + %strong.gl-font-lg Cloud Functions + .gl-ml-auto.gl-text-gray-500 Disabled + %p= _('Cloud Functions description and apps that are suitable for this deployment target') + %button.gl-button.btn.btn-default= _('Configure via Merge Request') + + .gl-card.gl-mb-6 + .gl-card-body + .gl-display-flex.gl-align-items-baseline + %strong.gl-font-lg Cloud Run + .gl-ml-auto.gl-text-gray-500 Disabled + %p= _('Cloud Run description and apps that are suitable for this deployment target') + %button.gl-button.btn.btn-default= _('Configure via Merge Request') diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml index ee4dbf5c05c..6a46b0b3510 100644 --- a/app/views/projects/hook_logs/_index.html.haml +++ b/app/views/projects/hook_logs/_index.html.haml @@ -1,37 +1,11 @@ -.row.gl-mt-7.gl-mb-3 +- docs_link_url = help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks') +- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url } +- link_end = '</a>'.html_safe + +.row.gl-mt-3.gl-mb-3 .col-lg-3 %h4.gl-mt-0 - Recent Deliveries - %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong. + = _('Recent events') + %p= _('GitLab events trigger webhooks. Use the request details of a webhook to help troubleshoot problems. %{link_start}How do I troubleshoot?%{link_end}').html_safe % { link_start: link_start, link_end: link_end } .col-lg-9 - - if hook_logs.present? - %table.table - %thead - %tr - %th Status - %th Trigger - %th URL - %th Elapsed time - %th Request time - %th - - hook_logs.each do |hook_log| - %tr - %td - = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } - %td.d-none.d-sm-block - %span.badge.badge-gray.deploy-project-label - = hook_log.trigger.singularize.titleize - %td - = truncate(hook_log.url, length: 50) - %td.light - #{number_with_precision(hook_log.execution_duration, precision: 2)} sec - %td.light - = time_ago_with_tooltip(hook_log.created_at) - %td - = link_to 'View details', hook_log.present.details_path - - = paginate hook_logs, theme: 'gitlab' - - - else - .settings-message.text-center - You don't have any webhooks deliveries + = render partial: 'shared/hook_logs/recent_deliveries_table', locals: { hook: hook, hook_logs: hook_logs } diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml index ebe179c3454..86dfa1929d6 100644 --- a/app/views/projects/hook_logs/show.html.haml +++ b/app/views/projects/hook_logs/show.html.haml @@ -5,8 +5,8 @@ .row.gl-mt-3.gl-mb-3 .col-lg-3 %h4.gl-mt-0 - Request details + = _("Request details") .col-lg-9 - = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn gl-button btn-default float-right gl-ml-3" + = link_to _('Resend Request'), @hook_log.present.retry_path, method: :post, class: "btn gl-button btn-default float-right gl-ml-3" = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml index 0d69f6f69aa..8d16c3d978f 100644 --- a/app/views/projects/issues/_nav_btns.html.haml +++ b/app/views/projects/issues/_nav_btns.html.haml @@ -5,11 +5,11 @@ - can_edit = can?(current_user, :admin_project, @project) - notification_email = @current_user.present? ? @current_user.notification_email_or_default : nil -.nav-controls.issues-nav-controls +.nav-controls.issues-nav-controls.gl-font-size-0 - if show_feed_buttons = render 'shared/issuable/feed_buttons' - .js-csv-import-export-buttons{ data: { show_export_button: show_export_button.to_s, show_import_button: show_import_button.to_s, issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_issues_path(@project, request.query_parameters), import_csv_issues_path: import_csv_namespace_project_issues_path, container_class: 'gl-mr-3', can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project) } } + .js-csv-import-export-buttons{ data: { show_export_button: show_export_button.to_s, show_import_button: show_import_button.to_s, issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_issues_path(@project, request.query_parameters), import_csv_issues_path: import_csv_namespace_project_issues_path, container_class: 'gl-mr-3', can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } } - if @can_bulk_update = button_tag _("Edit issues"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle" @@ -18,4 +18,3 @@ issue: { milestone_id: finder.milestones.first.try(:id) }), class: "gl-button btn btn-confirm", id: "new_issue_link" - diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 0604e89be6e..c47257eec4a 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -1,9 +1,9 @@ - if @related_branches.any? - %h2.related-branches-title + %h2.gl-font-lg = pluralize(@related_branches.size, 'Related Branch') %ul.unstyled-list.related-merge-requests - @related_branches.each do |branch| - %li + %li.gl-display-flex.gl-align-items-center - if branch[:pipeline_status].present? %span.related-branch-ci-status = render 'ci/status/icon', status: branch[:pipeline_status] diff --git a/app/views/projects/issues/_related_issues.html.haml b/app/views/projects/issues/_related_issues.html.haml index d131d20f079..bab37609c20 100644 --- a/app/views/projects/issues/_related_issues.html.haml +++ b/app/views/projects/issues/_related_issues.html.haml @@ -3,4 +3,3 @@ can_add_related_issues: "#{can?(current_user, :admin_issue_link, @issue)}", help_path: help_page_path('user/project/issues/related_issues'), show_categorized_issues: "false" } } - - render('projects/issues/related_issues_block') diff --git a/app/views/projects/issues/_related_issues_block.html.haml b/app/views/projects/issues/_related_issues_block.html.haml deleted file mode 100644 index 8d986b64b1d..00000000000 --- a/app/views/projects/issues/_related_issues_block.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -.related-issues-block - .card.card-slim - .card-header.panel-empty-heading.border-bottom-0 - %h3.card-title.mt-0.mb-0.h5 - = _('Linked issues') diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 5a983fb5565..0e8de3c2bb8 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -32,20 +32,20 @@ .dropdown-menu.dropdown-menu-right %ul - if can_update_merge_request - %li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + %li= link_to _('Edit'), edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - if @merge_request.opened? %li = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_merge_request_path(@merge_request), method: :put, class: "js-draft-toggle-button" %li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] } - = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request' + = link_to _('Close'), merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request' - if can_reopen_merge_request %li{ class: merge_request_button_visibility(@merge_request, false) } - = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, title: 'Reopen merge request' + = link_to _('Reopen'), merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, title: 'Reopen merge request' - unless @merge_request.merged? || current_user == @merge_request.author - %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) + %li= link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) - if can_update_merge_request - = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" } + = link_to _('Edit'), edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" } - if can_update_merge_request && !are_close_and_open_buttons_hidden = render 'projects/merge_requests/close_reopen_draft_report_toggle' diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml index b34cf23634c..00d12423eb9 100644 --- a/app/views/projects/merge_requests/_nav_btns.html.haml +++ b/app/views/projects/merge_requests/_nav_btns.html.haml @@ -5,7 +5,7 @@ .js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters), container_class: 'gl-mr-3' } } - if @can_bulk_update - = button_tag "Edit merge requests", class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle" + = button_tag _("Edit merge requests"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle" - if merge_project - = link_to new_merge_request_path, class: "gl-button btn btn-confirm", title: "New merge request" do - New merge request + = link_to new_merge_request_path, class: "gl-button btn btn-confirm", title: _("New merge request") do + = _('New merge request') diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 47a0d05fc65..459742c3b81 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -19,6 +19,6 @@ window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}'; window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}'; window.gl.mrWidgetData.false_positive_doc_url = '#{help_page_path('user/application_security/vulnerabilities/index')}'; - window.gl.mrWidgetData.can_view_false_positive = '#{(Feature.enabled?(:vulnerability_flags, default_enabled: :yaml) && @merge_request.project.licensed_feature_available?(:sast_fp_reduction)).to_s}'; + window.gl.mrWidgetData.can_view_false_positive = '#{@merge_request.project.licensed_feature_available?(:sast_fp_reduction).to_s}'; #js-vue-mr-widget.mr-widget diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index ee296258d04..5ba42ca7610 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -6,7 +6,7 @@ .merge-request-details.issuable-details = render "projects/merge_requests/mr_box" -= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, source_branch: @merge_request.source_branch += render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch #conflicts{ data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json), resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request), diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 7e260a03c5d..2154ef6b596 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -49,6 +49,7 @@ = render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do .row %section.col-md-12 + -# haml-lint:disable InlineJavaScript %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe .issuable-discussion.js-vue-notes-event - if @merge_request.description.present? @@ -98,3 +99,4 @@ = render 'projects/invite_members_modal', project: @project - if Gitlab::CurrentSettings.gitpod_enabled && !current_user&.gitpod_enabled = render 'shared/gitpod/enable_gitpod_modal' += render 'shared/web_ide_path' diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index b89aa9d402e..d253ab8e32b 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -10,7 +10,7 @@ = expanded ? _('Collapse') : _('Expand') %p = _('Set up your project to automatically push and/or pull changes to/from another repository. Branches, tags, and commits will be synced automatically.') - = link_to _('How do I mirror repositories?'), help_page_path('user/project/repository/repository_mirroring'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('How do I mirror repositories?'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content - if mirror_settings_enabled @@ -32,7 +32,7 @@ = label_tag :only_protected_branches, _('Mirror only protected branches'), class: 'form-check-label' .form-text.text-muted = _('If enabled, only protected branches will be mirrored.') - = link_to _('Learn more.'), help_page_path('user/project/repository/repository_mirroring', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer' .panel-footer = f.submit _('Mirror repository'), class: 'gl-button btn btn-confirm js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml index b3e0f71bf19..339c5d82919 100644 --- a/app/views/projects/mirrors/_mirror_repos_push.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml @@ -12,4 +12,4 @@ = label_tag :keep_divergent_refs, _('Keep divergent refs'), class: 'form-check-label' .form-text.text-muted - link_opening_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe - = html_escape(_('Do not force push over diverged refs. After the mirror is created, this setting can only be modified using the API. %{mirroring_docs_link_start}Learn more about this option%{link_closing_tag} and %{mirroring_api_docs_link_start}the API.%{link_closing_tag}')) % { mirroring_docs_link_start: link_opening_tag % {url: help_page_path('user/project/repository/repository_mirroring', anchor: 'keep-divergent-refs')}, mirroring_api_docs_link_start: link_opening_tag % {url: help_page_path('api/remote_mirrors')}, link_closing_tag: '</a>'.html_safe } + = html_escape(_('Do not force push over diverged refs. After the mirror is created, this setting can only be modified using the API. %{mirroring_docs_link_start}Learn more about this option%{link_closing_tag} and %{mirroring_api_docs_link_start}the API.%{link_closing_tag}')) % { mirroring_docs_link_start: link_opening_tag % {url: help_page_path('user/project/repository/mirror/push.md', anchor: 'keep-divergent-refs')}, mirroring_api_docs_link_start: link_opening_tag % {url: help_page_path('api/remote_mirrors')}, link_closing_tag: '</a>'.html_safe } diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 23606e24563..93afddce779 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -70,10 +70,10 @@ = link_to retry_project_job_path(build.project, build, return_to: request.original_url), method: :post, title: _('Retry'), class: 'gl-button btn btn-default btn-icon' do = sprite_icon('repeat', css_class: 'gl-icon') - if can?(current_user, :read_build, job) - %tr.build-trace-row.responsive-table-border-end + %tr.build-log-row.responsive-table-border-end %td - %td.responsive-table-cell.build-trace-container{ colspan: 4 } - %pre.build-trace.build-trace-rounded + %td.responsive-table-cell.build-log-container{ colspan: 4 } + %pre.build-log.build-log-rounded %code.bash.js-build-output = build_summary(build) diff --git a/app/views/projects/product_analytics/test.html.haml b/app/views/projects/product_analytics/test.html.haml index 60d897ee138..3204cd5fbbe 100644 --- a/app/views/projects/product_analytics/test.html.haml +++ b/app/views/projects/product_analytics/test.html.haml @@ -12,5 +12,6 @@ %code = @event.as_json_wo_empty +-# haml-lint:disable InlineJavaScript :javascript #{render 'tracker'} diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index bdb5f021b70..cfdbf3410b1 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -20,7 +20,8 @@ "is_admin": current_user&.admin.to_s, "show_cleanup_policy_on_alert": show_cleanup_policy_on_alert(@project).to_s, "cleanup_policies_settings_path": project_settings_packages_and_registries_path(@project), - character_error: @character_error.to_s, + connection_error: (!!@connection_error).to_s, + invalid_path_error: (!!@invalid_path_error).to_s, user_callouts_path: user_callouts_path, user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s, } } diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml index 6b2a1468eec..23b1ec4dea3 100644 --- a/app/views/projects/settings/operations/_error_tracking.html.haml +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -18,4 +18,5 @@ api_host: setting.api_host, enabled: setting.enabled.to_json, integrated: setting.integrated.to_json, + gitlab_dsn: setting.gitlab_dsn, token: setting.token.present? ? '*' * 12 : nil } } diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 8ef53c40b11..3e6acdb130a 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -3,7 +3,7 @@ - breadcrumb_title @snippet.to_reference - page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") -#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet) } } +#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } } .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true, api_awards_path: project_snippets_award_api_path(@snippet) diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 79205a51d71..d3cc409df1d 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -18,6 +18,9 @@ = render_if_exists 'projects/commits/mirror_status' + - if @tags_loading_error + = render 'shared/errors/gitaly_unavailable', reason: s_('TagsPage|Unable to load tags') + .tags - if @tags.any? %ul.flex-list.content-list diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index fe00772d1d6..4281152225a 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -54,4 +54,5 @@ .form-actions.gl-display-flex = button_tag s_('TagsPage|Create tag'), class: 'gl-button btn btn-confirm gl-mr-3', data: { qa_selector: "create_tag_button" } = link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'gl-button btn btn-default btn-cancel' +-# haml-lint:disable InlineJavaScript %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 2d0c4cc20a0..1553eda1cfb 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -11,3 +11,4 @@ = render 'projects/last_push' = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) += render 'shared/web_ide_path' diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml index dfd46af0499..6c7cccfb9b1 100644 --- a/app/views/projects/usage_quotas/index.html.haml +++ b/app/views/projects/usage_quotas/index.html.haml @@ -6,7 +6,7 @@ .row .col-sm-12 = s_('UsageQuota|Usage of project resources across the %{strong_start}%{project_name}%{strong_end} project').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_name: @project.name } + '.' - %a{ href: help_page_path('user/usage_quotas.md') } + %a{ href: help_page_path('user/usage_quotas.md'), target: '_blank', rel: 'noopener noreferrer' } = s_('UsageQuota|Learn more about usage quotas') + '.' .top-area.scrolling-tabs-container.inner-page-scroll-tabs diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml index 5645fbfb238..41058034d6f 100644 --- a/app/views/search/results/_issuable.html.haml +++ b/app/views/search/results/_issuable.html.haml @@ -13,7 +13,8 @@ = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight) .col-sm-3.gl-mt-3.gl-sm-mt-0.gl-text-right - if issuable.respond_to?(:upvotes_count) && issuable.upvotes_count > 0 - %li.issuable-upvotes.gl-list-style-none.has-tooltip{ title: _('Upvotes') } - = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle") - = issuable.upvotes_count + %li.issuable-upvotes.gl-list-style-none + %span.has-tooltip{ title: _('Upvotes') } + = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle") + = issuable.upvotes_count %span.gl-text-gray-500= sprintf(s_('updated %{time_ago}'), { time_ago: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom') }).html_safe diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index f03314563cb..3ab2b969b75 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -9,17 +9,12 @@ = f.text_field :import_url, value: import_url.sanitized_url, autocomplete: 'off', class: 'form-control gl-form-input', placeholder: 'https://gitlab.company.com/group/project.git', required: true = render 'shared/global_alert', - variant: :warning, - alert_class: 'gl-mt-3 js-import-url-warning hide', + variant: :danger, + alert_class: 'gl-mt-3 js-import-url-error hide', dismissible: false, close_button_class: 'js-close-2fa-enabled-success-alert' do .gl-alert-body - = s_('Import|A repository URL usually ends in a .git suffix, although this is not required. Double check to make sure your repository URL is correct.') - - .gl-alert.gl-alert-not-dismissible.gl-alert-warning.gl-mt-3.hide#project_import_url_warning - .gl-alert-container - = sprite_icon('warning-solid', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content{ role: 'alert' } + = s_('Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials.') .row .form-group.col-md-6 = f.label :import_url_user, class: 'label-bold' do diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml index eb50960202a..117ed212fd9 100644 --- a/app/views/shared/_milestones_filter.html.haml +++ b/app/views/shared/_milestones_filter.html.haml @@ -1,13 +1,15 @@ -%ul.nav-links.mobile-separator.nav.nav-tabs - %li{ class: milestone_class_for_state(params[:state], 'opened', true) }> - = link_to milestones_filter_path(state: 'opened') do - = _('Open') - %span.badge.badge-pill= counts[:opened] - %li{ class: milestone_class_for_state(params[:state], 'closed') }> - = link_to milestones_filter_path(state: 'closed', sort: 'due_date_desc') do - = _('Closed') - %span.badge.badge-pill= counts[:closed] - %li{ class: milestone_class_for_state(params[:state], 'all') }> - = link_to milestones_filter_path(state: 'all', sort: 'due_date_desc') do - = _('All') - %span.badge.badge-pill= counts[:all] +- count_badge_classes = 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex' + += gl_tabs_nav( {class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'milestones-filter' } } ) do + = gl_tab_link_to milestones_filter_path(state: 'opened'), { item_active: params[:state].blank? || params[:state] == 'opened' } do + = _('Open') + %span{ class: count_badge_classes } + = counts[:opened] + = gl_tab_link_to milestones_filter_path(state: 'closed', sort: 'due_date_desc'), { item_active: params[:state] == 'closed' } do + = _('Closed') + %span{ class: count_badge_classes } + = counts[:closed] + = gl_tab_link_to milestones_filter_path(state: 'all', sort: 'due_date_desc'), { item_active: params[:state] == 'all' } do + = _('All') + %span{ class: count_badge_classes } + = counts[:all] diff --git a/app/views/shared/_web_ide_path.html.haml b/app/views/shared/_web_ide_path.html.haml new file mode 100644 index 00000000000..73d00bcd408 --- /dev/null +++ b/app/views/shared/_web_ide_path.html.haml @@ -0,0 +1,4 @@ += javascript_tag do + :plain + window.gl = window.gl || {}; + window.gl.webIDEPath = '#{web_ide_url}' diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 98752345074..165564c5666 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -2,7 +2,7 @@ - @no_breadcrumb_container = true - @no_container = true - @content_wrapper_class = "#{@content_wrapper_class} gl-relative" -- @content_class = "issue-boards-content js-focus-mode-board" +- @content_class = "js-focus-mode-board" - is_epic_board = board.to_type == "EpicBoard" - if is_epic_board - breadcrumb_title _("Epic Boards") diff --git a/app/views/shared/builds/_build_output.html.haml b/app/views/shared/builds/_build_output.html.haml index 380fac4d0e4..a3b7d4926f8 100644 --- a/app/views/shared/builds/_build_output.html.haml +++ b/app/views/shared/builds/_build_output.html.haml @@ -1,4 +1,4 @@ -%pre.build-trace#build-trace +%pre.build-log %code.bash.js-build-output .build-loader-animation.js-build-refresh .dot diff --git a/app/views/shared/builds/_tabs.html.haml b/app/views/shared/builds/_tabs.html.haml index 4973309edf5..498e9cc33ce 100644 --- a/app/views/shared/builds/_tabs.html.haml +++ b/app/views/shared/builds/_tabs.html.haml @@ -1,24 +1,19 @@ -%ul.nav-links.mobile-separator.nav.nav-tabs - %li{ class: active_when(scope.nil?) }> - = link_to build_path_proc.call(nil) do - All - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm.js-totalbuilds-count - = limited_counter_with_delimiter(all_builds) +- count_badge_classes = 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex' - %li{ class: active_when(scope == 'pending') }> - = link_to build_path_proc.call('pending') do - Pending - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm - = limited_counter_with_delimiter(all_builds.pending) - - %li{ class: active_when(scope == 'running') }> - = link_to build_path_proc.call('running') do - Running - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm - = limited_counter_with_delimiter(all_builds.running) - - %li{ class: active_when(scope == 'finished') }> - = link_to build_path_proc.call('finished') do - Finished - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm - = limited_counter_with_delimiter(all_builds.finished) += gl_tabs_nav( {class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'jobs-tabs' } } ) do + = gl_tab_link_to build_path_proc.call(nil), { item_active: scope.nil? } do + = _('All') + %span{ class: count_badge_classes } + = limited_counter_with_delimiter(all_builds) + = gl_tab_link_to build_path_proc.call('pending'), { item_active: scope == 'pending' } do + = _('Pending') + %span{ class: count_badge_classes } + = limited_counter_with_delimiter(all_builds.pending) + = gl_tab_link_to build_path_proc.call('running'), { item_active: scope == 'running' } do + = _('Running') + %span{ class: count_badge_classes } + = limited_counter_with_delimiter(all_builds.running) + = gl_tab_link_to build_path_proc.call('finished'), { item_active: scope == 'finished' } do + = _('Finished') + %span{ class: count_badge_classes } + = limited_counter_with_delimiter(all_builds.finished) diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index 652da4b396a..e049afbc40b 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -4,7 +4,6 @@ = s_('DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}').html_safe % { link_start: group_deploy_tokens_help_link_start, link_end: '</a>'.html_safe } = form_for token, url: create_deploy_token_path(group_or_project, anchor: 'js-deploy-tokens'), method: :post, remote: Feature.enabled?(:ajax_new_deploy_token, group_or_project) do |f| - = form_errors(token) .form-group = f.label :name, class: 'label-bold' diff --git a/app/views/shared/empty_states/_topics.html.haml b/app/views/shared/empty_states/_topics.html.haml new file mode 100644 index 00000000000..fd82a853037 --- /dev/null +++ b/app/views/shared/empty_states/_topics.html.haml @@ -0,0 +1,7 @@ +.row.empty-state + .col-12 + .svg-content + = image_tag 'illustrations/labels.svg', data: { qa_selector: 'svg_content' } + .text-content.gl-text-center.gl-pt-0! + %h4= _('There are no topics to show.') + %p= _('Add topics to projects to help users find them.') diff --git a/app/views/shared/errors/_gitaly_unavailable.html.haml b/app/views/shared/errors/_gitaly_unavailable.html.haml new file mode 100644 index 00000000000..96a68cbcdc6 --- /dev/null +++ b/app/views/shared/errors/_gitaly_unavailable.html.haml @@ -0,0 +1,8 @@ +.gl-alert.gl-alert-danger.gl-mb-5.gl-mt-5 + .gl-alert-container + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-title + = reason + .gl-alert-body + = s_('The git server, Gitaly, is not available at this time. Please contact your administrator.') diff --git a/app/views/shared/hook_logs/_recent_deliveries_table.html.haml b/app/views/shared/hook_logs/_recent_deliveries_table.html.haml new file mode 100644 index 00000000000..31ef8560781 --- /dev/null +++ b/app/views/shared/hook_logs/_recent_deliveries_table.html.haml @@ -0,0 +1,34 @@ +%table.gl-table.gl-w-full + %thead + %tr + %th= _('Status') + %th.d-none.d-sm-table-cell= _('Trigger') + %th= _('Elapsed time') + %th= _('Request time') + %th + + - if hook_logs.present? + - hook_logs.each do |hook_log| + %tr + %td + = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } + %td.d-none.d-sm-table-cell + %span.badge.badge-pill.gl-badge.badge-muted.sm + = hook_log.trigger.singularize.titleize + %td + #{number_with_precision(hook_log.execution_duration, precision: 2)} sec + %td + = time_ago_with_tooltip(hook_log.created_at) + %td + = link_to _('View details'), hook_log_path(hook, hook_log) + + +- if hook_logs.present? + = paginate hook_logs, theme: 'gitlab' +- else + .gl-text-center.gl-mt-7 + %h4= _('No webhook events') + %p + %span.gl-display-block= _('Webhook events will be displayed here.') + %span= _('Use the %{strongStart}Test%{strongEnd} option above to create an event.').html_safe % { strongStart: '<strong>'.html_safe, strongEnd: '</strong>'.html_safe } + diff --git a/app/views/shared/hook_logs/_status_label.html.haml b/app/views/shared/hook_logs/_status_label.html.haml index dfa5ecee448..b930074303c 100644 --- a/app/views/shared/hook_logs/_status_label.html.haml +++ b/app/views/shared/hook_logs/_status_label.html.haml @@ -1,3 +1,3 @@ - label_status = hook_log.success? ? 'badge-success' : 'badge-danger' -%span{ class: "badge #{label_status}" } +%span{ class: "badge badge-pill gl-badge sm #{label_status}" } = hook_log.internal_error? ? _('Error') : hook_log.response_status diff --git a/app/views/shared/integrations/_tabs.html.haml b/app/views/shared/integrations/_tabs.html.haml index 553401e47bd..d6ca0bd7d1e 100644 --- a/app/views/shared/integrations/_tabs.html.haml +++ b/app/views/shared/integrations/_tabs.html.haml @@ -1,18 +1,11 @@ -- active_tab = local_assigns.fetch(:active_tab, 'edit') -- active_classes = 'gl-tab-nav-item-active gl-tab-nav-item-active-indigo active' -- tabs = integration_tabs(integration: integration) - -- if tabs.length <= 1 - = yield -- else +- if integration.instance_level? .tabs.gl-tabs %div - %ul.nav.gl-tabs-nav{ role: 'tablist' } - - tabs.each do |tab| - %li.nav-item{ role: 'presentation' } - %a.nav-link.gl-tab-nav-item{ role: 'tab', class: (active_classes if tab[:key] == active_tab), href: tab[:href] } - = tab[:text] + = gl_tabs_nav({ class: 'gl-mb-5' }) do + = gl_tab_link_to _('Settings'), scoped_edit_integration_path(integration) + = gl_tab_link_to s_('Integrations|Projects using custom settings'), scoped_overrides_integration_path(integration) - .tab-content.gl-tab-content - .tab-pane.gl-pt-3.active{ role: 'tabpanel' } - = yield + = yield + +- else + = yield diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml index cff50eef88b..4a33f625347 100644 --- a/app/views/shared/issuable/_nav.html.haml +++ b/app/views/shared/issuable/_nav.html.haml @@ -2,22 +2,16 @@ - page_context_word = type.to_s.humanize(capitalize: false) - display_count = local_assigns.fetch(:display_count, true) -%ul.nav-links.issues-state-filters.mobile-separator.nav.nav-tabs - %li{ class: active_when(params[:state] == 'opened') }> - = link_to page_filter_path(state: 'opened'), id: 'state-opened', title: _("Filter by %{page_context_word} that are currently open.") % { page_context_word: page_context_word }, data: { state: 'opened' } do - #{issuables_state_counter_text(type, :opened, display_count)} - += gl_tabs_nav({ class: 'issues-state-filters gl-border-b-0 gl-flex-grow-1' }) do + = gl_tab_link_to page_filter_path(state: 'opened'), { item_active: params[:state] == 'opened', id: 'state-opened', title: _("Filter by %{page_context_word} that are currently open.") % { page_context_word: page_context_word }, data: { state: 'opened' } } do + #{issuables_state_counter_text(type, :opened, display_count)} - if type == :merge_requests - %li{ class: active_when(params[:state] == 'merged') }> - = link_to page_filter_path(state: 'merged'), id: 'state-merged', title: _('Filter by merge requests that are currently merged.'), data: { state: 'merged' } do - #{issuables_state_counter_text(type, :merged, display_count)} - - %li{ class: active_when(params[:state] == 'closed') }> - = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: _('Filter by merge requests that are currently closed and unmerged.'), data: { state: 'closed' } do - #{issuables_state_counter_text(type, :closed, display_count)} + = gl_tab_link_to page_filter_path(state: 'merged'), item_active: params[:state] == 'merged', id: 'state-merged', title: _('Filter by merge requests that are currently merged.'), data: { state: 'merged' } do + #{issuables_state_counter_text(type, :merged, display_count)} + = gl_tab_link_to page_filter_path(state: 'closed'), item_active: params[:state] == 'closed', id: 'state-closed', title: _('Filter by merge requests that are currently closed and unmerged.'), data: { state: 'closed' } do + #{issuables_state_counter_text(type, :closed, display_count)} - else - %li{ class: active_when(params[:state] == 'closed') }> - = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: _('Filter by issues that are currently closed.'), data: { state: 'closed', qa_selector: 'closed_issues_link' } do - #{issuables_state_counter_text(type, :closed, display_count)} + = gl_tab_link_to page_filter_path(state: 'closed'), item_active: params[:state] == 'closed', id: 'state-closed', title: _('Filter by issues that are currently closed.'), data: { state: 'closed', qa_selector: 'closed_issues_link' } do + #{issuables_state_counter_text(type, :closed, display_count)} = render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all, display_count) diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index e6c4b3f4814..81a7581d392 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -19,7 +19,7 @@ - if params[:search].present? = hidden_field_tag :search, params[:search] - if @can_bulk_update - .check-all-holder.d-none.d-sm-block.hidden + .check-all-holder.gl-display-none.gl-sm-display-block.hidden.gl-float-left.gl-mr-5.gl-line-height-36 - checkbox_id = 'check-all-issues' %label.gl-sr-only{ for: checkbox_id }= _('Select all') = check_box_tag checkbox_id, nil, false, class: "check-all-issues left" diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 1e8724c3448..62539bfeffd 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -7,6 +7,7 @@ - can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit) - add_page_startup_api_call "#{issuable_sidebar[:issuable_json_path]}?serializer=sidebar_extras" - reviewers = local_assigns.fetch(:reviewers, nil) +- in_group_context_with_iterations = @project.group.present? && issuable_sidebar[:supports_iterations] %aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite', 'aria-label': issuable_type } .issuable-sidebar @@ -28,11 +29,11 @@ = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type - if issuable_sidebar[:supports_milestone] - .block.milestone{ :class => ("gl-border-b-0!" if issuable_sidebar[:supports_iterations]), data: { qa_selector: 'milestone_block' } } + .block.milestone{ :class => ("gl-border-b-0!" if in_group_context_with_iterations), data: { qa_selector: 'milestone_block' } } .js-milestone-select{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } - - if @project.group.present? && issuable_sidebar[:supports_iterations] - .block{ class: 'gl-pt-0!', data: { qa_selector: 'iteration_container' } } + - if in_group_context_with_iterations + .block{ class: 'gl-pt-0! gl-collapse-empty', data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }< = render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type - if issuable_sidebar[:supports_time_tracking] @@ -55,11 +56,13 @@ .js-sidebar-status-entry-point{ data: sidebar_status_data(issuable_sidebar, @project) } - if issuable_sidebar.has_key?(:confidential) + -# haml-lint:disable InlineJavaScript %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe #js-confidential-entry-point = render_if_exists 'shared/issuable/sidebar_cve_id_request', issuable_sidebar: issuable_sidebar + -# haml-lint:disable InlineJavaScript %script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe #js-lock-entry-point @@ -72,7 +75,7 @@ #js-reference-entry-point - if issuable_type == 'merge_request' .sub-block.js-sidebar-source-branch - .sidebar-collapsed-icon.dont-change-state + .sidebar-collapsed-icon.js-dont-change-state = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport') .gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed %span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap diff --git a/app/views/shared/issuable/nav_links/_all.html.haml b/app/views/shared/issuable/nav_links/_all.html.haml index c92a50bcb70..7afa194d5db 100644 --- a/app/views/shared/issuable/nav_links/_all.html.haml +++ b/app/views/shared/issuable/nav_links/_all.html.haml @@ -1,6 +1,5 @@ - page_context_word = local_assigns.fetch(:page_context_word) - counter = local_assigns.fetch(:counter) -%li{ class: active_when(params[:state] == 'all') }> - = link_to page_filter_path(state: 'all'), id: 'state-all', title: "Show all #{page_context_word}.", data: { state: 'all' } do - #{counter} += gl_tab_link_to page_filter_path(state: 'all'), item_active: params[:state] == 'all', id: 'state-all', title: _("Show all %{issuable_type}.") % { issuable_type: page_context_word }, data: { state: 'all' } do + #{counter} diff --git a/app/views/shared/issue_type/_emoji_block.html.haml b/app/views/shared/issue_type/_emoji_block.html.haml index 26d30341999..d2c851a4e49 100644 --- a/app/views/shared/issue_type/_emoji_block.html.haml +++ b/app/views/shared/issue_type/_emoji_block.html.haml @@ -4,7 +4,7 @@ .row.gl-m-0.gl-justify-content-space-between .js-noteable-awards = render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path - .new-branch-col.gl-my-2 + .new-branch-col.gl-my-2.gl-font-size-0 = render_if_exists "projects/issues/timeline_toggle", issuable: issuable #js-vue-sort-issue-discussions #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(issuable), notes_filters: UserPreference.notes_filters.to_json } } diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 56b2b0d5801..c66ba5ba2e1 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -161,11 +161,11 @@ - milestone_ref = milestone.try(:to_reference, full: true) - if milestone_ref.present? .block.reference - .sidebar-collapsed-icon.dont-change-state + .sidebar-collapsed-icon.js-dont-change-state = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport') .cross-project-reference.hide-collapsed - %span + %span.gl-display-inline-block.gl-text-truncate = s_('MilestoneSidebar|Reference:') %span{ title: milestone_ref } = milestone_ref - = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport') + = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport', class: 'btn-clipboard btn-transparent gl-float-right gl-bg-gray-10') diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index d0a2d97df0f..3e880a36e29 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -1,31 +1,4 @@ - noteable_name = @note.noteable.human_class_name -.float-left.btn-group.gl-sm-mr-3.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown +.js-comment-type-dropdown.float-left.gl-sm-mr-3{ data: { noteable_name: noteable_name } } %input.btn.gl-button.btn-confirm.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } } - - - if @note.can_be_discussion_note? - = button_tag type: 'button', class: 'gl-button btn dropdown-toggle btn-confirm js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do - = sprite_icon('chevron-down') - - %ul#resolvable-comment-menu.dropdown-menu.dropdown-open-top{ data: { dropdown: true } } - %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => _('Comment'), 'close-text' => _("Comment & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Comment & reopen %{noteable_name}") % { noteable_name: noteable_name } } } - %button.btn.gl-button.btn-default-tertiary - = sprite_icon('check', css_class: 'icon') - .description - %strong= _("Comment") - %p - = _("Add a general comment to this %{noteable_name}.") % { noteable_name: noteable_name } - - %li.divider.droplab-item-ignore - - %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => _('Start thread'), 'close-text' => _("Start thread & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Start thread & reopen %{noteable_name}") % { noteable_name: noteable_name } } } - %button.btn.gl-button.btn-default-tertiary - = sprite_icon('check', css_class: 'icon') - .description - %strong= _("Start thread") - %p - = succeed '.' do - - if @note.noteable.supports_resolvable_notes? - = _('Discuss a specific suggestion or question that needs to be resolved') - - else - = _('Discuss a specific suggestion or question') diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index a03e8446f5d..6231f817704 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -1,4 +1,5 @@ - supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) +- supports_file_upload = local_assigns.fetch(:supports_file_upload, true) .comment-toolbar.clearfix .toolbar-text = link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank' @@ -10,33 +11,34 @@ is supported - %span.uploading-container - %span.uploading-progress-container.hide - = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom') - %span.attaching-file-message - -# Populated by app/assets/javascripts/dropzone_input.js - %span.uploading-progress 0% - = loading_icon(css_class: 'align-text-bottom gl-mr-2') - - %span.uploading-error-container.hide - %span.uploading-error-icon + - if supports_file_upload + %span.uploading-container + %span.uploading-progress-container.hide = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom') - %span.uploading-error-message - -# Populated by app/assets/javascripts/dropzone_input.js - %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link - %span.gl-button-text - = _("Try again") - = _("or") - %button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline - %span.gl-button-text - = _("attach a new file") - = _(".") + %span.attaching-file-message + -# Populated by app/assets/javascripts/dropzone_input.js + %span.uploading-progress 0% + = loading_icon(css_class: 'align-text-bottom gl-mr-2') - %button.btn.gl-button.btn-link.button-attach-file.markdown-selector.button-attach-file.gl-vertical-align-text-bottom - = sprite_icon('media') - %span.gl-button-text - = _("Attach a file") + %span.uploading-error-container.hide + %span.uploading-error-icon + = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom') + %span.uploading-error-message + -# Populated by app/assets/javascripts/dropzone_input.js + %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link + %span.gl-button-text + = _("Try again") + = _("or") + %button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline + %span.gl-button-text + = _("attach a new file") + = _(".") - %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide - %span.gl-button-text - = _("Cancel") + %button.btn.gl-button.btn-link.button-attach-file.markdown-selector.button-attach-file.gl-vertical-align-text-bottom + = sprite_icon('media') + %span.gl-button-text + = _("Attach a file") + + %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide + %span.gl-button-text + = _("Cancel") diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index f7f5c02370d..e34f412baa4 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -25,4 +25,5 @@ = sprite_icon('lock', css_class: 'icon') %span = html_escape(_("This %{issuable} is locked. Only %{strong_open}project members%{strong_close} can comment.")) % { issuable: issuable.class.to_s.titleize.downcase, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } +-# haml-lint:disable InlineJavaScript %script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe diff --git a/app/views/shared/topics/_search_form.html.haml b/app/views/shared/topics/_search_form.html.haml new file mode 100644 index 00000000000..97343983b3c --- /dev/null +++ b/app/views/shared/topics/_search_form.html.haml @@ -0,0 +1,7 @@ += form_tag page_filter_path, method: :get, class: "topic-filter-form js-topic-filter-form", id: 'topic-filter-form' do |f| + = search_field_tag :search, params[:search], + placeholder: s_('Filter by name'), + class: 'topic-filter-form-field form-control input-short', + spellcheck: false, + id: 'topic-filter-form-field', + autofocus: local_assigns[:autofocus] diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index abe23d0be78..fd124c2967d 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -5,12 +5,12 @@ %div - hook.class.triggers.each_value do |trigger| - if hook.public_send(trigger) - %span.gl-badge.gl-bg-gray-10.gl-mt-2.rounded.deploy-project-label= trigger.to_s.titleize - %span.gl-badge.gl-bg-gray-10.gl-mt-2.rounded + %span.gl-badge.badge-muted.badge-pill.sm.gl-mt-2.deploy-project-label= trigger.to_s.titleize + %span.gl-badge.badge-muted.badge-pill.sm.gl-mt-2 = _('SSL Verification:') = hook.enable_ssl_verification ? _('enabled') : _('disabled') .col-md-4.col-lg-5.text-right-md.gl-mt-2 %span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm btn-default gl-mr-3' %span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn gl-button btn-default btn-sm gl-mr-3' - = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn gl-button btn-default btn-sm' + = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn gl-button btn-secondary btn-danger-secondary btn-sm' diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml index 15710f0df49..e0860bc473d 100644 --- a/app/views/shared/wikis/edit.html.haml +++ b/app/views/shared/wikis/edit.html.haml @@ -4,7 +4,7 @@ - if @error #js-wiki-error{ data: { error: @error, wiki_page_path: wiki_page_path(@wiki, @page) } } -.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row +.js-wiki-edit-page.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row = wiki_sidebar_toggle_button %h3.page-title.gl-flex-grow-1 diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index ca52a1f8f46..f1093a3b730 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -12,7 +12,7 @@ - content_for :prefetch_asset_tags do - webpack_preload_asset_tag('monaco', prefetch: true) -#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet) } } +#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } } .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 20cbe08225e..522f0f771cd 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -84,10 +84,12 @@ = sprite_icon('location', css_class: 'fgray') %span{ itemprop: 'addressLocality' } = @user.location - = render 'middle_dot_divider', stacking: true do - = sprite_icon('clock', css_class: 'fgray') - %span - = local_time(@user.timezone) + - user_local_time = local_time(@user.timezone) + - unless user_local_time.nil? + = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do + = sprite_icon('clock', css_class: 'fgray') + %span + = user_local_time - unless work_information(@user).blank? = render 'middle_dot_divider', stacking: true do = sprite_icon('work', css_class: 'fgray') diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 955674b52a4..c7ce2eb8d00 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -30,6 +30,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: authorized_project_update:authorized_project_update_project_recalculate_per_user + :worker_name: AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :urgency: :high + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_from_replica :worker_name: AuthorizedProjectUpdate::UserRefreshFromReplicaWorker :feature_category: :authentication_and_authorization @@ -46,7 +55,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: true :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency :worker_name: AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker @@ -185,7 +194,7 @@ :tags: [] - :name: cronjob:ci_delete_unit_tests :worker_name: Ci::DeleteUnitTestsWorker - :feature_category: :continuous_integration + :feature_category: :code_testing :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -194,7 +203,7 @@ :tags: [] - :name: cronjob:ci_pipeline_artifacts_expire_artifacts :worker_name: Ci::PipelineArtifacts::ExpireArtifactsWorker - :feature_category: :continuous_integration + :feature_category: :build_artifacts :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -219,6 +228,24 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:ci_stuck_builds_drop_running + :worker_name: Ci::StuckBuilds::DropRunningWorker + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: cronjob:ci_stuck_builds_drop_scheduled + :worker_name: Ci::StuckBuilds::DropScheduledWorker + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:container_expiration_policy :worker_name: ContainerExpirationPolicyWorker :feature_category: :container_registry @@ -255,6 +282,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:dependency_proxy_image_ttl_group_policy + :worker_name: DependencyProxy::ImageTtlGroupPolicyWorker + :feature_category: :dependency_proxy + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: cronjob:environments_auto_delete_cron :worker_name: Environments::AutoDeleteCronWorker :feature_category: :continuous_delivery @@ -275,7 +311,7 @@ :tags: [] - :name: cronjob:expire_build_artifacts :worker_name: ExpireBuildArtifactsWorker - :feature_category: :continuous_integration + :feature_category: :build_artifacts :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -347,7 +383,7 @@ :tags: [] - :name: cronjob:namespaces_in_product_marketing_emails :worker_name: Namespaces::InProductMarketingEmailsWorker - :feature_category: :subgroups + :feature_category: :experimentation_activation :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -557,7 +593,7 @@ :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low - :resource_boundary: :cpu + :resource_boundary: :unknown :weight: 1 :idempotent: :tags: [] @@ -642,6 +678,24 @@ :weight: 1 :idempotent: true :tags: [] +- :name: dependency_proxy_blob:dependency_proxy_cleanup_blob + :worker_name: DependencyProxy::CleanupBlobWorker + :feature_category: :dependency_proxy + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: dependency_proxy_manifest:dependency_proxy_cleanup_manifest + :worker_name: DependencyProxy::CleanupManifestWorker + :feature_category: :dependency_proxy + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: deployment:deployments_drop_older_deployments :worker_name: Deployments::DropOlderDeploymentsWorker :feature_category: :continuous_delivery @@ -1418,7 +1472,7 @@ :urgency: :high :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: true :tags: [] - :name: pipeline_cache:expire_pipeline_cache :worker_name: ExpirePipelineCacheWorker @@ -1474,6 +1528,15 @@ :weight: 3 :idempotent: :tags: [] +- :name: pipeline_default:ci_create_downstream_pipeline + :worker_name: Ci::CreateDownstreamPipelineWorker + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :cpu + :weight: 3 + :idempotent: + :tags: [] - :name: pipeline_default:ci_drop_pipeline :worker_name: Ci::DropPipelineWorker :feature_category: :continuous_integration @@ -1890,7 +1953,7 @@ :tags: [] - :name: default :worker_name: - :feature_category: + :feature_category: :not_owned :has_external_dependencies: :urgency: :resource_boundary: @@ -2044,7 +2107,7 @@ :tags: [] - :name: expire_build_instance_artifacts :worker_name: ExpireBuildInstanceArtifactsWorker - :feature_category: :continuous_integration + :feature_category: :build_artifacts :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -2215,7 +2278,7 @@ :tags: [] - :name: mailers :worker_name: ActionMailer::MailDeliveryJob - :feature_category: :issue_tracking + :feature_category: :not_owned :has_external_dependencies: :urgency: low :resource_boundary: @@ -2314,7 +2377,7 @@ :tags: [] - :name: namespaces_onboarding_issue_created :worker_name: Namespaces::OnboardingIssueCreatedWorker - :feature_category: :issue_tracking + :feature_category: :onboarding :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -2323,7 +2386,7 @@ :tags: [] - :name: namespaces_onboarding_pipeline_created :worker_name: Namespaces::OnboardingPipelineCreatedWorker - :feature_category: :subgroups + :feature_category: :onboarding :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -2332,7 +2395,7 @@ :tags: [] - :name: namespaces_onboarding_progress :worker_name: Namespaces::OnboardingProgressWorker - :feature_category: :product_analytics + :feature_category: :onboarding :has_external_dependencies: :urgency: :low :resource_boundary: :cpu @@ -2341,7 +2404,7 @@ :tags: [] - :name: namespaces_onboarding_user_added :worker_name: Namespaces::OnboardingUserAddedWorker - :feature_category: :users + :feature_category: :onboarding :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -2411,15 +2474,6 @@ :weight: 1 :idempotent: :tags: [] -- :name: pages_remove - :worker_name: PagesRemoveWorker - :feature_category: :pages - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: - :tags: [] - :name: pages_transfer :worker_name: PagesTransferWorker :feature_category: :pages diff --git a/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb b/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb new file mode 100644 index 00000000000..352c82e5021 --- /dev/null +++ b/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + class ProjectRecalculatePerUserWorker < ProjectRecalculateWorker + data_consistency :always + + feature_category :authentication_and_authorization + urgency :high + queue_namespace :authorized_project_update + + deduplicate :until_executing, including_scheduled: true + idempotent! + + def perform(project_id, user_id) + project = Project.find_by_id(project_id) + user = User.find_by_id(user_id) + + return unless project && user + + in_lock(lock_key(project), ttl: 10.seconds) do + AuthorizedProjectUpdate::ProjectRecalculatePerUserService.new(project, user).execute + end + end + end +end diff --git a/app/workers/authorized_project_update/project_recalculate_worker.rb b/app/workers/authorized_project_update/project_recalculate_worker.rb index 4d350d95e7e..3d073f18622 100644 --- a/app/workers/authorized_project_update/project_recalculate_worker.rb +++ b/app/workers/authorized_project_update/project_recalculate_worker.rb @@ -26,7 +26,9 @@ module AuthorizedProjectUpdate private def lock_key(project) - "#{self.class.name.underscore}/projects/#{project.id}" + # The self.class.name.underscore value is hardcoded here as the prefix, so that the same + # lock_key for this superclass will be used by the ProjectRecalculatePerUserWorker subclass. + "authorized_project_update/project_recalculate_worker/projects/#{project.id}" end end end diff --git a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb index 48e3d0837c7..daebb23baae 100644 --- a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb +++ b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb @@ -30,8 +30,6 @@ module AuthorizedProjectUpdate # does not allow us to deduplicate these jobs. # https://gitlab.com/gitlab-org/gitlab/-/issues/325291 def use_replica_if_available(&block) - return yield unless ::Gitlab::Database::LoadBalancing.enable? - ::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries(&block) end diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb index ab4d9c13422..f5327449242 100644 --- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb +++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb @@ -19,11 +19,10 @@ module AuthorizedProjectUpdate feature_category :authentication_and_authorization urgency :low queue_namespace :authorized_project_update - # This job will not be deduplicated since it is marked with - # `data_consistency :delayed` and not `idempotent!` - # See https://gitlab.com/gitlab-org/gitlab/-/issues/325291 + deduplicate :until_executing, including_scheduled: true data_consistency :delayed + idempotent! def perform(start_user_id, end_user_id) User.where(id: start_user_id..end_user_id).find_each do |user| # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index fa255d064cc..d560ebcc6e6 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -26,7 +26,7 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker created_entities.first(next_batch_size).each do |entity| entity.create_pipeline_trackers! - BulkImports::ExportRequestWorker.perform_async(entity.id) if entity.group_entity? + BulkImports::ExportRequestWorker.perform_async(entity.id) BulkImports::EntityWorker.perform_async(entity.id) entity.start! diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb index d5f7215b08a..8bc0acc9b22 100644 --- a/app/workers/bulk_imports/export_request_worker.rb +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -10,8 +10,6 @@ module BulkImports worker_has_external_dependencies! feature_category :importers - GROUP_EXPORTED_URL_PATH = "/groups/%s/export_relations" - def perform(entity_id) entity = BulkImports::Entity.find(entity_id) @@ -21,8 +19,7 @@ module BulkImports private def request_export(entity) - http_client(entity.bulk_import.configuration) - .post(GROUP_EXPORTED_URL_PATH % entity.encoded_source_full_path) + http_client(entity.bulk_import.configuration).post(entity.export_relations_url_path) end def http_client(configuration) diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 760a309a381..35633b55489 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -16,7 +16,7 @@ module BulkImports def perform(pipeline_tracker_id, stage, entity_id) pipeline_tracker = ::BulkImports::Tracker - .with_status(:created) + .with_status(:created, :started) .find_by_id(pipeline_tracker_id) if pipeline_tracker.present? @@ -59,18 +59,35 @@ module BulkImports pipeline_tracker.pipeline_class.new(context).run pipeline_tracker.finish! + rescue BulkImports::NetworkError => e + if e.retriable?(pipeline_tracker) + logger.error( + worker: self.class.name, + entity_id: pipeline_tracker.entity.id, + pipeline_name: pipeline_tracker.pipeline_name, + message: "Retrying error: #{e.message}" + ) + + reenqueue(pipeline_tracker, delay: e.retry_delay) + else + fail_tracker(pipeline_tracker, e) + end rescue StandardError => e + fail_tracker(pipeline_tracker, e) + end + + def fail_tracker(pipeline_tracker, exception) pipeline_tracker.update!(status_event: 'fail_op', jid: jid) logger.error( worker: self.class.name, entity_id: pipeline_tracker.entity.id, pipeline_name: pipeline_tracker.pipeline_name, - message: e.message + message: exception.message ) Gitlab::ErrorTracking.track_exception( - e, + exception, entity_id: pipeline_tracker.entity.id, pipeline_name: pipeline_tracker.pipeline_name ) @@ -88,8 +105,13 @@ module BulkImports (Time.zone.now - pipeline_tracker.entity.created_at) > Pipeline::NDJSON_EXPORT_TIMEOUT end - def reenqueue(pipeline_tracker) - self.class.perform_in(NDJSON_PIPELINE_PERFORM_DELAY, pipeline_tracker.id, pipeline_tracker.stage, pipeline_tracker.entity.id) + def reenqueue(pipeline_tracker, delay: NDJSON_PIPELINE_PERFORM_DELAY) + self.class.perform_in( + delay, + pipeline_tracker.id, + pipeline_tracker.stage, + pipeline_tracker.entity.id + ) end end end diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb index 3bca3015988..f047ba8fde5 100644 --- a/app/workers/ci/build_finished_worker.rb +++ b/app/workers/ci/build_finished_worker.rb @@ -15,13 +15,13 @@ module Ci ARCHIVE_TRACES_IN = 2.minutes.freeze - # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) - Ci::Build.find_by(id: build_id).try do |build| - process_build(build) - end + return unless build = Ci::Build.find_by(id: build_id) # rubocop: disable CodeReuse/ActiveRecord + return unless build.project + return if build.project.pending_delete? + + process_build(build) end - # rubocop: enable CodeReuse/ActiveRecord private diff --git a/app/workers/ci/create_downstream_pipeline_worker.rb b/app/workers/ci/create_downstream_pipeline_worker.rb new file mode 100644 index 00000000000..6d4cd2539c1 --- /dev/null +++ b/app/workers/ci/create_downstream_pipeline_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + class CreateDownstreamPipelineWorker # rubocop:disable Scalability/IdempotentWorker + include ::ApplicationWorker + include ::PipelineQueue + + sidekiq_options retry: 3 + worker_resource_boundary :cpu + + def perform(bridge_id) + ::Ci::Bridge.find_by_id(bridge_id).try do |bridge| + ::Ci::CreateDownstreamPipelineService + .new(bridge.project, bridge.user) + .execute(bridge) + end + end + end +end diff --git a/app/workers/ci/delete_unit_tests_worker.rb b/app/workers/ci/delete_unit_tests_worker.rb index d5bb72ce80c..01d909773d2 100644 --- a/app/workers/ci/delete_unit_tests_worker.rb +++ b/app/workers/ci/delete_unit_tests_worker.rb @@ -10,7 +10,7 @@ module Ci include CronjobQueue # rubocop:enable Scalability/CronWorkerContext - feature_category :continuous_integration + feature_category :code_testing idempotent! def perform diff --git a/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb b/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb index 2af07cf6f93..cde3d71286e 100644 --- a/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb +++ b/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb @@ -14,7 +14,7 @@ module Ci deduplicate :until_executed, including_scheduled: true idempotent! - feature_category :continuous_integration + feature_category :build_artifacts def perform service = ::Ci::PipelineArtifacts::DestroyAllExpiredService.new diff --git a/app/workers/ci/stuck_builds/drop_running_worker.rb b/app/workers/ci/stuck_builds/drop_running_worker.rb new file mode 100644 index 00000000000..db571fdc38d --- /dev/null +++ b/app/workers/ci/stuck_builds/drop_running_worker.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Ci + module StuckBuilds + class DropRunningWorker + include ApplicationWorker + include ExclusiveLeaseGuard + + idempotent! + + # rubocop:disable Scalability/CronWorkerContext + # This is an instance-wide cleanup query, so there's no meaningful + # scope to consider this in the context of. + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + data_consistency :always + + feature_category :continuous_integration + + def perform + try_obtain_lease do + Ci::StuckBuilds::DropRunningService.new.execute + end + end + + private + + def lease_timeout + 30.minutes + end + end + end +end diff --git a/app/workers/ci/stuck_builds/drop_scheduled_worker.rb b/app/workers/ci/stuck_builds/drop_scheduled_worker.rb new file mode 100644 index 00000000000..923841771cf --- /dev/null +++ b/app/workers/ci/stuck_builds/drop_scheduled_worker.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Ci + module StuckBuilds + class DropScheduledWorker + include ApplicationWorker + include ExclusiveLeaseGuard + + idempotent! + + # rubocop:disable Scalability/CronWorkerContext + # This is an instance-wide cleanup query, so there's no meaningful + # scope to consider this in the context of. + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + data_consistency :always + + feature_category :continuous_integration + + def perform + try_obtain_lease do + Ci::StuckBuilds::DropScheduledService.new.execute + end + end + + private + + def lease_timeout + 30.minutes + end + end + end +end diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb index 9adc026ced2..7274ecf62f9 100644 --- a/app/workers/cleanup_container_repository_worker.rb +++ b/app/workers/cleanup_container_repository_worker.rb @@ -28,8 +28,8 @@ class CleanupContainerRepositoryWorker end result = Projects::ContainerRepository::CleanupTagsService - .new(project, current_user, params) - .execute(container_repository) + .new(container_repository, current_user, params) + .execute if run_by_container_expiration_policy? && result[:status] == :success container_repository.reset_expiration_policy_started_at! diff --git a/app/workers/concerns/dependency_proxy/cleanup_worker.rb b/app/workers/concerns/dependency_proxy/cleanup_worker.rb new file mode 100644 index 00000000000..b668634f233 --- /dev/null +++ b/app/workers/concerns/dependency_proxy/cleanup_worker.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module DependencyProxy + module CleanupWorker + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + def perform_work + return unless artifact + + log_metadata(artifact) + + artifact.destroy! + rescue StandardError + artifact&.error! + end + + def max_running_jobs + ::Gitlab::CurrentSettings.dependency_proxy_ttl_group_policy_worker_capacity + end + + def remaining_work_count + expired_artifacts.limit(max_running_jobs + 1).count + end + + private + + def model + raise NotImplementedError + end + + def log_metadata + raise NotImplementedError + end + + def log_cleanup_item + raise NotImplementedError + end + + def artifact + strong_memoize(:artifact) do + model.transaction do + to_delete = next_item + + if to_delete + to_delete.processing! + log_cleanup_item(to_delete) + end + + to_delete + end + end + end + + def expired_artifacts + model.expired + end + + def next_item + expired_artifacts.lock_next_by(:updated_at).first + end + end +end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index a377b7a2000..e1f404b250d 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -26,8 +26,7 @@ module Gitlab object = representation_class.from_json_hash(hash) # To better express in the logs what object is being imported. - self.github_id = object.attributes.fetch(:github_id) - + self.github_identifiers = object.github_identifiers info(project.id, message: 'starting importer') importer_class.new(object, project, client).execute @@ -35,10 +34,10 @@ module Gitlab Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported) info(project.id, message: 'importer finished') - rescue KeyError => e + rescue NoMethodError => e # This exception will be more useful in development when a new # Representation is created but the developer forgot to add a - # `:github_id` field. + # `:github_identifiers` field. Gitlab::Import::ImportFailureService.track( project_id: project.id, error_source: importer_class.name, @@ -72,7 +71,7 @@ module Gitlab private - attr_accessor :github_id + attr_accessor :github_identifiers def info(project_id, extra = {}) Logger.info(log_attributes(project_id, extra)) @@ -82,7 +81,7 @@ module Gitlab extra.merge( project_id: project_id, importer: importer_class.name, - github_id: github_id + github_identifiers: github_identifiers ) end end diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb index eebea30655c..6f91418e38c 100644 --- a/app/workers/concerns/worker_attributes.rb +++ b/app/workers/concerns/worker_attributes.rb @@ -46,8 +46,14 @@ module WorkerAttributes set_class_attribute(:feature_category, :not_owned) end + # Special case: if a worker is not owned, get the feature category + # (if present) from the calling context. def get_feature_category - get_class_attribute(:feature_category) + feature_category = get_class_attribute(:feature_category) + + return feature_category unless feature_category == :not_owned + + Gitlab::ApplicationContext.current_context_attribute('meta.feature_category') || feature_category end def feature_category_not_owned? diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb index 433ed5e0ea4..69f5906f54c 100644 --- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb +++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb @@ -21,6 +21,7 @@ module ContainerExpirationPolicies cleanup_tags_service_original_size cleanup_tags_service_before_truncate_size cleanup_tags_service_after_truncate_size + cleanup_tags_service_cached_tags_count cleanup_tags_service_before_delete_size cleanup_tags_service_deleted_size ].freeze @@ -147,13 +148,27 @@ module ContainerExpirationPolicies log_extra_metadata_on_done(field, value) end + log_truncate(result) + log_cache_ratio(result) + log_extra_metadata_on_done(:running_jobs_count, running_jobs_count) + end + + def log_cache_ratio(result) + tags_count = result.payload[:cleanup_tags_service_after_truncate_size] + cached_tags_count = result.payload[:cleanup_tags_service_cached_tags_count] + + return unless tags_count && cached_tags_count && tags_count != 0 + + log_extra_metadata_on_done(:cleanup_tags_service_cache_hit_ratio, cached_tags_count / tags_count.to_f) + end + + def log_truncate(result) before_truncate_size = result.payload[:cleanup_tags_service_before_truncate_size] after_truncate_size = result.payload[:cleanup_tags_service_after_truncate_size] truncated = before_truncate_size && after_truncate_size && before_truncate_size != after_truncate_size log_extra_metadata_on_done(:cleanup_tags_service_truncated, !!truncated) - log_extra_metadata_on_done(:running_jobs_count, running_jobs_count) end def policy diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb index a791fe5d350..5fcbd74ddad 100644 --- a/app/workers/container_expiration_policy_worker.rb +++ b/app/workers/container_expiration_policy_worker.rb @@ -45,8 +45,6 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo # not perfomed with a delay # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63635#note_603771207 def use_replica_if_available(&blk) - return yield unless ::Gitlab::Database::LoadBalancing.enable? - ::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries(&blk) end diff --git a/app/workers/create_note_diff_file_worker.rb b/app/workers/create_note_diff_file_worker.rb index 4bea4fc872e..8481fd0a2ab 100644 --- a/app/workers/create_note_diff_file_worker.rb +++ b/app/workers/create_note_diff_file_worker.rb @@ -10,8 +10,10 @@ class CreateNoteDiffFileWorker # rubocop:disable Scalability/IdempotentWorker feature_category :code_review def perform(diff_note_id) - diff_note = DiffNote.find(diff_note_id) + return unless diff_note_id.present? - diff_note.create_diff_file + diff_note = DiffNote.find_by_id(diff_note_id) # rubocop: disable CodeReuse/ActiveRecord + + diff_note&.create_diff_file end end diff --git a/app/workers/database/drop_detached_partitions_worker.rb b/app/workers/database/drop_detached_partitions_worker.rb index f9c8ce57a36..1e4dc20a0d2 100644 --- a/app/workers/database/drop_detached_partitions_worker.rb +++ b/app/workers/database/drop_detached_partitions_worker.rb @@ -10,7 +10,7 @@ module Database idempotent! def perform - Gitlab::Database::Partitioning::DetachedPartitionDropper.new.perform + Gitlab::Database::Partitioning.drop_detached_partitions ensure Gitlab::Database::Partitioning::PartitionMonitoring.new.report_metrics end diff --git a/app/workers/dependency_proxy/cleanup_blob_worker.rb b/app/workers/dependency_proxy/cleanup_blob_worker.rb new file mode 100644 index 00000000000..054bc5854a3 --- /dev/null +++ b/app/workers/dependency_proxy/cleanup_blob_worker.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module DependencyProxy + class CleanupBlobWorker + include ApplicationWorker + include LimitedCapacity::Worker + include Gitlab::Utils::StrongMemoize + include DependencyProxy::CleanupWorker + + data_consistency :always + + sidekiq_options retry: 3 + + queue_namespace :dependency_proxy_blob + feature_category :dependency_proxy + urgency :low + worker_resource_boundary :unknown + idempotent! + + private + + def model + DependencyProxy::Blob + end + + def log_metadata(blob) + log_extra_metadata_on_done(:dependency_proxy_blob_id, blob.id) + log_extra_metadata_on_done(:group_id, blob.group_id) + end + + def log_cleanup_item(blob) + logger.info( + structured_payload( + group_id: blob.group_id, + dependency_proxy_blob_id: blob.id + ) + ) + end + end +end diff --git a/app/workers/dependency_proxy/cleanup_manifest_worker.rb b/app/workers/dependency_proxy/cleanup_manifest_worker.rb new file mode 100644 index 00000000000..1186efa2034 --- /dev/null +++ b/app/workers/dependency_proxy/cleanup_manifest_worker.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module DependencyProxy + class CleanupManifestWorker + include ApplicationWorker + include LimitedCapacity::Worker + include Gitlab::Utils::StrongMemoize + include DependencyProxy::CleanupWorker + + data_consistency :always + + sidekiq_options retry: 3 + + queue_namespace :dependency_proxy_manifest + feature_category :dependency_proxy + urgency :low + worker_resource_boundary :unknown + idempotent! + + private + + def model + DependencyProxy::Manifest + end + + def log_metadata(manifest) + log_extra_metadata_on_done(:dependency_proxy_manifest_id, manifest.id) + log_extra_metadata_on_done(:group_id, manifest.group_id) + end + + def log_cleanup_item(manifest) + logger.info( + structured_payload( + group_id: manifest.group_id, + dependency_proxy_manifest_id: manifest.id + ) + ) + end + end +end diff --git a/app/workers/dependency_proxy/image_ttl_group_policy_worker.rb b/app/workers/dependency_proxy/image_ttl_group_policy_worker.rb new file mode 100644 index 00000000000..fed469e6dc8 --- /dev/null +++ b/app/workers/dependency_proxy/image_ttl_group_policy_worker.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module DependencyProxy + class ImageTtlGroupPolicyWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + data_consistency :always + + feature_category :dependency_proxy + + UPDATE_BATCH_SIZE = 100 + + def perform + DependencyProxy::ImageTtlGroupPolicy.enabled.each do |policy| + # Technical Debt: change to read_before https://gitlab.com/gitlab-org/gitlab/-/issues/341536 + qualified_blobs = policy.group.dependency_proxy_blobs.active.updated_before(policy.ttl) + qualified_manifests = policy.group.dependency_proxy_manifests.active.updated_before(policy.ttl) + + enqueue_blob_cleanup_job if expire_artifacts(qualified_blobs, DependencyProxy::Blob) + enqueue_manifest_cleanup_job if expire_artifacts(qualified_manifests, DependencyProxy::Manifest) + end + + log_counts + end + + private + + def expire_artifacts(artifacts, model) + rows_updated = false + + artifacts.each_batch(of: UPDATE_BATCH_SIZE) do |batch| + rows = batch.update_all(status: :expired) + rows_updated ||= rows > 0 + end + + rows_updated + end + + def enqueue_blob_cleanup_job + DependencyProxy::CleanupBlobWorker.perform_with_capacity + end + + def enqueue_manifest_cleanup_job + DependencyProxy::CleanupManifestWorker.perform_with_capacity + end + + def log_counts + use_replica_if_available do + expired_blob_count = DependencyProxy::Blob.expired.count + expired_manifest_count = DependencyProxy::Manifest.expired.count + processing_blob_count = DependencyProxy::Blob.processing.count + processing_manifest_count = DependencyProxy::Manifest.processing.count + error_blob_count = DependencyProxy::Blob.error.count + error_manifest_count = DependencyProxy::Manifest.error.count + + log_extra_metadata_on_done(:expired_dependency_proxy_blob_count, expired_blob_count) + log_extra_metadata_on_done(:expired_dependency_proxy_manifest_count, expired_manifest_count) + log_extra_metadata_on_done(:processing_dependency_proxy_blob_count, processing_blob_count) + log_extra_metadata_on_done(:processing_dependency_proxy_manifest_count, processing_manifest_count) + log_extra_metadata_on_done(:error_dependency_proxy_blob_count, error_blob_count) + log_extra_metadata_on_done(:error_dependency_proxy_manifest_count, error_manifest_count) + end + end + + def use_replica_if_available(&block) + ::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries(&block) + end + end +end diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index 65d387f73ed..295703cc1c3 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -10,7 +10,7 @@ class ExpireBuildArtifactsWorker # rubocop:disable Scalability/IdempotentWorker include CronjobQueue # rubocop:enable Scalability/CronWorkerContext - feature_category :continuous_integration + feature_category :build_artifacts def perform service = Ci::JobArtifacts::DestroyAllExpiredService.new diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb index 96378acca08..77b8f59e365 100644 --- a/app/workers/expire_build_instance_artifacts_worker.rb +++ b/app/workers/expire_build_instance_artifacts_worker.rb @@ -7,7 +7,7 @@ class ExpireBuildInstanceArtifactsWorker # rubocop:disable Scalability/Idempoten sidekiq_options retry: 3 - feature_category :continuous_integration + feature_category :build_artifacts # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb index 401fe1dc1e5..7374f650546 100644 --- a/app/workers/expire_job_cache_worker.rb +++ b/app/workers/expire_job_cache_worker.rb @@ -10,11 +10,9 @@ class ExpireJobCacheWorker # rubocop:disable Scalability/IdempotentWorker queue_namespace :pipeline_cache urgency :high - # This worker should be idempotent, but we're switching to data_consistency - # :sticky and there is an ongoing incompatibility, so it needs to be disabled for - # now. The following line can be uncommented and this comment removed once - # https://gitlab.com/gitlab-org/gitlab/-/issues/325291 is resolved. - # idempotent! + + deduplicate :until_executing, including_scheduled: true + idempotent! # rubocop: disable CodeReuse/ActiveRecord def perform(job_id) diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb index 006b79dbff4..5197c1e1e88 100644 --- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb +++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb @@ -18,36 +18,28 @@ module Gitlab # project - An instance of Project. def import(_, project) + @project = project project.after_import - report_import_time(project) + report_import_time end - def report_import_time(project) - duration = Time.zone.now - project.created_at + private - histogram.observe({ project: project.full_path }, duration) - counter.increment + attr_reader :project + + def report_import_time + metrics.track_finished_import info( project.id, message: "GitHub project import finished", - duration_s: duration.round(2), + duration_s: metrics.duration.round(2), object_counts: ::Gitlab::GithubImport::ObjectCounter.summary(project) ) end - def histogram - @histogram ||= Gitlab::Metrics.histogram( - :github_importer_total_duration_seconds, - 'Total time spent importing GitHub projects, in seconds' - ) - end - - def counter - @counter ||= Gitlab::Metrics.counter( - :github_importer_imported_projects, - 'The number of imported GitHub projects' - ) + def metrics + @metrics ||= Gitlab::Import::Metrics.new(:github_importer, project) end end end diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb index 715c39caf42..cc6a2255160 100644 --- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb @@ -31,6 +31,22 @@ module Gitlab project.import_state.refresh_jid_expiration ImportPullRequestsWorker.perform_async(project.id) + rescue StandardError => e + Gitlab::Import::ImportFailureService.track( + project_id: project.id, + error_source: self.class.name, + exception: e, + fail_import: abort_on_failure, + metrics: true + ) + + raise(e) + end + + private + + def abort_on_failure + true end end end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb index d76d36531d1..71d0247bae0 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb @@ -27,6 +27,22 @@ module Gitlab { waiter.key => waiter.jobs_remaining }, :pull_requests_merged_by ) + rescue StandardError => e + Gitlab::Import::ImportFailureService.track( + project_id: project.id, + error_source: self.class.name, + exception: e, + fail_import: abort_on_failure, + metrics: true + ) + + raise(e) + end + + private + + def abort_on_failure + true end end end diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb index 227b7c304b0..3e914cc7590 100644 --- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb @@ -33,6 +33,17 @@ module Gitlab counter.increment ImportBaseDataWorker.perform_async(project.id) + + rescue StandardError => e + Gitlab::Import::ImportFailureService.track( + project_id: project.id, + error_source: self.class.name, + exception: e, + fail_import: abort_on_failure, + metrics: true + ) + + raise(e) end def counter diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb index e0c4502ed1a..22e2a8e95f4 100644 --- a/app/workers/issue_placement_worker.rb +++ b/app/workers/issue_placement_worker.rb @@ -31,7 +31,7 @@ class IssuePlacementWorker # while preserving creation order. to_place = Issue .relative_positioning_query_base(issue) - .where(relative_position: nil) + .with_null_relative_position .order({ created_at: :asc }, { id: :asc }) .limit(QUERY_LIMIT + 1) .to_a diff --git a/app/workers/namespaces/in_product_marketing_emails_worker.rb b/app/workers/namespaces/in_product_marketing_emails_worker.rb index 49e65d59e83..470fba1227d 100644 --- a/app/workers/namespaces/in_product_marketing_emails_worker.rb +++ b/app/workers/namespaces/in_product_marketing_emails_worker.rb @@ -8,7 +8,7 @@ module Namespaces include CronjobQueue # rubocop:disable Scalability/CronWorkerContext - feature_category :subgroups + feature_category :experimentation_activation urgency :low def perform diff --git a/app/workers/namespaces/onboarding_issue_created_worker.rb b/app/workers/namespaces/onboarding_issue_created_worker.rb index 81d105ab19c..aab5767e0f1 100644 --- a/app/workers/namespaces/onboarding_issue_created_worker.rb +++ b/app/workers/namespaces/onboarding_issue_created_worker.rb @@ -8,7 +8,7 @@ module Namespaces sidekiq_options retry: 3 - feature_category :issue_tracking + feature_category :onboarding urgency :low deduplicate :until_executing diff --git a/app/workers/namespaces/onboarding_pipeline_created_worker.rb b/app/workers/namespaces/onboarding_pipeline_created_worker.rb index f9a6b734586..4172e286474 100644 --- a/app/workers/namespaces/onboarding_pipeline_created_worker.rb +++ b/app/workers/namespaces/onboarding_pipeline_created_worker.rb @@ -8,7 +8,7 @@ module Namespaces sidekiq_options retry: 3 - feature_category :subgroups + feature_category :onboarding urgency :low deduplicate :until_executing diff --git a/app/workers/namespaces/onboarding_progress_worker.rb b/app/workers/namespaces/onboarding_progress_worker.rb index b77db1aec5e..77a31d85a9a 100644 --- a/app/workers/namespaces/onboarding_progress_worker.rb +++ b/app/workers/namespaces/onboarding_progress_worker.rb @@ -8,7 +8,7 @@ module Namespaces sidekiq_options retry: 3 - feature_category :product_analytics + feature_category :onboarding worker_resource_boundary :cpu urgency :low diff --git a/app/workers/namespaces/onboarding_user_added_worker.rb b/app/workers/namespaces/onboarding_user_added_worker.rb index 6a189e81b95..4d17cf9a6e2 100644 --- a/app/workers/namespaces/onboarding_user_added_worker.rb +++ b/app/workers/namespaces/onboarding_user_added_worker.rb @@ -8,7 +8,7 @@ module Namespaces sidekiq_options retry: 3 - feature_category :users + feature_category :onboarding urgency :low idempotent! diff --git a/app/workers/packages/composer/cache_cleanup_worker.rb b/app/workers/packages/composer/cache_cleanup_worker.rb index 19babf63967..c80d6ea45d8 100644 --- a/app/workers/packages/composer/cache_cleanup_worker.rb +++ b/app/workers/packages/composer/cache_cleanup_worker.rb @@ -14,19 +14,7 @@ module Packages idempotent! def perform - ::Packages::Composer::CacheFile.without_namespace.find_in_batches do |cache_files| - cache_files.each(&:destroy) - rescue ActiveRecord::RecordNotFound - # ignore. likely due to object already being deleted. - end - - ::Packages::Composer::CacheFile.expired.find_in_batches do |cache_files| - cache_files.each(&:destroy) - rescue ActiveRecord::RecordNotFound - # ignore. likely due to object already being deleted. - end - rescue StandardError => e - Gitlab::ErrorTracking.log_exception(e) + # no-op: to be removed after 14.5 https://gitlab.com/gitlab-org/gitlab/-/issues/333694 end end end diff --git a/app/workers/packages/composer/cache_update_worker.rb b/app/workers/packages/composer/cache_update_worker.rb index 874993a1325..5600af6ce24 100644 --- a/app/workers/packages/composer/cache_update_worker.rb +++ b/app/workers/packages/composer/cache_update_worker.rb @@ -7,20 +7,14 @@ module Packages data_consistency :always - sidekiq_options retry: 3 + sidekiq_options retry: false feature_category :package_registry idempotent! - def perform(project_id, package_name, last_page_sha) - project = Project.find_by_id(project_id) - - return unless project - - Gitlab::Composer::Cache.new(project: project, name: package_name, last_page_sha: last_page_sha).execute - rescue StandardError => e - Gitlab::ErrorTracking.log_exception(e, project_id: project_id) + def perform(*args) + # no-op: to be removed after 14.5 https://gitlab.com/gitlab-org/gitlab/-/issues/333694 end end end diff --git a/app/workers/packages/debian/generate_distribution_worker.rb b/app/workers/packages/debian/generate_distribution_worker.rb index b9b157d25d2..1eff3ea02dd 100644 --- a/app/workers/packages/debian/generate_distribution_worker.rb +++ b/app/workers/packages/debian/generate_distribution_worker.rb @@ -2,7 +2,7 @@ module Packages module Debian - class GenerateDistributionWorker # rubocop:disable Scalability/IdempotentWorker + class GenerateDistributionWorker include ApplicationWorker data_consistency :always diff --git a/app/workers/pages_remove_worker.rb b/app/workers/pages_remove_worker.rb deleted file mode 100644 index 4de99b8654d..00000000000 --- a/app/workers/pages_remove_worker.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# TODO: remove this worker https://gitlab.com/gitlab-org/gitlab/-/issues/340641 -class PagesRemoveWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - feature_category :pages - loggable_arguments 0 - - def perform(project_id) - # no-op - end -end diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb index 322f92d376b..c67f3860a50 100644 --- a/app/workers/pipeline_hooks_worker.rb +++ b/app/workers/pipeline_hooks_worker.rb @@ -12,9 +12,10 @@ class PipelineHooksWorker # rubocop:disable Scalability/IdempotentWorker # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - Ci::Pipeline - .find_by(id: pipeline_id) - .try(:execute_hooks) + pipeline = Ci::Pipeline.find_by(id: pipeline_id) + return unless pipeline + + Ci::Pipelines::HookService.new(pipeline).execute end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index 9cd471a5ab6..9370b361068 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -14,7 +14,7 @@ class PipelineProcessWorker loggable_arguments 1 idempotent! - deduplicate :until_executing, feature_flag: :ci_idempotent_pipeline_process_worker + deduplicate :until_executing # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index dd0f14a5cab..12042ebc4f0 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -27,8 +27,9 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker user, ref: schedule.ref) .execute!(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) - rescue Ci::CreatePipelineService::CreateError - # no-op. This is a user operation error such as corrupted .gitlab-ci.yml. + rescue Ci::CreatePipelineService::CreateError => e + # This is a user operation error such as corrupted .gitlab-ci.yml. Log the error for debugging purpose. + log_extra_metadata_on_done(:pipeline_creation_error, e) rescue StandardError => e error(schedule, e) end @@ -37,10 +38,16 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker def error(schedule, error) failed_creation_counter.increment + log_error(schedule, error) + track_error(schedule, error) + end + def log_error(schedule, error) Gitlab::AppLogger.error "Failed to create a scheduled pipeline. " \ "schedule_id: #{schedule.id} message: #{error.message}" + end + def track_error(schedule, error) Gitlab::ErrorTracking .track_and_raise_for_dev_exception(error, issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/41231', diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index a2b2686c8d5..72004f7568c 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -2,6 +2,7 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + include ExclusiveLeaseGuard # rubocop:disable Scalability/CronWorkerContext # This is an instance-wide cleanup query, so there's no meaningful @@ -12,25 +13,19 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker data_consistency :always feature_category :continuous_integration - worker_resource_boundary :cpu - - EXCLUSIVE_LEASE_KEY = 'stuck_ci_builds_worker_lease' def perform - return unless try_obtain_lease - - Ci::StuckBuilds::DropService.new.execute + Ci::StuckBuilds::DropRunningWorker.perform_in(20.minutes) + Ci::StuckBuilds::DropScheduledWorker.perform_in(40.minutes) - remove_lease + try_obtain_lease do + Ci::StuckBuilds::DropPendingService.new.execute + end end private - def try_obtain_lease - @uuid = Gitlab::ExclusiveLease.new(EXCLUSIVE_LEASE_KEY, timeout: 30.minutes).try_obtain - end - - def remove_lease - Gitlab::ExclusiveLease.cancel(EXCLUSIVE_LEASE_KEY, @uuid) + def lease_timeout + 30.minutes end end |