diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-18 08:17:02 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-18 08:17:02 +0000 |
commit | b39512ed755239198a9c294b6a45e65c05900235 (patch) | |
tree | d234a3efade1de67c46b9e5a38ce813627726aa7 /app | |
parent | d31474cf3b17ece37939d20082b07f6657cc79a9 (diff) | |
download | gitlab-ce-b39512ed755239198a9c294b6a45e65c05900235.tar.gz |
Add latest changes from gitlab-org/gitlab@15-3-stable-eev15.3.0-rc42
Diffstat (limited to 'app')
1435 files changed, 18411 insertions, 9432 deletions
diff --git a/app/assets/images/auth_buttons/jwt_64.png b/app/assets/images/auth_buttons/jwt_64.png Binary files differindex ca97ae47002..fcfecde23d3 100644 --- a/app/assets/images/auth_buttons/jwt_64.png +++ b/app/assets/images/auth_buttons/jwt_64.png diff --git a/app/assets/images/auth_buttons/salesforce_64.png b/app/assets/images/auth_buttons/salesforce_64.png Binary files differindex c8a86a0c515..b562e09c20f 100644 --- a/app/assets/images/auth_buttons/salesforce_64.png +++ b/app/assets/images/auth_buttons/salesforce_64.png diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue index 147de529eea..5516fd0daf6 100644 --- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue +++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue @@ -1,7 +1,8 @@ <script> -import { GlDatepicker, GlFormInput, GlFormGroup } from '@gitlab/ui'; +import { GlDatepicker, GlFormGroup } from '@gitlab/ui'; import { __ } from '~/locale'; +import { getDateInFuture } from '~/lib/utils/datetime_utility'; export default { name: 'ExpiresAtField', @@ -10,7 +11,6 @@ export default { }, components: { GlDatepicker, - GlFormInput, GlFormGroup, MaxExpirationDateMessage: () => import('ee_component/access_tokens/components/max_expiration_date_message.vue'), @@ -32,20 +32,28 @@ export default { default: () => null, }, }, + computed: { + in30Days() { + const today = new Date(); + return getDateInFuture(today, 30); + }, + }, }; </script> <template> <gl-form-group :label="$options.i18n.label" :label-for="inputAttrs.id"> - <gl-datepicker :target="null" :min-date="minDate" :max-date="maxDate"> - <gl-form-input - v-bind="inputAttrs" - class="datepicker gl-datepicker-input" - autocomplete="off" - inputmode="none" - data-qa-selector="expiry_date_field" - /> - </gl-datepicker> + <gl-datepicker + :target="null" + :min-date="minDate" + :max-date="maxDate" + :default-date="in30Days" + show-clear-button + :input-name="inputAttrs.name" + :input-id="inputAttrs.id" + :placeholder="inputAttrs.placeholder" + data-qa-selector="expiry_date_field" + /> <template #description> <max-expiration-date-message :max-date="maxDate" /> </template> diff --git a/app/assets/javascripts/access_tokens/components/projects_field.vue b/app/assets/javascripts/access_tokens/components/projects_field.vue deleted file mode 100644 index 066cea5e90c..00000000000 --- a/app/assets/javascripts/access_tokens/components/projects_field.vue +++ /dev/null @@ -1,69 +0,0 @@ -<script> -import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui'; -import ProjectsTokenSelector from './projects_token_selector.vue'; - -export default { - name: 'ProjectsField', - ALL_PROJECTS: 'ALL_PROJECTS', - SELECTED_PROJECTS: 'SELECTED_PROJECTS', - components: { GlFormGroup, GlFormRadio, GlFormText, ProjectsTokenSelector }, - props: { - inputAttrs: { - type: Object, - required: true, - }, - }, - data() { - return { - selectedRadio: !this.inputAttrs.value - ? this.$options.ALL_PROJECTS - : this.$options.SELECTED_PROJECTS, - selectedProjects: [], - }; - }, - computed: { - allProjectsRadioSelected() { - return this.selectedRadio === this.$options.ALL_PROJECTS; - }, - hiddenInputValue() { - return this.allProjectsRadioSelected - ? null - : this.selectedProjects.map((project) => project.id).join(','); - }, - initialProjectIds() { - if (!this.inputAttrs.value) { - return []; - } - - return this.inputAttrs.value.split(','); - }, - }, - methods: { - handleTokenSelectorFocus() { - this.selectedRadio = this.$options.SELECTED_PROJECTS; - }, - }, -}; -</script> - -<template> - <div> - <gl-form-group :label="__('Projects')" label-class="gl-pb-0!"> - <gl-form-text class="gl-pb-3">{{ - __('Set access permissions for this token.') - }}</gl-form-text> - <gl-form-radio v-model="selectedRadio" :value="$options.ALL_PROJECTS">{{ - __('All projects') - }}</gl-form-radio> - <gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{ - __('Selected projects') - }}</gl-form-radio> - <input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" :value="hiddenInputValue" /> - <projects-token-selector - v-model="selectedProjects" - :initial-project-ids="initialProjectIds" - @focus="handleTokenSelectorFocus" - /> - </gl-form-group> - </div> -</template> diff --git a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue deleted file mode 100644 index 4843c52fcbb..00000000000 --- a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue +++ /dev/null @@ -1,156 +0,0 @@ -<script> -import { - GlTokenSelector, - GlAvatar, - GlAvatarLabeled, - GlIntersectionObserver, - GlLoadingIcon, -} from '@gitlab/ui'; -import produce from 'immer'; - -import { convertToGraphQLIds, convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils'; - -import getProjectsQuery from '../graphql/queries/get_projects.query.graphql'; - -const DEBOUNCE_DELAY = 250; -const PROJECTS_PER_PAGE = 20; -const GRAPHQL_ENTITY_TYPE = 'Project'; - -export default { - name: 'ProjectsTokenSelector', - components: { - GlTokenSelector, - GlAvatar, - GlAvatarLabeled, - GlIntersectionObserver, - GlLoadingIcon, - }, - model: { - prop: 'selectedProjects', - }, - props: { - selectedProjects: { - type: Array, - required: true, - }, - initialProjectIds: { - type: Array, - required: true, - }, - }, - apollo: { - projects: { - query: getProjectsQuery, - debounce: DEBOUNCE_DELAY, - variables() { - return { - search: this.searchQuery, - after: null, - first: PROJECTS_PER_PAGE, - }; - }, - update({ projects }) { - return { - list: convertNodeIdsFromGraphQLIds(projects.nodes), - pageInfo: projects.pageInfo, - }; - }, - result() { - this.isLoadingMoreProjects = false; - this.isSearching = false; - }, - }, - initialProjects: { - query: getProjectsQuery, - variables() { - return { - ids: convertToGraphQLIds(GRAPHQL_ENTITY_TYPE, this.initialProjectIds), - }; - }, - manual: true, - skip() { - return !this.initialProjectIds.length; - }, - result({ data: { projects } }) { - this.$emit('input', convertNodeIdsFromGraphQLIds(projects.nodes)); - }, - }, - }, - data() { - return { - projects: { - list: [], - pageInfo: {}, - }, - searchQuery: '', - isLoadingMoreProjects: false, - isSearching: false, - }; - }, - methods: { - handleSearch(query) { - this.isSearching = true; - this.searchQuery = query; - }, - loadMoreProjects() { - this.isLoadingMoreProjects = true; - - this.$apollo.queries.projects.fetchMore({ - variables: { - after: this.projects.pageInfo.endCursor, - first: PROJECTS_PER_PAGE, - }, - updateQuery(previousResult, { fetchMoreResult: { projects: newProjects } }) { - const { projects: previousProjects } = previousResult; - - return produce(previousResult, (draftData) => { - draftData.projects.nodes = [...previousProjects.nodes, ...newProjects.nodes]; - draftData.projects.pageInfo = newProjects.pageInfo; - }); - }, - }); - }, - }, -}; -</script> - -<template> - <div class="gl-relative"> - <gl-token-selector - :selected-tokens="selectedProjects" - :dropdown-items="projects.list" - :loading="isSearching" - :placeholder="__('Select projects')" - menu-class="gl-w-full! gl-max-w-full!" - @input="$emit('input', $event)" - @focus="$emit('focus', $event)" - @text-input="handleSearch" - @keydown.enter.prevent - > - <template #token-content="{ token: project }"> - <gl-avatar - :entity-id="project.id" - :entity-name="project.name" - :src="project.avatarUrl" - :size="16" - /> - {{ project.nameWithNamespace }} - </template> - <template #dropdown-item-content="{ dropdownItem: project }"> - <gl-avatar-labeled - :entity-id="project.id" - :entity-name="project.name" - :size="32" - :src="project.avatarUrl" - :label="project.name" - :sub-label="project.nameWithNamespace" - /> - </template> - <template #dropdown-footer> - <gl-intersection-observer v-if="projects.pageInfo.hasNextPage" @appear="loadMoreProjects"> - <gl-loading-icon v-if="isLoadingMoreProjects" class="gl-mb-3" size="sm" /> - </gl-intersection-observer> - </template> - </gl-token-selector> - </div> -</template> diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql index a5fc70b9ca6..6fb17bf0ee6 100644 --- a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql +++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql @@ -22,7 +22,6 @@ query accessTokensGetProjects( avatarUrl } pageInfo { - __typename ...PageInfo } } diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index a7a03523e7f..9801aa08e28 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -1,6 +1,5 @@ import Vue from 'vue'; -import createFlash from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { parseRailsFormFields } from '~/lib/utils/forms'; import { __, sprintf } from '~/locale'; @@ -99,62 +98,6 @@ export const initNewAccessTokenApp = () => { }); }; -export const initProjectsField = () => { - const el = document.querySelector('.js-access-tokens-projects'); - - if (!el) { - return null; - } - - const { projects: inputAttrs } = parseRailsFormFields(el); - - if (window.gon.features.personalAccessTokensScopedToProjects) { - return new Promise((resolve) => { - Promise.all([ - import('./components/projects_field.vue'), - import('vue-apollo'), - import('~/lib/graphql'), - ]) - .then( - ([ - { default: ProjectsField }, - { default: VueApollo }, - { default: createDefaultClient }, - ]) => { - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); - - Vue.use(VueApollo); - - resolve( - new Vue({ - el, - apolloProvider, - render(h) { - return h(ProjectsField, { - props: { - inputAttrs, - }, - }); - }, - }), - ); - }, - ) - .catch(() => { - createFlash({ - message: __( - 'An error occurred while loading the access tokens form, please try again.', - ), - }); - }); - }); - } - - return null; -}; - export const initTokensApp = () => { const el = document.getElementById('js-tokens-app'); diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue index 46e7ac3cf28..6b140590938 100644 --- a/app/assets/javascripts/admin/deploy_keys/components/table.vue +++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue @@ -139,15 +139,15 @@ export default { title, fingerprint, fingerprint_sha256, - projects_with_write_access, - created_at, + projects_with_write_access: projects, + created_at: created, }) => ({ id, title, fingerprint, fingerprint_sha256, - projects: projects_with_write_access, - created: created_at, + projects, + created, }), ); } catch (error) { diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql index ac9304391f9..3cd3f2d92f8 100644 --- a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql @@ -5,7 +5,6 @@ query getIntegrations($projectPath: ID!) { id alertManagementIntegrations { nodes { - __typename ...IntegrationItem } } 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 b151e1605da..b2e554bc913 100644 --- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -285,7 +285,7 @@ export default { :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> <div> - <div data-testid="project-name">{{ project.name }}</div> + <div data-testid="project-name" data-qa-selector="project_name">{{ project.name }}</div> <div class="gl-text-gray-500" data-testid="project-full-path"> {{ project.fullPath }} </div> diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js index 71b7ca29bad..1887f2affc3 100644 --- a/app/assets/javascripts/analytics/shared/utils.js +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -19,24 +19,22 @@ export const toYmd = (date) => dateFormat(date, dateFormats.isoDate); * @returns {Object} */ export const extractFilterQueryParameters = (url = '') => { - /* eslint-disable camelcase */ const { - source_branch_name = null, - target_branch_name = null, - author_username = null, - milestone_title = null, - assignee_username = [], - label_name = [], + source_branch_name: selectedSourceBranch = null, + target_branch_name: selectedTargetBranch = null, + author_username: selectedAuthor = null, + milestone_title: selectedMilestone = null, + assignee_username: selectedAssigneeList = [], + label_name: selectedLabelList = [], } = urlQueryToFilter(url); - /* eslint-enable camelcase */ return { - selectedSourceBranch: source_branch_name, - selectedTargetBranch: target_branch_name, - selectedAuthor: author_username, - selectedMilestone: milestone_title, - selectedAssigneeList: assignee_username, - selectedLabelList: label_name, + selectedSourceBranch, + selectedTargetBranch, + selectedAuthor, + selectedMilestone, + selectedAssigneeList, + selectedLabelList, }; }; diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql index b353bcdfd0e..2bde5973600 100644 --- a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql +++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql @@ -1,5 +1,4 @@ fragment Count on UsageTrendsMeasurement { - __typename count recordedAt } diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js index c7a53288ae4..15457f28eff 100644 --- a/app/assets/javascripts/api/analytics_api.js +++ b/app/assets/javascripts/api/analytics_api.js @@ -43,9 +43,6 @@ export const getProjectValueStreamStages = (requestPath, valueStreamId) => { export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) => axios.get(joinPaths(requestPath, 'events', stageId), { params }); -export const getProjectValueStreamMetrics = (requestPath, params) => - axios.get(requestPath, { params }); - /** * Dedicated project VSA paths */ diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js index a563afc6abb..48cf346d0e6 100644 --- a/app/assets/javascripts/api/groups_api.js +++ b/app/assets/javascripts/api/groups_api.js @@ -2,6 +2,7 @@ import { DEFAULT_PER_PAGE } from '~/api'; import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; +const GROUP_PATH = '/api/:version/groups/:id'; const GROUPS_PATH = '/api/:version/groups.json'; const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups'; @@ -30,3 +31,9 @@ export function getDescendentGroups(parentGroupId, query, options, callback = () const url = buildApiUrl(DESCENDANT_GROUPS_PATH.replace(':id', parentGroupId)); return axiosGet(url, query, options, callback); } + +export function updateGroup(groupId, data = {}) { + const url = buildApiUrl(GROUP_PATH).replace(':id', groupId); + + return axios.put(url, data); +} diff --git a/app/assets/javascripts/attention_requests/components/navigation_popover.vue b/app/assets/javascripts/attention_requests/components/navigation_popover.vue deleted file mode 100644 index 804eda8f321..00000000000 --- a/app/assets/javascripts/attention_requests/components/navigation_popover.vue +++ /dev/null @@ -1,122 +0,0 @@ -<script> -import { GlPopover, GlSprintf, GlButton, GlLink, GlIcon } from '@gitlab/ui'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; - -export default { - components: { - GlPopover, - GlSprintf, - GlButton, - GlLink, - GlIcon, - UserCalloutDismisser, - }, - inject: { - message: { - default: '', - }, - observerElSelector: { - default: '', - }, - observerElToggledClass: { - default: '', - }, - featureName: { - default: '', - }, - popoverTarget: { - default: '', - }, - showAttentionIcon: { - default: false, - }, - delay: { - default: 0, - }, - popoverCssClass: { - default: '', - }, - }, - data() { - return { - showPopover: false, - popoverPlacement: this.popoverPosition(), - }; - }, - mounted() { - this.observeEl = document.querySelector(this.observerElSelector); - this.observer = new MutationObserver(this.callback); - this.observer.observe(this.observeEl, { - attributes: true, - }); - this.callback(); - - window.addEventListener('resize', () => { - this.popoverPlacement = this.popoverPosition(); - }); - }, - beforeDestroy() { - this.observer.disconnect(); - }, - methods: { - callback() { - if (this.showPopover) { - this.$root.$emit('bv::hide::popover'); - } - - setTimeout(() => this.toggleShowPopover(), this.delay); - }, - toggleShowPopover() { - this.showPopover = this.observeEl.classList.contains(this.observerElToggledClass); - }, - getPopoverTarget() { - return document.querySelector(this.popoverTarget); - }, - popoverPosition() { - if (bp.isDesktop()) { - return 'left'; - } - - return 'bottom'; - }, - }, - docsPage: helpPagePath('user/project/merge_requests/index.md', { - anchor: 'request-attention-to-a-merge-request', - }), -}; -</script> - -<template> - <user-callout-dismisser :feature-name="featureName"> - <template #default="{ shouldShowCallout, dismiss }"> - <gl-popover - v-if="shouldShowCallout" - :show-close-button="false" - :target="() => getPopoverTarget()" - :show="showPopover" - :delay="0" - triggers="manual" - :placement="popoverPlacement" - boundary="window" - no-fade - :css-classes="[popoverCssClass]" - > - <p v-for="(m, index) in message" :key="index" class="gl-mb-5"> - <gl-sprintf :message="m"> - <template #strong="{ content }"> - <strong><gl-icon v-if="showAttentionIcon" name="attention" /> {{ content }}</strong> - </template> - </gl-sprintf> - </p> - <div class="gl-display-flex gl-align-items-center"> - <gl-button size="small" variant="confirm" class="gl-mr-5" @click.prevent.stop="dismiss"> - {{ __('Got it!') }} - </gl-button> - <gl-link :href="$options.docsPage" target="_blank">{{ __('Learn more') }}</gl-link> - </div> - </gl-popover> - </template> - </user-callout-dismisser> -</template> diff --git a/app/assets/javascripts/attention_requests/index.js b/app/assets/javascripts/attention_requests/index.js deleted file mode 100644 index 2a142ab46e5..00000000000 --- a/app/assets/javascripts/attention_requests/index.js +++ /dev/null @@ -1,73 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { __ } from '~/locale'; -import createDefaultClient from '~/lib/graphql'; -import NavigationPopover from './components/navigation_popover.vue'; - -Vue.use(VueApollo); - -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -export const initTopNavPopover = () => { - const el = document.getElementById('js-need-attention-nav-onboarding'); - - if (!el) return; - - // eslint-disable-next-line no-new - new Vue({ - el, - apolloProvider, - provide: { - observerElSelector: '.user-counter.dropdown', - observerElToggledClass: 'show', - message: [ - __( - '%{strongStart}Need your attention%{strongEnd} are the merge requests that need your help to move forward, as an assignee or reviewer.', - ), - ], - featureName: 'attention_requests_top_nav', - popoverTarget: '#js-need-attention-nav', - }, - render(h) { - return h(NavigationPopover); - }, - }); -}; - -export const initSideNavPopover = () => { - const el = document.getElementById('js-need-attention-sidebar-onboarding'); - - if (!el) return; - - // eslint-disable-next-line no-new - new Vue({ - el, - apolloProvider, - provide: { - observerElSelector: '.js-right-sidebar', - observerElToggledClass: 'right-sidebar-expanded', - message: [ - __( - 'To ask someone to look at a merge request, select %{strongStart}Request attention%{strongEnd}. Select again to remove the request.', - ), - __( - 'Some actions remove attention requests, like a reviewer approving or anyone merging the merge request.', - ), - ], - featureName: 'attention_requests_side_nav', - popoverTarget: '.js-attention-request-toggle', - showAttentionIcon: true, - delay: 500, - popoverCssClass: 'attention-request-sidebar-popover', - }, - render(h) { - return h(NavigationPopover); - }, - }); -}; - -export default () => { - initTopNavPopover(); -}; diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index 300a81caa5c..e5408d0734a 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -116,11 +116,7 @@ export default { class="referenced-commands draft-note-commands" ></div> - <p - v-if="!glFeatures.mrReviewSubmitComment" - class="draft-note-actions d-flex" - data-qa-selector="draft_note_content" - > + <p v-if="!glFeatures.mrReviewSubmitComment" class="draft-note-actions d-flex"> <publish-button :show-count="true" :should-publish="false" diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index 3cd1a2525e9..111b670596b 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -2,10 +2,20 @@ import { mapActions, mapGetters } from 'vuex'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants'; +import { PREVENT_LEAVING_PENDING_REVIEW } from '../i18n'; import PreviewDropdown from './preview_dropdown.vue'; import PublishButton from './publish_button.vue'; import SubmitDropdown from './submit_dropdown.vue'; +function closeInterrupt(event) { + event.preventDefault(); + + // This is the correct way to write backwards-compatible beforeunload listeners + // https://developer.chrome.com/blog/page-lifecycle-api/#the-beforeunload-event + /* eslint-disable-next-line no-return-assign, no-param-reassign */ + return (event.returnValue = PREVENT_LEAVING_PENDING_REVIEW); +} + export default { components: { PreviewDropdown, @@ -25,8 +35,26 @@ export default { }, mounted() { document.body.classList.add(REVIEW_BAR_VISIBLE_CLASS_NAME); + /* + * This stuff is a lot trickier than it looks. + * + * Mandatory reading: https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event + * Some notable sentences: + * - "[...] browsers may not display prompts created in beforeunload event handlers unless the + * page has been interacted with, or may even not display them at all." + * - "Especially on mobile, the beforeunload event is not reliably fired." + * - "The beforeunload event is not compatible with the back/forward cache (bfcache) [...] + * It is recommended that developers listen for beforeunload only in this scenario, and only + * when they actually have unsaved changes, so as to minimize the effect on performance." + * + * Please ensure that this is really not working before you modify it, because there are a LOT + * of scenarios where browser behavior will make it _seem_ like it's not working, but it actually + * is under the right combination of contexts. + */ + window.addEventListener('beforeunload', closeInterrupt, { capture: true }); }, beforeDestroy() { + window.removeEventListener('beforeunload', closeInterrupt, { capture: true }); document.body.classList.remove(REVIEW_BAR_VISIBLE_CLASS_NAME); }, methods: { diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index b070848cae9..54b9953270b 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -1,8 +1,11 @@ <script> -import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup } from '@gitlab/ui'; +import $ from 'jquery'; +import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlLink } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; +import Autosave from '~/autosave'; +import { helpPagePath } from '~/helpers/help_page_helper'; export default { components: { @@ -11,6 +14,7 @@ export default { GlIcon, GlForm, GlFormGroup, + GlLink, MarkdownField, }, data() { @@ -23,6 +27,11 @@ export default { ...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']), }, mounted() { + this.autosave = new Autosave( + $(this.$refs.textarea), + `submit_review_dropdown/${this.getNoteableData.id}`, + ); + // We override the Bootstrap Vue click outside behaviour // to allow for clicking in the autocomplete dropdowns // without this override the submit dropdown will close @@ -47,6 +56,8 @@ export default { await this.publishReview(noteData); + this.autosave.reset(); + if (window.mrTabs && this.note) { window.location.hash = `note_${this.getCurrentUserLastNote.id}`; window.mrTabs.tabShown('show'); @@ -60,6 +71,9 @@ export default { }, }, restrictedToolbarItems: ['full-screen'], + helpPagePath: helpPagePath('user/project/merge_requests/reviews/index.html', { + anchor: 'submit-a-review', + }), }; </script> @@ -68,19 +82,27 @@ export default { ref="dropdown" right class="submit-review-dropdown" + data-qa-selector="submit_review_dropdown" variant="info" - category="secondary" + category="primary" > <template #button-content> {{ __('Finish review') }} <gl-icon class="dropdown-chevron" name="chevron-up" /> </template> <gl-form data-testid="submit-gl-form" @submit.prevent="submitReview"> - <gl-form-group - :label="__('Summary comment (optional)')" - label-for="review-note-body" - label-class="gl-mb-2" - > + <gl-form-group label-for="review-note-body" label-class="gl-mb-2"> + <template #label> + {{ __('Summary comment (optional)') }} + <gl-link + :href="$options.helpPagePath" + :aria-label="__('More information')" + target="_blank" + class="gl-ml-2" + > + <gl-icon name="question-o" /> + </gl-link> + </template> <div class="common-note-form gfm-form"> <div class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-base gl-border-gray-100" @@ -117,13 +139,14 @@ export default { </div> </div> </gl-form-group> - <div class="gl-display-flex gl-justify-content-end gl-mt-5"> + <div class="gl-display-flex gl-justify-content-start gl-mt-5"> <gl-button :loading="isSubmitting" variant="confirm" type="submit" class="js-no-auto-disable" data-testid="submit-review-button" + data-qa-selector="submit_review_button" > {{ __('Submit review') }} </gl-button> diff --git a/app/assets/javascripts/batch_comments/i18n.js b/app/assets/javascripts/batch_comments/i18n.js new file mode 100644 index 00000000000..6cdbf00f9ca --- /dev/null +++ b/app/assets/javascripts/batch_comments/i18n.js @@ -0,0 +1,3 @@ +import { __ } from '~/locale'; + +export const PREVENT_LEAVING_PENDING_REVIEW = __('There are unsubmitted review comments.'); diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index a44b9827fe9..863d2a99972 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -1,9 +1,12 @@ import { isEmpty } from 'lodash'; + import createFlash from '~/flash'; import { scrollToElement } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; + import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants'; import service from '../../../services/drafts_service'; + import * as types from './mutation_types'; export const saveDraft = ({ dispatch }, draft) => @@ -15,6 +18,7 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) => .then((res) => res.data) .then((res) => { commit(types.ADD_NEW_DRAFT, res); + return res; }) .catch(() => { @@ -29,6 +33,7 @@ export const createNewDraft = ({ commit }, { endpoint, data }) => .then((res) => res.data) .then((res) => { commit(types.ADD_NEW_DRAFT, res); + return res; }) .catch(() => { diff --git a/app/assets/javascripts/behaviors/components/json_table.vue b/app/assets/javascripts/behaviors/components/json_table.vue new file mode 100644 index 00000000000..bb38d80c1b5 --- /dev/null +++ b/app/assets/javascripts/behaviors/components/json_table.vue @@ -0,0 +1,71 @@ +<script> +import { GlTable, GlFormInput } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlTable, + GlFormInput, + }, + props: { + fields: { + type: Array, + required: true, + }, + items: { + type: Array, + required: true, + }, + hasFilter: { + type: Boolean, + required: false, + default: false, + }, + caption: { + type: String, + required: false, + default: __('Generated with JSON data'), + }, + }, + data() { + return { + filterInput: '', + }; + }, + computed: { + cleanedFields() { + return this.fields.map((field) => { + if (typeof field === 'string') { + return field; + } + return { + key: field.key, + label: field.label, + sortable: field.sortable || false, + }; + }); + }, + }, +}; +</script> +<template> + <div class="gl-display-inline-block"> + <gl-form-input + v-if="hasFilter" + v-model="filterInput" + :placeholder="__('Type to search')" + class="gl-mb-2!" + /> + <gl-table + :fields="cleanedFields" + :items="items" + :filter="filterInput" + show-empty + class="gl-mt-0!" + > + <template v-if="caption" #table-caption> + <small>{{ caption }}</small> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js index 967c0a120cd..afab266b645 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/strike.js +++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js @@ -2,16 +2,35 @@ export default () => ({ name: 'strike', schema: { - parseDOM: [ - { - tag: 'del', + attrs: { + strike: { + default: false, + }, + inapplicable: { + default: false, }, + }, + parseDOM: [ + { tag: 'li.inapplicable > s', attrs: { inapplicable: true } }, + { tag: 'li.inapplicable > p:first-of-type > s', attrs: { inapplicable: true } }, + { tag: 's', attrs: { strike: true } }, + { tag: 'del' }, ], toDOM: () => ['s', 0], }, toMarkdown: { - open: '~~', - close: '~~', + open(_, mark) { + if (mark.attrs.strike) { + return '<s>'; + } + return mark.attrs.inapplicable ? '' : '~~'; + }, + close(_, mark) { + if (mark.attrs.strike) { + return '</s>'; + } + return mark.attrs.inapplicable ? '' : '~~'; + }, mixable: true, expelEnclosingWhitespace: true, }, diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js index 0ff59779e7d..b862d111de7 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js @@ -37,7 +37,7 @@ export default () => ({ attrs: { lang: 'math' }, }, // Matches HTML generated by Banzai::Filter::MermaidFilter, - // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js + // after being transformed by app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js { tag: 'svg.mermaid', preserveWhitespace: 'full', diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js index 10ffce9b1b8..095634340c1 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js @@ -5,8 +5,8 @@ export default () => ({ name: 'task_list_item', schema: { attrs: { - done: { - default: false, + state: { + default: null, }, }, defining: true, @@ -18,21 +18,53 @@ export default () => ({ tag: 'li.task-list-item', getAttrs: (el) => { const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox'); - return { done: checkbox && checkbox.checked }; + if (checkbox?.matches('[data-inapplicable]')) { + return { state: 'inapplicable' }; + } else if (checkbox?.checked) { + return { state: 'done' }; + } + + return {}; }, }, ], toDOM(node) { return [ 'li', - { class: 'task-list-item' }, - ['input', { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }], + { + class: () => { + if (node.attrs.state === 'inapplicable') { + return 'task-list-item inapplicable'; + } + + return 'task-list-item'; + }, + }, + [ + 'input', + { + type: 'checkbox', + class: 'task-list-item-checkbox', + checked: node.attrs.state === 'done', + 'data-inapplicable': node.attrs.state === 'inapplicable', + }, + ], ['div', { class: 'todo-content' }, 0], ]; }, }, toMarkdown(state, node) { - state.write(`[${node.attrs.done ? 'x' : ' '}] `); + switch (node.attrs.state) { + case 'done': + state.write('[x] '); + break; + case 'inapplicable': + state.write('[~] '); + break; + default: + state.write('[ ] '); + break; + } state.renderContent(node); }, }); diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index c9ae3706383..ee5c0fe5ef3 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -5,6 +5,7 @@ import { renderKroki } from './render_kroki'; import renderMath from './render_math'; import renderSandboxedMermaid from './render_sandboxed_mermaid'; import renderMetrics from './render_metrics'; +import { renderJSONTable } from './render_json_table'; // Render GitLab flavoured Markdown // @@ -15,6 +16,9 @@ $.fn.renderGFM = function renderGFM() { renderKroki(this.find('.js-render-kroki[hidden]').get()); renderMath(this.find('.js-render-math')); renderSandboxedMermaid(this.find('.js-render-mermaid')); + renderJSONTable( + Array.from(this.find('[lang="json"][data-lang-params="table"]').get()).map((e) => e.parentNode), + ); highlightCurrentUser(this.find('.gfm-project_member').get()); diff --git a/app/assets/javascripts/behaviors/markdown/render_json_table.js b/app/assets/javascripts/behaviors/markdown/render_json_table.js new file mode 100644 index 00000000000..4d9ac1d266b --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_json_table.js @@ -0,0 +1,70 @@ +import { memoize } from 'lodash'; +import Vue from 'vue'; +import { __ } from '~/locale'; +import { createAlert } from '~/flash'; + +// Async import component since we might not need it... +const JSONTable = memoize(() => + import(/* webpackChunkName: 'gfm_json_table' */ '../components/json_table.vue'), +); + +const mountParseError = (element) => { + // Let the error container be a sibling to the element. + // Otherwise, dismissing the alert causes the copy button to be misplaced. + const container = document.createElement('div'); + element.insertAdjacentElement('beforebegin', container); + + // We need to create a child element with a known selector for `createAlert` + const el = document.createElement('div'); + el.classList.add('js-json-table-error'); + + container.insertAdjacentElement('afterbegin', el); + + return createAlert({ + message: __('Unable to parse JSON'), + variant: 'warning', + parent: container, + containerSelector: '.js-json-table-error', + }); +}; + +const mountJSONTableVueComponent = (userData, element) => { + const { fields = [], items = [], filter, caption } = userData; + + const container = document.createElement('div'); + element.innerHTML = ''; + element.appendChild(container); + + return new Vue({ + el: container, + render(h) { + return h(JSONTable, { + props: { + fields, + items, + hasFilter: filter, + caption, + }, + }); + }, + }); +}; + +const renderTable = (element) => { + // Avoid rendering multiple times + if (!element || element.classList.contains('js-json-table')) { + return; + } + + element.classList.add('js-json-table'); + + try { + mountJSONTableVueComponent(JSON.parse(element.textContent), element); + } catch (e) { + mountParseError(element); + } +}; + +export const renderJSONTable = (elements) => { + elements.forEach(renderTable); +}; diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js deleted file mode 100644 index 2df0f7387fb..00000000000 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ /dev/null @@ -1,231 +0,0 @@ -import $ from 'jquery'; -import { once, countBy } from 'lodash'; -import createFlash from '~/flash'; -import { darkModeEnabled } from '~/lib/utils/color_utils'; -import { __, sprintf } from '~/locale'; -import { unrestrictedPages } from './constants'; - -// Renders diagrams and flowcharts from text using Mermaid in any element with the -// `js-render-mermaid` class. -// -// Example markup: -// -// <pre class="js-render-mermaid"> -// graph TD; -// A-- > B; -// A-- > C; -// B-- > D; -// C-- > D; -// </pre> -// - -// This is an arbitrary number; Can be iterated upon when suitable. -const MAX_CHAR_LIMIT = 2000; -// Max # of mermaid blocks that can be rendered in a page. -const MAX_MERMAID_BLOCK_LIMIT = 50; -// Max # of `&` allowed in Chaining of links syntax -const MAX_CHAINING_OF_LINKS_LIMIT = 30; -// Keep a map of mermaid blocks we've already rendered. -const elsProcessingMap = new WeakMap(); -let renderedMermaidBlocks = 0; - -let mermaidModule = {}; - -export function initMermaid(mermaid) { - let theme = 'neutral'; - - if (darkModeEnabled()) { - theme = 'dark'; - } - - mermaid.initialize({ - // mermaid core options - mermaid: { - startOnLoad: false, - }, - // mermaidAPI options - theme, - flowchart: { - useMaxWidth: true, - htmlLabels: true, - }, - secure: ['secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'htmlLabels'], - securityLevel: 'strict', - }); - - return mermaid; -} - -function importMermaidModule() { - return import(/* webpackChunkName: 'mermaid' */ 'mermaid') - .then(({ default: mermaid }) => { - mermaidModule = initMermaid(mermaid); - }) - .catch((err) => { - createFlash({ - message: sprintf(__("Can't load mermaid module: %{err}"), { err }), - }); - // eslint-disable-next-line no-console - console.error(err); - }); -} - -function shouldLazyLoadMermaidBlock(source) { - /** - * If source contains `&`, which means that it might - * contain Chaining of links a new syntax in Mermaid. - */ - if (countBy(source)['&'] > MAX_CHAINING_OF_LINKS_LIMIT) { - return true; - } - - return false; -} - -function fixElementSource(el) { - // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly. - const source = el.textContent.replace(/<br\s*\/>/g, '<br>'); - - // Remove any extra spans added by the backend syntax highlighting. - Object.assign(el, { textContent: source }); - - return { source }; -} - -function renderMermaidEl(el) { - mermaidModule.init(undefined, el, (id) => { - const source = el.textContent; - const svg = document.getElementById(id); - - // As of https://github.com/knsv/mermaid/commit/57b780a0d, - // Mermaid will make two init callbacks:one to initialize the - // flow charts, and another to initialize the Gannt charts. - // Guard against an error caused by double initialization. - if (svg.classList.contains('mermaid')) { - return; - } - - svg.classList.add('mermaid'); - - // pre > code > svg - svg.closest('pre').replaceWith(svg); - - // We need to add the original source into the DOM to allow Copy-as-GFM - // to access it. - const sourceEl = document.createElement('text'); - sourceEl.classList.add('source'); - sourceEl.setAttribute('display', 'none'); - sourceEl.textContent = source; - - svg.appendChild(sourceEl); - }); -} - -function renderMermaids($els) { - if (!$els.length) return; - - const pageName = document.querySelector('body').dataset.page; - - // A diagram may have been truncated in search results which will cause errors, so abort the render. - if (pageName === 'search:show') return; - - importMermaidModule() - .then(() => { - let renderedChars = 0; - - $els.each((i, el) => { - // Skipping all the elements which we've already queued in requestIdleCallback - if (elsProcessingMap.has(el)) { - return; - } - - const { source } = fixElementSource(el); - /** - * Restrict the rendering to a certain amount of character - * and mermaid blocks to prevent mermaidjs from hanging - * up the entire thread and causing a DoS. - */ - if ( - !unrestrictedPages.includes(pageName) && - ((source && source.length > MAX_CHAR_LIMIT) || - renderedChars > MAX_CHAR_LIMIT || - renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT || - shouldLazyLoadMermaidBlock(source)) - ) { - const html = ` - <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert"> - <div> - <div class="display-flex"> - <div>${__( - 'Warning: Displaying this diagram might cause performance issues on this page.', - )}</div> - <div class="gl-alert-actions"> - <button class="js-lazy-render-mermaid btn gl-alert-action btn-confirm btn-md gl-button">Display</button> - </div> - </div> - <button type="button" class="close" data-dismiss="alert" aria-label="Close"> - <span aria-hidden="true">×</span> - </button> - </div> - </div> - `; - - const $parent = $(el).parent(); - - if (!$parent.hasClass('lazy-alert-shown')) { - $parent.after(html); - $parent.addClass('lazy-alert-shown'); - } - - return; - } - - renderedChars += source.length; - renderedMermaidBlocks += 1; - - const requestId = window.requestIdleCallback(() => { - renderMermaidEl(el); - }); - - elsProcessingMap.set(el, requestId); - }); - }) - .catch((err) => { - createFlash({ - message: sprintf(__('Encountered an error while rendering: %{err}'), { err }), - }); - // eslint-disable-next-line no-console - console.error(err); - }); -} - -const hookLazyRenderMermaidEvent = once(() => { - $(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() { - const parent = $(this).closest('.js-lazy-render-mermaid-container'); - const pre = parent.prev(); - - const el = pre.find('.js-render-mermaid'); - - parent.remove(); - - renderMermaidEl(el); - }); -}); - -export default function renderMermaid($els) { - if (!$els.length) return; - - const visibleMermaids = $els.filter(function filter() { - return $(this).closest('details').length === 0 && $(this).is(':visible'); - }); - - renderMermaids(visibleMermaids); - - $els.closest('details').one('toggle', function toggle() { - if (this.open) { - renderMermaids($(this).find('.js-render-mermaid')); - } - }); - - hookLazyRenderMermaidEvent(); -} diff --git a/app/assets/javascripts/blob/blob_links_tracking.js b/app/assets/javascripts/blob/blob_links_tracking.js new file mode 100644 index 00000000000..9a49aa8b0fc --- /dev/null +++ b/app/assets/javascripts/blob/blob_links_tracking.js @@ -0,0 +1,25 @@ +import Tracking from '~/tracking'; + +function addBlobLinksTracking(containerSelector, eventsToTrack) { + const containerEl = document.querySelector(containerSelector); + + if (!containerEl) { + return; + } + + const eventName = 'click_link'; + const label = 'file_line_action'; + + containerEl.addEventListener('click', (e) => { + eventsToTrack.forEach((event) => { + if (e.target.matches(event.selector)) { + Tracking.event(undefined, eventName, { + label, + property: event.property, + }); + } + }); + }); +} + +export default addBlobLinksTracking; diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 9fca9860282..8062460f052 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -38,10 +38,8 @@ export function formatIssue(issue) { export function formatListIssues(listIssues) { const boardItems = {}; - let listItemsCount; const listData = listIssues.nodes.reduce((map, list) => { - listItemsCount = list.issuesCount; let sortedIssues = list.issues.edges.map((issueNode) => ({ ...issueNode.node, })); @@ -67,7 +65,7 @@ export function formatListIssues(listIssues) { }; }, {}); - return { listData, boardItems, listItemsCount }; + return { listData, boardItems }; } export function formatListsPageInfo(lists) { diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue index 10c7a3db2d3..c4a2f83ab50 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue @@ -19,6 +19,7 @@ export default { scope: __('Scope'), scopeDescription: __('Issues must match this scope to appear in this list.'), selected: __('Selected'), + requiredFieldFeedback: __('This field is required.'), }, components: { GlButton, @@ -55,12 +56,21 @@ export default { data() { return { searchValue: '', + selectedIdValid: true, }; }, + computed: { + toggleClassList() { + return `gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate ${ + this.selectedIdValid ? '' : 'gl-inset-border-1-red-400!' + }`; + }, + }, watch: { selectedId(val) { if (val) { this.$refs.dropdown.hide(true); + this.selectedIdValid = true; } }, }, @@ -74,6 +84,13 @@ export default { this.$emit('filter-items', ''); this.$emit('hide'); }, + onSubmit() { + if (!this.selectedId) { + this.selectedIdValid = false; + } else { + this.$emit('add-list'); + } + }, }, }; </script> @@ -103,11 +120,16 @@ export default { <slot name="select-list-type"></slot> - <gl-form-group class="gl-px-5 lg-mb-3 gl-max-w-full" :label="searchLabel"> + <gl-form-group + class="gl-px-5 lg-mb-3 gl-max-w-full" + :label="searchLabel" + :state="selectedIdValid" + :invalid-feedback="$options.i18n.requiredFieldFeedback" + > <gl-dropdown ref="dropdown" class="gl-mb-3 gl-max-w-full" - toggle-class="gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate" + :toggle-class="toggleClassList" boundary="viewport" @shown="setFocus" @hide="onHide" @@ -147,10 +169,9 @@ export default { <div class="gl-display-flex gl-mb-4"> <gl-button data-testid="addNewColumnButton" - :disabled="!selectedId" variant="confirm" class="gl-mr-3 gl-ml-4" - @click="$emit('add-list')" + @click="onSubmit" >{{ $options.i18n.add }}</gl-button > <gl-button data-testid="cancelAddNewColumn" @click="setAddColumnFormVisibility(false)">{{ diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index a632f5ae0ed..8dc521317cd 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -147,6 +147,9 @@ export default { showReferencePath() { return !this.isProjectBoard && this.itemReferencePath; }, + avatarSize() { + return { default: 16, lg: 24 }; + }, }, methods: { ...mapActions(['performSearch', 'setError']), @@ -359,16 +362,17 @@ export default { </span> </span> </div> - <div class="board-card-assignee gl-display-flex"> + <div class="board-card-assignee gl-display-flex gl-gap-3"> <user-avatar-link v-for="assignee in cappedAssignees" :key="assignee.id" :link-href="assigneeUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" :img-src="avatarUrl(assignee)" - :img-size="24" + :img-size="avatarSize" class="js-no-trigger" tooltip-placement="bottom" + :enforce-gl-avatar="true" > <span class="js-assignee-tooltip"> <span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 0320b4d925e..d25169b5b9d 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -138,9 +138,8 @@ export default { <template> <mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append> <gl-drawer - v-if="showSidebar" v-bind="$attrs" - :open="isSidebarOpen" + :open="showSidebar" class="boards-sidebar gl-absolute" variant="sidebar" @close="handleClose" diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index a65269de743..e3012f5b36d 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -117,7 +117,7 @@ export default { return 'issues'; }, itemsTooltipLabel() { - return n__(`%d issue`, `%d issues`, this.boardLists?.issuesCount); + return n__(`%d issue`, `%d issues`, this.boardList?.issuesCount); }, chevronTooltip() { return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index c559e4cdbd3..e93edad675c 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -58,7 +58,7 @@ export default { return ListTypeTitles[ListType.label]; }, showSidebar() { - return this.sidebarType === LIST; + return this.sidebarType === LIST && this.isSidebarOpen; }, }, created() { @@ -87,10 +87,9 @@ export default { <template> <mounting-portal mount-to="#js-right-sidebar-portal" name="board-settings-sidebar" append> <gl-drawer - v-if="showSidebar" v-bind="$attrs" class="js-board-settings-sidebar gl-absolute" - :open="isSidebarOpen" + :open="showSidebar" variant="sidebar" @close="unsetActiveId" > diff --git a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql index 4dc245660a4..01fab571733 100644 --- a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql @@ -1,9 +1,7 @@ query BoardBlockingIssues($id: IssueID!) { issuable: issue(id: $id) { - __typename id blockingIssuables: blockedByIssues { - __typename nodes { id iid diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql index aec674eb006..252e8c1ab06 100644 --- a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql @@ -2,10 +2,8 @@ query GroupBoardMembers($fullPath: ID!, $search: String) { workspace: group(fullPath: $fullPath) { - __typename id assignees: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { - __typename nodes { id user { diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index bf5329c4a8d..ae6394f9a2f 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -17,7 +17,6 @@ query BoardListsEE( lists(id: $id, issueFilters: $filters) { nodes { id - issuesCount listType issues(first: $first, filters: $filters, after: $after) { edges { @@ -41,7 +40,6 @@ query BoardListsEE( lists(id: $id, issueFilters: $filters) { nodes { id - issuesCount listType issues(first: $first, filters: $filters, after: $after) { edges { diff --git a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql index 45bec5e574b..5279680b03c 100644 --- a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql @@ -2,10 +2,8 @@ query ProjectBoardMembers($fullPath: ID!, $search: String) { workspace: project(fullPath: $fullPath) { - __typename id assignees: projectMembers(search: $search) { - __typename nodes { id user { diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 04e7d3643e7..26a98a645b3 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -11,7 +11,7 @@ const updateListItemsCount = ({ state, listId, value }) => { if (state.issuableType === issuableTypes.epic) { Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value }); } else { - Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + value }); + Vue.set(state.boardLists, listId, { ...list }); } }; diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index 5e5d799d627..fea4b56153f 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -79,7 +79,7 @@ export default { :title="$options.i18n.copyTrigger" css-class="gl-border-none gl-py-0 gl-px-2" /> - <div class="label-container"> + <div class="gl-display-inline-block gl-ml-3"> <gl-badge v-if="!item.canAccessProject" variant="danger"> <span v-gl-tooltip.viewport @@ -95,7 +95,7 @@ export default { :title="item.description" truncate-target="child" placement="top" - class="trigger-description gl-display-flex" + class="gl-max-w-15 gl-display-flex" > <div class="gl-flex-grow-1 gl-text-truncate">{{ item.description }}</div> </tooltip-on-truncate> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue new file mode 100644 index 00000000000..83bad9eb518 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue @@ -0,0 +1,101 @@ +<script> +import createFlash from '~/flash'; +import getAdminVariables from '../graphql/queries/variables.query.graphql'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, + genericMutationErrorText, + variableFetchErrorText, +} from '../constants'; +import addAdminVariable from '../graphql/mutations/admin_add_variable.mutation.graphql'; +import deleteAdminVariable from '../graphql/mutations/admin_delete_variable.mutation.graphql'; +import updateAdminVariable from '../graphql/mutations/admin_update_variable.mutation.graphql'; +import ciVariableSettings from './ci_variable_settings.vue'; + +export default { + components: { + ciVariableSettings, + }, + inject: ['endpoint'], + data() { + return { + adminVariables: [], + isInitialLoading: true, + }; + }, + apollo: { + adminVariables: { + query: getAdminVariables, + update(data) { + return data?.ciVariables?.nodes || []; + }, + error() { + createFlash({ message: variableFetchErrorText }); + }, + watchLoading(flag) { + if (!flag) { + this.isInitialLoading = false; + } + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.adminVariables.loading && this.isInitialLoading; + }, + }, + methods: { + addVariable(variable) { + this.variableMutation(ADD_MUTATION_ACTION, variable); + }, + deleteVariable(variable) { + this.variableMutation(DELETE_MUTATION_ACTION, variable); + }, + updateVariable(variable) { + this.variableMutation(UPDATE_MUTATION_ACTION, variable); + }, + async variableMutation(mutationAction, variable) { + try { + const currentMutation = this.$options.mutationData[mutationAction]; + const { data } = await this.$apollo.mutate({ + mutation: currentMutation.action, + variables: { + endpoint: this.endpoint, + variable, + }, + }); + + const { errors } = data[currentMutation.name]; + + if (errors.length > 0) { + createFlash({ message: errors[0] }); + } else { + // The writing to cache for admin variable is not working + // because there is no ID in the cache at the top level. + // We therefore need to manually refetch. + this.$apollo.queries.adminVariables.refetch(); + } + } catch { + createFlash({ message: genericMutationErrorText }); + } + }, + }, + mutationData: { + [ADD_MUTATION_ACTION]: { action: addAdminVariable, name: 'addAdminVariable' }, + [UPDATE_MUTATION_ACTION]: { action: updateAdminVariable, name: 'updateAdminVariable' }, + [DELETE_MUTATION_ACTION]: { action: deleteAdminVariable, name: 'deleteAdminVariable' }, + }, +}; +</script> + +<template> + <ci-variable-settings + :are-scoped-variables-available="false" + :is-loading="isLoading" + :variables="adminVariables" + @add-variable="addVariable" + @delete-variable="deleteVariable" + @update-variable="updateVariable" + /> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue index ecb39f214ec..c9002edc1ab 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; import { __, sprintf } from '~/locale'; +import { convertEnvironmentScope } from '../utils'; export default { name: 'CiEnvironmentsDropdown', @@ -12,7 +12,11 @@ export default { GlSearchBoxByType, }, props: { - value: { + environments: { + type: Array, + required: true, + }, + selectedEnvironmentScope: { type: String, required: false, default: '', @@ -24,31 +28,36 @@ export default { }; }, computed: { - ...mapGetters(['joinedEnvironments']), composedCreateButtonLabel() { return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm }); }, + filteredEnvironments() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.environments.filter((environment) => { + return environment.toLowerCase().includes(lowerCasedSearchTerm); + }); + }, shouldRenderCreateButton() { - return this.searchTerm && !this.joinedEnvironments.includes(this.searchTerm); + return this.searchTerm && !this.environments.includes(this.searchTerm); }, - filteredResults() { - const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.joinedEnvironments.filter((resultString) => - resultString.toLowerCase().includes(lowerCasedSearchTerm), - ); + environmentScopeLabel() { + return convertEnvironmentScope(this.selectedEnvironmentScope); }, }, methods: { selectEnvironment(selected) { - this.$emit('selectEnvironment', selected); - this.searchTerm = ''; + this.$emit('select-environment', selected); + this.clearSearch(); }, - createClicked() { - this.$emit('createClicked', this.searchTerm); - this.searchTerm = ''; + convertEnvironmentScopeValue(scope) { + return convertEnvironmentScope(scope); + }, + createEnvironmentScope() { + this.$emit('create-environment-scope', this.searchTerm); + this.selectEnvironment(this.searchTerm); }, isSelected(env) { - return this.value === env; + return this.selectedEnvironmentScope === env; }, clearSearch() { this.searchTerm = ''; @@ -57,23 +66,23 @@ export default { }; </script> <template> - <gl-dropdown :text="value" @show="clearSearch"> + <gl-dropdown :text="environmentScopeLabel" @show="clearSearch"> <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" /> <gl-dropdown-item - v-for="environment in filteredResults" + v-for="environment in filteredEnvironments" :key="environment" :is-checked="isSelected(environment)" is-check-item @click="selectEnvironment(environment)" > - {{ environment }} + {{ convertEnvironmentScopeValue(environment) }} </gl-dropdown-item> - <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ + <gl-dropdown-item v-if="!filteredEnvironments.length" ref="noMatchingResults">{{ __('No matching results') }}</gl-dropdown-item> <template v-if="shouldRenderCreateButton"> <gl-dropdown-divider /> - <gl-dropdown-item data-testid="create-wildcard-button" @click="createClicked"> + <gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope"> {{ composedCreateButtonLabel }} </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue new file mode 100644 index 00000000000..3af83ffa8ed --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue @@ -0,0 +1,104 @@ +<script> +import createFlash from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import getGroupVariables from '../graphql/queries/group_variables.query.graphql'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + GRAPHQL_GROUP_TYPE, + UPDATE_MUTATION_ACTION, + genericMutationErrorText, + variableFetchErrorText, +} from '../constants'; +import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql'; +import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql'; +import updateGroupVariable from '../graphql/mutations/group_update_variable.mutation.graphql'; +import ciVariableSettings from './ci_variable_settings.vue'; + +export default { + components: { + ciVariableSettings, + }, + mixins: [glFeatureFlagsMixin()], + inject: ['endpoint', 'groupPath', 'groupId'], + data() { + return { + groupVariables: [], + }; + }, + apollo: { + groupVariables: { + query: getGroupVariables, + variables() { + return { + fullPath: this.groupPath, + }; + }, + update(data) { + return data?.group?.ciVariables?.nodes || []; + }, + error() { + createFlash({ message: variableFetchErrorText }); + }, + }, + }, + computed: { + areScopedVariablesAvailable() { + return this.glFeatures.groupScopedCiVariables; + }, + isLoading() { + return this.$apollo.queries.groupVariables.loading; + }, + }, + methods: { + addVariable(variable) { + this.variableMutation(ADD_MUTATION_ACTION, variable); + }, + deleteVariable(variable) { + this.variableMutation(DELETE_MUTATION_ACTION, variable); + }, + updateVariable(variable) { + this.variableMutation(UPDATE_MUTATION_ACTION, variable); + }, + async variableMutation(mutationAction, variable) { + try { + const currentMutation = this.$options.mutationData[mutationAction]; + const { data } = await this.$apollo.mutate({ + mutation: currentMutation.action, + variables: { + endpoint: this.endpoint, + fullPath: this.groupPath, + groupId: convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId), + variable, + }, + }); + + const { errors } = data[currentMutation.name]; + + if (errors.length > 0) { + createFlash({ message: errors[0] }); + } + } catch { + createFlash({ message: genericMutationErrorText }); + } + }, + }, + mutationData: { + [ADD_MUTATION_ACTION]: { action: addGroupVariable, name: 'addGroupVariable' }, + [UPDATE_MUTATION_ACTION]: { action: updateGroupVariable, name: 'updateGroupVariable' }, + [DELETE_MUTATION_ACTION]: { action: deleteGroupVariable, name: 'deleteGroupVariable' }, + }, +}; +</script> + +<template> + <ci-variable-settings + :are-scoped-variables-available="areScopedVariablesAvailable" + :is-loading="isLoading" + :variables="groupVariables" + @add-variable="addVariable" + @delete-variable="deleteVariable" + @update-variable="updateVariable" + /> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 557a8d6b5ba..5ba63de8c96 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -14,22 +14,26 @@ import { GlModal, GlSprintf, } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; import { getCookie, setCookie } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import Tracking from '~/tracking'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { mapComputed } from '~/vuex_shared/bindings'; + import { + allEnvironments, AWS_TOKEN_CONSTANTS, ADD_CI_VARIABLE_MODAL_ID, AWS_TIP_DISMISSED_COOKIE_NAME, AWS_TIP_MESSAGE, CONTAINS_VARIABLE_REFERENCE_MESSAGE, + defaultVariableState, ENVIRONMENT_SCOPE_LINK_TITLE, EVENT_LABEL, EVENT_ACTION, + EDIT_VARIABLE_ACTION, + VARIABLE_ACTIONS, + variableOptions, } from '../constants'; +import { createJoinedEnvironments } from '../utils'; import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; @@ -58,66 +62,84 @@ export default { GlModal, GlSprintf, }, - mixins: [glFeatureFlagsMixin(), trackingMixin], + mixins: [trackingMixin], + inject: [ + 'awsLogoSvgPath', + 'awsTipCommandsLink', + 'awsTipDeployLink', + 'awsTipLearnLink', + 'containsVariableReferenceLink', + 'environmentScopeLink', + 'isProtectedByDefault', + 'maskedEnvironmentVariablesLink', + 'maskableRegex', + 'protectedEnvironmentVariablesLink', + ], + props: { + areScopedVariablesAvailable: { + type: Boolean, + required: false, + default: false, + }, + environments: { + type: Array, + required: false, + default: () => [], + }, + mode: { + type: String, + required: true, + validator(val) { + return VARIABLE_ACTIONS.includes(val); + }, + }, + selectedVariable: { + type: Object, + required: false, + default: () => {}, + }, + variables: { + type: Array, + required: false, + default: () => [], + }, + }, data() { return { + newEnvironments: [], isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', + typeOptions: variableOptions, validationErrorEventProperty: '', + variable: { ...defaultVariableState, ...this.selectedVariable }, }; }, computed: { - ...mapState([ - 'projectId', - 'environments', - 'typeOptions', - 'variable', - 'variableBeingEdited', - 'isGroup', - 'maskableRegex', - 'selectedEnvironment', - 'isProtectedByDefault', - 'awsLogoSvgPath', - 'awsTipDeployLink', - 'awsTipCommandsLink', - 'awsTipLearnLink', - 'containsVariableReferenceLink', - 'protectedEnvironmentVariablesLink', - 'maskedEnvironmentVariablesLink', - 'environmentScopeLink', - ]), - ...mapComputed( - [ - { key: 'key', updateFn: 'updateVariableKey' }, - { key: 'secret_value', updateFn: 'updateVariableValue' }, - { key: 'variable_type', updateFn: 'updateVariableType' }, - { key: 'environment_scope', updateFn: 'setEnvironmentScope' }, - { key: 'protected_variable', updateFn: 'updateVariableProtected' }, - { key: 'masked', updateFn: 'updateVariableMasked' }, - ], - false, - 'variable', - ), - isTipVisible() { - return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); - }, - canSubmit() { - return ( - this.variableValidationState && - this.variable.key !== '' && - this.variable.secret_value !== '' - ); - }, canMask() { const regex = RegExp(this.maskableRegex); - return regex.test(this.variable.secret_value); + return regex.test(this.variable.value); + }, + canSubmit() { + return this.variableValidationState && this.variable.key !== '' && this.variable.value !== ''; }, containsVariableReference() { const regex = /\$/; - return regex.test(this.variable.secret_value); + return regex.test(this.variable.value); }, displayMaskedError() { return !this.canMask && this.variable.masked; }, + isEditing() { + return this.mode === EDIT_VARIABLE_ACTION; + }, + isTipVisible() { + return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); + }, + joinedEnvironments() { + return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments); + }, + maskedFeedback() { + return this.displayMaskedError ? __('This variable can not be masked.') : ''; + }, maskedState() { if (this.displayMaskedError) { return false; @@ -125,10 +147,7 @@ export default { return true; }, modalActionText() { - return this.variableBeingEdited ? __('Update variable') : __('Add variable'); - }, - maskedFeedback() { - return this.displayMaskedError ? __('This variable can not be masked.') : ''; + return this.isEditing ? __('Update variable') : __('Add variable'); }, tokenValidationFeedback() { const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage; @@ -141,19 +160,16 @@ export default { const validator = this.$options.tokens?.[this.variable.key]?.validation; if (validator) { - return validator(this.variable.secret_value); + return validator(this.variable.value); } return true; }, - scopedVariablesAvailable() { - return !this.isGroup || this.glFeatures.groupScopedCiVariables; - }, variableValidationFeedback() { return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; }, variableValidationState() { - return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState); + return this.variable.value === '' || (this.tokenValidationState && this.maskedState); }, }, watch: { @@ -165,19 +181,18 @@ export default { }, }, methods: { - ...mapActions([ - 'addVariable', - 'updateVariable', - 'resetEditing', - 'displayInputValue', - 'clearModal', - 'deleteVariable', - 'setEnvironmentScope', - 'addWildCardScope', - 'resetSelectedEnvironment', - 'setSelectedEnvironment', - 'setVariableProtected', - ]), + addVariable() { + this.$emit('add-variable', this.variable); + }, + createEnvironmentScope(env) { + this.newEnvironments.push(env); + }, + deleteVariable() { + this.$emit('delete-variable', this.variable); + }, + updateVariable() { + this.$emit('update-variable', this.variable); + }, dismissTip() { setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 }); this.isTipDismissed = true; @@ -190,16 +205,22 @@ export default { this.$refs.modal.hide(); }, resetModalHandler() { - if (this.variableBeingEdited) { - this.resetEditing(); - } - - this.clearModal(); - this.resetSelectedEnvironment(); + this.resetVariableData(); this.resetValidationErrorEvents(); + + this.$emit('hideModal'); + }, + resetVariableData() { + this.variable = { ...defaultVariableState }; + }, + setEnvironmentScope(scope) { + this.variable = { ...this.variable, environmentScope: scope }; + }, + setVariableProtected() { + this.variable = { ...this.variable, protected: true }; }, updateOrAddVariable() { - if (this.variableBeingEdited) { + if (this.isEditing) { this.updateVariable(); } else { this.addVariable(); @@ -207,7 +228,7 @@ export default { this.hideModal(); }, setVariableProtectedByDefault() { - if (this.isProtectedByDefault && !this.variableBeingEdited) { + if (this.isProtectedByDefault && !this.isEditing) { this.setVariableProtected(); } }, @@ -220,11 +241,11 @@ export default { }, getTrackingErrorProperty() { let property; - if (this.variable.secret_value?.length && !property) { + if (this.variable.value?.length && !property) { if (this.displayMaskedError && this.maskableRegex?.length) { const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, ''); const regex = new RegExp(supportedChars, 'g'); - property = this.variable.secret_value.replace(regex, ''); + property = this.variable.value.replace(regex, ''); } if (this.containsVariableReference) { property = '$'; @@ -237,6 +258,7 @@ export default { this.validationErrorEventProperty = ''; }, }, + defaultScope: allEnvironments.text, }; </script> @@ -252,7 +274,7 @@ export default { > <form> <gl-form-combobox - v-model="key" + v-model="variable.key" :token-list="$options.tokenList" :label-text="__('Key')" data-qa-selector="ci_variable_key_field" @@ -267,7 +289,7 @@ export default { <gl-form-textarea id="ci-variable-value" ref="valueField" - v-model="secret_value" + v-model="variable.value" :state="variableValidationState" rows="3" max-rows="6" @@ -278,7 +300,11 @@ export default { <div class="d-flex"> <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5"> - <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" /> + <gl-form-select + id="ci-variable-type" + v-model="variable.variableType" + :options="typeOptions" + /> </gl-form-group> <gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope"> @@ -294,22 +320,24 @@ export default { </gl-link> </template> <ci-environments-dropdown - v-if="scopedVariablesAvailable" - class="w-100" - :value="environment_scope" - @selectEnvironment="setEnvironmentScope" - @createClicked="addWildCardScope" + v-if="areScopedVariablesAvailable" + class="gl-w-full" + :selected-environment-scope="variable.environmentScope" + :environments="joinedEnvironments" + @select-environment="setEnvironmentScope" + @create-environment-scope="createEnvironmentScope" /> - <gl-form-input v-else v-model="environment_scope" class="w-100" readonly /> + <gl-form-input v-else :value="$options.defaultScope" class="gl-w-full" readonly /> </gl-form-group> </div> <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> <gl-form-checkbox - v-model="protected_variable" - class="mb-0" + v-model="variable.protected" + class="gl-mb-0" data-testid="ci-variable-protected-checkbox" + :data-is-protected-checked="variable.protected" > {{ __('Protect variable') }} <gl-link target="_blank" :href="protectedEnvironmentVariablesLink"> @@ -322,7 +350,7 @@ export default { <gl-form-checkbox ref="masked-ci-variable" - v-model="masked" + v-model="variable.masked" data-testid="ci-variable-masked-checkbox" > {{ __('Mask variable') }} @@ -403,7 +431,7 @@ export default { <template #modal-footer> <gl-button @click="hideModal">{{ __('Cancel') }}</gl-button> <gl-button - v-if="variableBeingEdited" + v-if="isEditing" ref="deleteCiVariable" variant="danger" category="secondary" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue index 4cc00eb01d9..81e3a983ea3 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue @@ -1,9 +1,91 @@ <script> -export default {}; +import { ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION, VARIABLE_ACTIONS } from '../constants'; +import CiVariableTable from './ci_variable_table.vue'; +import CiVariableModal from './ci_variable_modal.vue'; + +export default { + components: { + CiVariableTable, + CiVariableModal, + }, + props: { + areScopedVariablesAvailable: { + type: Boolean, + required: false, + default: false, + }, + environments: { + type: Array, + required: false, + default: () => [], + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + variables: { + type: Array, + required: true, + }, + }, + data() { + return { + selectedVariable: {}, + mode: null, + }; + }, + computed: { + showModal() { + return VARIABLE_ACTIONS.includes(this.mode); + }, + }, + methods: { + addVariable(variable) { + this.$emit('add-variable', variable); + }, + deleteVariable(variable) { + this.$emit('delete-variable', variable); + }, + updateVariable(variable) { + this.$emit('update-variable', variable); + }, + hideModal() { + this.mode = null; + }, + setSelectedVariable(variable = null) { + if (!variable) { + this.selectedVariable = {}; + this.mode = ADD_VARIABLE_ACTION; + } else { + this.selectedVariable = variable; + this.mode = EDIT_VARIABLE_ACTION; + } + }, + }, +}; </script> <template> <div class="row"> - <div class="col-lg-12"></div> + <div class="col-lg-12"> + <ci-variable-table + :is-loading="isLoading" + :variables="variables" + @set-selected-variable="setSelectedVariable" + /> + <ci-variable-modal + v-if="showModal" + :are-scoped-variables-available="areScopedVariablesAvailable" + :environments="environments" + :variables="variables" + :mode="mode" + :selected-variable="selectedVariable" + @add-variable="addVariable" + @delete-variable="deleteVariable" + @hideModal="hideModal" + @update-variable="updateVariable" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index f078234829a..1bb94080694 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -1,10 +1,17 @@ <script> -import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; +import { + GlButton, + GlIcon, + GlLoadingIcon, + GlModalDirective, + GlTable, + GlTooltipDirective, +} from '@gitlab/ui'; import { s__, __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; +import { ADD_CI_VARIABLE_MODAL_ID, variableText } from '../constants'; +import { convertEnvironmentScope } from '../utils'; import CiVariablePopover from './ci_variable_popover.vue'; export default { @@ -14,7 +21,7 @@ export default { iconSize: 16, fields: [ { - key: 'variable_type', + key: 'variableType', label: s__('CiVariables|Type'), customStyle: { width: '70px' }, }, @@ -41,7 +48,7 @@ export default { customStyle: { width: '100px' }, }, { - key: 'environment_scope', + key: 'environmentScope', label: s__('CiVariables|Environments'), customStyle: { width: '20%' }, }, @@ -56,6 +63,7 @@ export default { CiVariablePopover, GlButton, GlIcon, + GlLoadingIcon, GlTable, TooltipOnTruncate, }, @@ -64,10 +72,25 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [glFeatureFlagsMixin()], + props: { + isLoading: { + type: Boolean, + required: false, + default: false, + }, + variables: { + type: Array, + required: true, + }, + }, + data() { + return { + areValuesHidden: true, + }; + }, computed: { - ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']), valuesButtonText() { - return this.valuesHidden ? __('Reveal values') : __('Hide values'); + return this.areValuesHidden ? __('Reveal values') : __('Hide values'); }, isTableEmpty() { return !this.variables || this.variables.length === 0; @@ -76,18 +99,28 @@ export default { return this.$options.fields; }, }, - mounted() { - this.fetchVariables(); - }, methods: { - ...mapActions(['fetchVariables', 'toggleValues', 'editVariable']), + convertEnvironmentScopeValue(env) { + return convertEnvironmentScope(env); + }, + generateTypeText(item) { + return variableText[item.variableType]; + }, + toggleHiddenState() { + this.areValuesHidden = !this.areValuesHidden; + }, + setSelectedVariable(variable = null) { + this.$emit('set-selected-variable', variable); + }, }, }; </script> <template> <div class="ci-variable-table" data-testid="ci-variable-table"> + <gl-loading-icon v-if="isLoading" /> <gl-table + v-else :fields="fields" :items="variables" tbody-tr-class="js-ci-variable-row" @@ -104,6 +137,11 @@ export default { <template #table-colgroup="scope"> <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" /> </template> + <template #cell(variableType)="{ item }"> + <div class="gl-pt-2"> + {{ generateTypeText(item) }} + </div> + </template> <template #cell(key)="{ item }"> <div class="gl-display-flex gl-align-items-center"> <tooltip-on-truncate :title="item.key" truncate-target="child"> @@ -125,11 +163,12 @@ export default { </template> <template #cell(value)="{ item }"> <div class="gl-display-flex gl-align-items-center"> - <span v-if="valuesHidden">*********************</span> + <span v-if="areValuesHidden" data-testid="hiddenValue">*********************</span> <span v-else :id="`ci-variable-value-${item.id}`" class="gl-display-inline-block gl-max-w-full gl-text-truncate" + data-testid="revealedValue" >{{ item.value }}</span > <gl-button @@ -150,16 +189,16 @@ export default { <gl-icon v-if="item.masked" :size="$options.iconSize" :name="$options.trueIcon" /> <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" /> </template> - <template #cell(environment_scope)="{ item }"> + <template #cell(environmentScope)="{ item }"> <div class="gl-display-flex"> <span :id="`ci-variable-env-${item.id}`" class="gl-display-inline-block gl-max-w-full gl-text-truncate" - >{{ item.environment_scope }}</span + >{{ convertEnvironmentScopeValue(item.environmentScope) }}</span > <ci-variable-popover :target="`ci-variable-env-${item.id}`" - :value="item.environment_scope" + :value="convertEnvironmentScopeValue(item.environmentScope)" :tooltip-text="__('Copy environment')" /> </div> @@ -170,7 +209,7 @@ export default { icon="pencil" :aria-label="__('Edit')" data-qa-selector="edit_ci_variable_button" - @click="editVariable(item)" + @click="setSelectedVariable(item)" /> </template> <template #empty> @@ -186,12 +225,14 @@ export default { data-qa-selector="add_ci_variable_button" variant="confirm" category="primary" + :aria-label="__('Add')" + @click="setSelectedVariable()" >{{ __('Add variable') }}</gl-button > <gl-button v-if="!isTableEmpty" data-qa-selector="reveal_ci_variable_value_button" - @click="toggleValues(!valuesHidden)" + @click="toggleHiddenState" >{{ valuesButtonText }}</gl-button > </div> diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue index 7dcc5ce42d7..cebb7eb85ac 100644 --- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue @@ -30,7 +30,7 @@ import { EVENT_LABEL, EVENT_ACTION, } from '../constants'; -import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; +import LegacyCiEnvironmentsDropdown from './legacy_ci_environments_dropdown.vue'; import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; const trackingMixin = Tracking.mixin({ label: EVENT_LABEL }); @@ -43,7 +43,7 @@ export default { containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, components: { - CiEnvironmentsDropdown, + LegacyCiEnvironmentsDropdown, GlAlert, GlButton, GlCollapse, @@ -293,7 +293,7 @@ export default { <gl-icon name="question" :size="12" /> </gl-link> </template> - <ci-environments-dropdown + <legacy-ci-environments-dropdown v-if="scopedVariablesAvailable" class="w-100" :value="environment_scope" diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index fa55b4d9e77..5d22974ffbb 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -2,18 +2,58 @@ import { __ } from '~/locale'; export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable'; +// This const will be deprecated once we remove VueX from the section export const displayText = { variableText: __('Variable'), fileText: __('File'), allEnvironmentsText: __('All (default)'), }; +export const variableTypes = { + variableType: 'ENV_VAR', + fileType: 'FILE', +}; + +// Once REST is removed, we won't need `types` export const types = { variableType: 'env_var', fileType: 'file', - allEnvironmentsType: '*', }; +export const allEnvironments = { + type: '*', + text: __('All (default)'), +}; + +// Once REST is removed, we won't need `types` key +export const variableText = { + [types.variableType]: __('Variable'), + [types.fileType]: __('File'), + [variableTypes.variableType]: __('Variable'), + [variableTypes.fileType]: __('File'), +}; + +export const variableOptions = [ + { value: types.variableType, text: variableText[types.variableType] }, + { value: types.fileType, text: variableText[types.fileType] }, +]; + +export const defaultVariableState = { + environmentScope: allEnvironments.type, + key: '', + masked: false, + protected: false, + value: '', + variableType: types.variableType, +}; + +// eslint-disable-next-line @gitlab/require-i18n-strings +export const groupString = 'Group'; +// eslint-disable-next-line @gitlab/require-i18n-strings +export const instanceString = 'Instance'; +// eslint-disable-next-line @gitlab/require-i18n-strings +export const projectString = 'Instance'; + export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed'; export const AWS_TIP_MESSAGE = __( '%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.', @@ -33,3 +73,20 @@ export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __( ); export const ENVIRONMENT_SCOPE_LINK_TITLE = __('Learn more'); + +export const ADD_VARIABLE_ACTION = 'ADD_VARIABLE'; +export const EDIT_VARIABLE_ACTION = 'EDIT_VARIABLE'; +export const VARIABLE_ACTIONS = [ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION]; + +export const GRAPHQL_PROJECT_TYPE = 'Project'; +export const GRAPHQL_GROUP_TYPE = 'Group'; + +export const ADD_MUTATION_ACTION = 'add'; +export const UPDATE_MUTATION_ACTION = 'update'; +export const DELETE_MUTATION_ACTION = 'delete'; + +export const environmentFetchErrorText = __( + 'There was an error fetching the environments information.', +); +export const genericMutationErrorText = __('Something went wrong on our end. Please try again.'); +export const variableFetchErrorText = __('There was an error fetching the variables.'); diff --git a/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql new file mode 100644 index 00000000000..a28ca4eebc9 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql @@ -0,0 +1,7 @@ +fragment BaseCiVariable on CiVariable { + __typename + id + key + value + variableType +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql new file mode 100644 index 00000000000..eba4b0c32f8 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql @@ -0,0 +1,16 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation addAdminVariable($variable: CiVariable!, $endpoint: String!) { + addAdminVariable(variable: $variable, endpoint: $endpoint) @client { + ciVariables { + nodes { + ...BaseCiVariable + ... on CiInstanceVariable { + protected + masked + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql new file mode 100644 index 00000000000..96eb8c794bc --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql @@ -0,0 +1,16 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation deleteAdminVariable($variable: CiVariable!, $endpoint: String!) { + deleteAdminVariable(variable: $variable, endpoint: $endpoint) @client { + ciVariables { + nodes { + ...BaseCiVariable + ... on CiInstanceVariable { + protected + masked + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql new file mode 100644 index 00000000000..c0388507bb8 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql @@ -0,0 +1,16 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation updateAdminVariable($variable: CiVariable!, $endpoint: String!) { + updateAdminVariable(variable: $variable, endpoint: $endpoint) @client { + ciVariables { + nodes { + ...BaseCiVariable + ... on CiInstanceVariable { + protected + masked + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql new file mode 100644 index 00000000000..f8e4dc55fa4 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql @@ -0,0 +1,30 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation addGroupVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $groupId: ID! +) { + addGroupVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + groupId: $groupId + ) @client { + group { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql new file mode 100644 index 00000000000..310e4a6e551 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql @@ -0,0 +1,30 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation deleteGroupVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $groupId: ID! +) { + deleteGroupVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + groupId: $groupId + ) @client { + group { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql new file mode 100644 index 00000000000..5291942eb87 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql @@ -0,0 +1,30 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation updateGroupVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $groupId: ID! +) { + updateGroupVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + groupId: $groupId + ) @client { + group { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql new file mode 100644 index 00000000000..c6dd6d4faaf --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql @@ -0,0 +1,17 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +query getGroupVariables($fullPath: ID!) { + group(fullPath: $fullPath) { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + } + } + } + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql new file mode 100644 index 00000000000..95056842b49 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql @@ -0,0 +1,13 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +query getVariables { + ciVariables { + nodes { + ...BaseCiVariable + ... on CiInstanceVariable { + masked + protected + } + } + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js new file mode 100644 index 00000000000..be7e3f88cfd --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js @@ -0,0 +1,113 @@ +import axios from 'axios'; +import { + convertObjectPropsToCamelCase, + convertObjectPropsToSnakeCase, +} from '../../lib/utils/common_utils'; +import { getIdFromGraphQLId } from '../../graphql_shared/utils'; +import { GRAPHQL_GROUP_TYPE, groupString, instanceString } from '../constants'; +import getAdminVariables from './queries/variables.query.graphql'; +import getGroupVariables from './queries/group_variables.query.graphql'; + +const prepareVariableForApi = ({ variable, destroy = false }) => { + return { + ...convertObjectPropsToSnakeCase(variable), + id: getIdFromGraphQLId(variable?.id), + variable_type: variable.variableType.toLowerCase(), + secret_value: variable.value, + _destroy: destroy, + }; +}; + +const mapVariableTypes = (variables = [], kind) => { + return variables.map((ciVar) => { + return { + __typename: `Ci${kind}Variable`, + ...convertObjectPropsToCamelCase(ciVar), + variableType: ciVar.variable_type ? ciVar.variable_type.toUpperCase() : ciVar.variableType, + }; + }); +}; + +const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => { + return { + errors, + group: { + __typename: GRAPHQL_GROUP_TYPE, + id: groupId, + ciVariables: { + __typename: 'CiVariableConnection', + nodes: mapVariableTypes(data.variables, groupString), + }, + }, + }; +}; + +const prepareAdminGraphQLResponse = ({ data, errors = [] }) => { + return { + errors, + ciVariables: { + __typename: `Ci${instanceString}VariableConnection`, + nodes: mapVariableTypes(data.variables, instanceString), + }, + }; +}; + +const callGroupEndpoint = async ({ + endpoint, + fullPath, + variable, + groupId, + cache, + destroy = false, +}) => { + try { + const { data } = await axios.patch(endpoint, { + variables_attributes: [prepareVariableForApi({ variable, destroy })], + }); + return prepareGroupGraphQLResponse({ data, groupId }); + } catch (e) { + return prepareGroupGraphQLResponse({ + data: cache.readQuery({ query: getGroupVariables, variables: { fullPath } }), + groupId, + errors: [...e.response.data], + }); + } +}; + +const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false }) => { + try { + const { data } = await axios.patch(endpoint, { + variables_attributes: [prepareVariableForApi({ variable, destroy })], + }); + + return prepareAdminGraphQLResponse({ data }); + } catch (e) { + return prepareAdminGraphQLResponse({ + data: cache.readQuery({ query: getAdminVariables }), + errors: [...e.response.data], + }); + } +}; + +export const resolvers = { + Mutation: { + addGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => { + return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache }); + }, + updateGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => { + return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache }); + }, + deleteGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => { + return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache, destroy: true }); + }, + addAdminVariable: async (_, { endpoint, variable }, { cache }) => { + return callAdminEndpoint({ endpoint, variable, cache }); + }, + updateAdminVariable: async (_, { endpoint, variable }, { cache }) => { + return callAdminEndpoint({ endpoint, variable, cache }); + }, + deleteAdminVariable: async (_, { endpoint, variable }, { cache }) => { + return callAdminEndpoint({ endpoint, variable, cache, destroy: true }); + }, + }, +}; diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index 2b54af6a2a4..a74af8aed12 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -2,8 +2,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; -import CiVariableSettings from './components/ci_variable_settings.vue'; +import CiAdminVariables from './components/ci_admin_variables.vue'; +import CiGroupVariables from './components/ci_group_variables.vue'; import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue'; +import { resolvers } from './graphql/resolvers'; import createStore from './store'; const mountCiVariableListApp = (containerEl) => { @@ -13,8 +15,12 @@ const mountCiVariableListApp = (containerEl) => { awsTipDeployLink, awsTipLearnLink, containsVariableReferenceLink, + endpoint, environmentScopeLink, - group, + groupId, + groupPath, + isGroup, + isProject, maskedEnvironmentVariablesLink, maskableRegex, projectFullPath, @@ -23,13 +29,20 @@ const mountCiVariableListApp = (containerEl) => { protectedEnvironmentVariablesLink, } = containerEl.dataset; - const isGroup = parseBoolean(group); + const parsedIsProject = parseBoolean(isProject); + const parsedIsGroup = parseBoolean(isGroup); const isProtectedByDefault = parseBoolean(protectedByDefault); + let component = CiAdminVariables; + + if (parsedIsGroup) { + component = CiGroupVariables; + } + Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient(resolvers), }); return new Vue({ @@ -41,8 +54,12 @@ const mountCiVariableListApp = (containerEl) => { awsTipDeployLink, awsTipLearnLink, containsVariableReferenceLink, + endpoint, environmentScopeLink, - isGroup, + groupId, + groupPath, + isGroup: parsedIsGroup, + isProject: parsedIsProject, isProtectedByDefault, maskedEnvironmentVariablesLink, maskableRegex, @@ -51,7 +68,7 @@ const mountCiVariableListApp = (containerEl) => { protectedEnvironmentVariablesLink, }, render(createElement) { - return createElement(CiVariableSettings); + return createElement(component); }, }); }; diff --git a/app/assets/javascripts/ci_variable_list/store/utils.js b/app/assets/javascripts/ci_variable_list/store/utils.js index d9ca460a8e1..f46a671ae7b 100644 --- a/app/assets/javascripts/ci_variable_list/store/utils.js +++ b/app/assets/javascripts/ci_variable_list/store/utils.js @@ -1,5 +1,5 @@ import { cloneDeep } from 'lodash'; -import { displayText, types } from '../constants'; +import { displayText, types, allEnvironments } from '../constants'; const variableTypeHandler = (type) => type === displayText.variableText ? types.variableType : types.fileType; @@ -15,7 +15,7 @@ export const prepareDataForDisplay = (variables) => { } variableCopy.secret_value = variableCopy.value; - if (variableCopy.environment_scope === types.allEnvironmentsType) { + if (variableCopy.environment_scope === allEnvironments.type) { variableCopy.environment_scope = displayText.allEnvironmentsText; } variableCopy.protected_variable = variableCopy.protected; @@ -31,7 +31,7 @@ export const prepareDataForApi = (variable, destroy = false) => { variableCopy.masked = variableCopy.masked.toString(); variableCopy.variable_type = variableTypeHandler(variableCopy.variable_type); if (variableCopy.environment_scope === displayText.allEnvironmentsText) { - variableCopy.environment_scope = types.allEnvironmentsType; + variableCopy.environment_scope = allEnvironments.type; } if (destroy) { diff --git a/app/assets/javascripts/ci_variable_list/utils.js b/app/assets/javascripts/ci_variable_list/utils.js new file mode 100644 index 00000000000..1faa97a5f73 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/utils.js @@ -0,0 +1,50 @@ +import { uniq } from 'lodash'; +import { allEnvironments } from './constants'; + +/** + * This function takes a list of variable, environments and + * new environments added through the scope dropdown + * and create a new Array that concatenate the environment list + * with the environment scopes find in the variable list. This is + * useful for variable settings so that we can render a list of all + * environment scopes available based on the list of envs, the ones the user + * added explictly and what is found under each variable. + * @param {Array} variables + * @param {Array} environments + * @returns {Array} - Array of environments + */ + +export const createJoinedEnvironments = ( + variables = [], + environments = [], + newEnvironments = [], +) => { + const scopesFromVariables = variables.map((variable) => variable.environmentScope); + return uniq([...environments, ...newEnvironments, ...scopesFromVariables]).sort(); +}; + +/** + * This function job is to convert the * wildcard to text when applicable + * in the UI. It uses a constants to compare the incoming value to that + * of the * and then apply the corresponding label if applicable. If there + * is no scope, then we return the default value as well. + * @param {String} scope + * @returns {String} - Converted value if applicable + */ + +export const convertEnvironmentScope = (environmentScope = '') => { + if (environmentScope === allEnvironments.type || !environmentScope) { + return allEnvironments.text; + } + + return environmentScope; +}; + +/** + * Gives us an array of all the environments by name + * @param {Array} nodes + * @return {Array<String>} - Array of environments strings + */ +export const mapEnvironmentNames = (nodes = []) => { + return nodes.map((env) => env.name); +}; diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 1ea5eff35d4..4b85ca2b508 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -235,7 +235,7 @@ export default { :fields="fields" fixed stacked="md" - class="qa-clusters-table gl-mb-4!" + class="gl-mb-4!" data-testid="cluster_list_table" > <template #cell(name)="{ item }"> diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 4ff49433749..95ee3a0d90e 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { getParameterByName } from '~/lib/utils/url_utility'; import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; import { PipelineKeyOptions } from '~/pipelines/constants'; @@ -19,6 +20,7 @@ export default { GlLink, GlLoadingIcon, GlModal, + GlSprintf, PipelinesTableComponent, TablePagination, }, @@ -32,6 +34,10 @@ export default { type: String, required: true, }, + emptyStateSvgPath: { + type: String, + required: true, + }, viewType: { type: String, required: false, @@ -83,6 +89,9 @@ export default { shouldRenderErrorState() { return this.hasError && !this.isLoading; }, + shouldRenderEmptyState() { + return this.state.pipelines.length === 0 && !this.shouldRenderErrorState; + }, /** * The "Run pipeline" button can only be rendered when: * - In MR view - we use `canCreatePipelineInTargetProject` for that purpose @@ -185,6 +194,17 @@ export default { }, }, }, + i18n: { + runPipelinePopoverTitle: s__('Pipeline|Run merge request pipeline'), + runPipelinePopoverDescription: s__( + 'Pipeline|To run a merge request pipeline, the jobs in the CI/CD configuration file %{linkStart}must be configured%{linkEnd} to run in merge request pipelines.', + ), + runPipelineText: s__('Pipeline|Run pipeline'), + emptyStateTitle: s__('Pipelines|There are currently no pipelines.'), + }, + mrPipelinesDocsPath: helpPagePath('ci/pipelines/merge_request_pipelines.md', { + anchor: 'prerequisites', + }), }; </script> <template> @@ -203,7 +223,41 @@ export default { s__(`Pipelines|There was an error fetching the pipelines. Try again in a few moments or contact your support team.`) " + data-testid="pipeline-error-empty-state" /> + <template v-else-if="shouldRenderEmptyState"> + <gl-empty-state + :svg-path="emptyStateSvgPath" + :title="$options.i18n.emptyStateTitle" + data-testid="pipeline-empty-state" + > + <template #description> + <gl-sprintf :message="$options.i18n.runPipelinePopoverDescription"> + <template #link="{ content }"> + <gl-link + :href="$options.mrPipelinesDocsPath" + target="_blank" + data-testid="mr-pipelines-docs-link" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </template> + + <template #actions> + <div class="gl-vertical-align-middle"> + <gl-button + variant="confirm" + :loading="state.isRunningMergeRequestPipeline" + data-testid="run_pipeline_button" + @click="tryRunPipeline" + > + {{ $options.i18n.runPipelineText }} + </gl-button> + </div> + </template> + </gl-empty-state> + </template> <div v-else-if="shouldRenderTable"> <gl-button @@ -215,7 +269,7 @@ export default { :loading="state.isRunningMergeRequestPipeline" @click="tryRunPipeline" > - {{ s__('Pipeline|Run pipeline') }} + {{ $options.i18n.runPipelineText }} </gl-button> <pipelines-table-component @@ -231,7 +285,7 @@ export default { :loading="state.isRunningMergeRequestPipeline" @click="tryRunPipeline" > - {{ s__('Pipeline|Run pipeline') }} + {{ $options.i18n.runPipelineText }} </gl-button> </div> </template> diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js index 784e9cb2faa..b105273ece7 100644 --- a/app/assets/javascripts/commons/nav/user_merge_requests.js +++ b/app/assets/javascripts/commons/nav/user_merge_requests.js @@ -26,39 +26,20 @@ function updateMergeRequestCounts(newCount) { mergeRequestsCountEl.classList.toggle('gl-display-none', Number(newCount) === 0); } -function updateAttentionRequestsCount(count) { - const attentionCountEl = document.querySelector('.js-attention-count'); - attentionCountEl.textContent = count.toLocaleString(); - - if (Number(count) === 0) { - attentionCountEl.classList.replace('badge-warning', 'badge-neutral'); - } else { - attentionCountEl.classList.replace('badge-neutral', 'badge-warning'); - } -} - /** * Refresh user counts (and broadcast if open) */ export function refreshUserMergeRequestCounts() { return getUserCounts() .then(({ data }) => { - const attentionRequestsEnabled = window.gon?.features?.mrAttentionRequests; const assignedMergeRequests = data.assigned_merge_requests; const reviewerMergeRequests = data.review_requested_merge_requests; - const attentionRequests = data.attention_requests; - const fullCount = attentionRequestsEnabled - ? attentionRequests - : assignedMergeRequests + reviewerMergeRequests; + const fullCount = assignedMergeRequests + reviewerMergeRequests; updateUserMergeRequestCounts(assignedMergeRequests); updateReviewerMergeRequestCounts(reviewerMergeRequests); updateMergeRequestCounts(fullCount); broadcastCount(fullCount); - - if (attentionRequestsEnabled) { - updateAttentionRequestsCount(attentionRequests); - } }) .catch((ex) => { console.error(ex); // eslint-disable-line no-console diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue index f0726ff3e63..05ca7fd75c3 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue @@ -3,13 +3,11 @@ import { GlButtonGroup } from '@gitlab/ui'; import { BubbleMenu } from '@tiptap/vue-2'; import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants'; import trackUIControl from '../../services/track_ui_control'; -import Image from '../../extensions/image'; +import Paragraph from '../../extensions/paragraph'; +import Heading from '../../extensions/heading'; import Audio from '../../extensions/audio'; import Video from '../../extensions/video'; -import Code from '../../extensions/code'; -import CodeBlockHighlight from '../../extensions/code_block_highlight'; -import Diagram from '../../extensions/diagram'; -import Frontmatter from '../../extensions/frontmatter'; +import Image from '../../extensions/image'; import ToolbarButton from '../toolbar_button.vue'; export default { @@ -27,17 +25,13 @@ export default { shouldShow: ({ editor, from, to }) => { if (from === to) return false; - const exclude = [ - Code.name, - CodeBlockHighlight.name, - Diagram.name, - Frontmatter.name, - Image.name, - Audio.name, - Video.name, - ]; + const includes = [Paragraph.name, Heading.name]; + const excludes = [Image.name, Audio.name, Video.name]; - return !exclude.some((type) => editor.isActive(type)); + return ( + includes.some((type) => editor.isActive(type)) && + !excludes.some((type) => editor.isActive(type)) + ); }, }, }; diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 74ae37b6d06..c3c881d9135 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -84,7 +84,14 @@ export default { <template> <content-editor-provider :content-editor="contentEditor"> <div> - <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" /> + <editor-state-observer + @docUpdate="notifyChange" + @focus="focus" + @blur="blur" + @loading="$emit('loading')" + @loadingSuccess="$emit('loadingSuccess')" + @loadingError="$emit('loadingError')" + /> <content-editor-alert /> <div data-testid="content-editor" diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue index 6e4cde5dad6..9ad739e7358 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -33,8 +33,12 @@ export default { this.$emit('execute', { contentType: listType }); }, - execute(command, contentType) { - this.tiptapEditor.chain().focus()[command]().run(); + execute(command, contentType, ...args) { + this.tiptapEditor + .chain() + .focus() + [command](...args) + .run(); this.$emit('execute', { contentType }); }, @@ -67,5 +71,8 @@ export default { <gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })"> {{ __('PlantUML diagram') }} </gl-dropdown-item> + <gl-dropdown-item @click="execute('insertTableOfContents', 'tableOfContents')"> + {{ __('Table of contents') }} + </gl-dropdown-item> </gl-dropdown> </template> diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index 65d71814268..1030ebbf838 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -93,7 +93,7 @@ export default { icon-name="list-task" class="gl-mx-2 gl-display-none gl-sm-display-inline" editor-command="toggleTaskList" - :label="__('Add a task list')" + :label="__('Add a checklist')" @execute="trackToolbarControlExecution" /> <toolbar-image-button diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index c0d6e32a739..6456540a0dd 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; -import { selectedRect as getSelectedRect } from 'prosemirror-tables'; +import { selectedRect as getSelectedRect } from '@_ueberdosis/prosemirror-tables'; import { __ } from '~/locale'; const TABLE_CELL_HEADER = 'th'; diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue new file mode 100644 index 00000000000..a4e1be9d95d --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue @@ -0,0 +1,55 @@ +<script> +import { debounce } from 'lodash'; +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { getHeadings } from '../../services/table_of_contents_utils'; +import TableOfContentsHeading from './table_of_contents_heading.vue'; + +export default { + name: 'TableOfContentsWrapper', + components: { + NodeViewWrapper, + TableOfContentsHeading, + }, + props: { + editor: { + type: Object, + required: true, + }, + node: { + type: Object, + required: true, + }, + }, + data() { + return { + headings: [], + }; + }, + mounted() { + this.handleUpdate = debounce(this.handleUpdate, DEFAULT_DEBOUNCE_AND_THROTTLE_MS * 2); + + this.editor.on('update', this.handleUpdate); + this.$nextTick(this.handleUpdate); + }, + methods: { + handleUpdate() { + this.headings = getHeadings(this.editor); + }, + }, +}; +</script> +<template> + <node-view-wrapper + as="ul" + class="table-of-contents gl-border-1 gl-border-solid gl-border-gray-100 gl-mb-5 gl-p-4!" + data-testid="table-of-contents" + > + {{ __('Table of contents') }} + <table-of-contents-heading + v-for="(heading, index) in headings" + :key="index" + :heading="heading" + /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue new file mode 100644 index 00000000000..edd75d232e8 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue @@ -0,0 +1,25 @@ +<script> +export default { + name: 'TableOfContentsHeading', + props: { + heading: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <li> + <a v-if="heading.text" href="#" @click.prevent> + {{ heading.text }} + </a> + <ul v-if="heading.subHeadings.length"> + <table-of-contents-heading + v-for="(child, index) in heading.subHeadings" + :key="index" + :heading="child" + /> + </ul> + </li> +</template> 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 edf8b3d3a0b..27432b1e18b 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -1,3 +1,4 @@ +import { lowlight } from 'lowlight/lib/core'; import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; import { textblockTypeInputRule } from '@tiptap/core'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; @@ -66,4 +67,4 @@ export default CodeBlockLowlight.extend({ addNodeView() { return new VueNodeViewRenderer(CodeBlockWrapper); }, -}); +}).configure({ lowlight }); diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js index c59ca8a28b8..d9983b8c1c5 100644 --- a/app/assets/javascripts/content_editor/extensions/diagram.js +++ b/app/assets/javascripts/content_editor/extensions/diagram.js @@ -1,3 +1,4 @@ +import { lowlight } from 'lowlight/lib/core'; import { textblockTypeInputRule } from '@tiptap/core'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; import languageLoader from '../services/code_block_language_loader'; @@ -10,6 +11,12 @@ export default CodeBlockHighlight.extend({ isolating: true, + addOptions() { + return { + lowlight, + }; + }, + addAttributes() { return { language: { diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js index 2ec22158106..428171a9389 100644 --- a/app/assets/javascripts/content_editor/extensions/frontmatter.js +++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js @@ -1,9 +1,16 @@ +import { lowlight } from 'lowlight/lib/core'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; import CodeBlockHighlight from './code_block_highlight'; export default CodeBlockHighlight.extend({ name: 'frontmatter', + addOptions() { + return { + lowlight, + }; + }, + addAttributes() { return { ...this.parent?.(), diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 25f976f524f..65849ec4d0d 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -34,6 +34,7 @@ export default Image.extend({ canonicalSrc: { default: null, parseHTML: (element) => element.dataset.canonicalSrc, + renderHTML: () => '', }, alt: { default: null, @@ -51,6 +52,10 @@ export default Image.extend({ return img.getAttribute('title'); }, }, + isReference: { + default: false, + renderHTML: () => '', + }, }; }, parseHTML() { @@ -71,7 +76,6 @@ export default Image.extend({ src: HTMLAttributes.src, alt: HTMLAttributes.alt, title: HTMLAttributes.title, - 'data-canonical-src': HTMLAttributes.canonicalSrc, }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index f9b12f631fe..e985e561fda 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -56,6 +56,11 @@ export default Link.extend({ canonicalSrc: { default: null, parseHTML: (element) => element.dataset.canonicalSrc, + renderHTML: () => '', + }, + isReference: { + default: false, + renderHTML: () => '', }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/reference_definition.js b/app/assets/javascripts/content_editor/extensions/reference_definition.js new file mode 100644 index 00000000000..e2762fe9fd9 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/reference_definition.js @@ -0,0 +1,29 @@ +import { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'referenceDefinition', + + group: 'block', + + content: 'text*', + + marks: '', + + addAttributes() { + return { + identifier: { + default: null, + }, + url: { + default: null, + }, + title: { + default: null, + }, + }; + }, + + renderHTML() { + return ['pre', {}, 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js index 618f17b1c5e..f9de71f601b 100644 --- a/app/assets/javascripts/content_editor/extensions/sourcemap.js +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -6,6 +6,7 @@ import Code from './code'; import CodeBlockHighlight from './code_block_highlight'; import FootnoteReference from './footnote_reference'; import FootnoteDefinition from './footnote_definition'; +import Frontmatter from './frontmatter'; import Heading from './heading'; import HardBreak from './hard_break'; import HorizontalRule from './horizontal_rule'; @@ -16,6 +17,7 @@ import Link from './link'; import ListItem from './list_item'; import OrderedList from './ordered_list'; import Paragraph from './paragraph'; +import ReferenceDefinition from './reference_definition'; import Strike from './strike'; import TaskList from './task_list'; import TaskItem from './task_item'; @@ -36,6 +38,7 @@ export default Extension.create({ CodeBlockHighlight.name, FootnoteReference.name, FootnoteDefinition.name, + Frontmatter.name, HardBreak.name, Heading.name, HorizontalRule.name, @@ -45,6 +48,7 @@ export default Extension.create({ ListItem.name, OrderedList.name, Paragraph.name, + ReferenceDefinition.name, Strike.name, TaskList.name, TaskItem.name, diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js index a8882f9ede4..f64ed67199f 100644 --- a/app/assets/javascripts/content_editor/extensions/table_of_contents.js +++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js @@ -1,6 +1,8 @@ import { Node, InputRule } from '@tiptap/core'; -import { s__ } from '~/locale'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { __ } from '~/locale'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import TableOfContentsWrapper from '../components/wrappers/table_of_contents.vue'; export default Node.create({ name: 'tableOfContents', @@ -25,9 +27,18 @@ export default Node.create({ 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'), + __('Table of contents'), ]; }, + addNodeView() { + return VueNodeViewRenderer(TableOfContentsWrapper); + }, + + addCommands() { + return { + insertTableOfContents: () => ({ commands }) => commands.insertContent({ type: this.name }), + }; + }, addInputRules() { const { type } = this; diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 867bf0b4d55..75d8581890f 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -1,4 +1,3 @@ -import { TextSelection } from 'prosemirror-state'; import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; /* eslint-disable no-underscore-dangle */ @@ -59,7 +58,6 @@ export class ContentEditor { async setSerializedContent(serializedContent) { const { _tiptapEditor: editor, _eventHub: eventHub } = this; const { doc, tr } = editor.state; - const selection = TextSelection.create(doc, 0, doc.content.size); try { eventHub.$emit(LOADING_CONTENT_EVENT); @@ -67,9 +65,7 @@ export class ContentEditor { if (document) { this._pristineDoc = document; - tr.setSelection(selection) - .replaceSelectionWith(document, false) - .setMeta('preventUpdate', true); + tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true); editor.view.dispatch(tr); } 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 c5cfa9a4285..7a289df94ea 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -1,6 +1,5 @@ import { Editor } from '@tiptap/vue-2'; import { isFunction } from 'lodash'; -import { lowlight } from 'lowlight/lib/core'; import eventHubFactory from '~/helpers/event_hub_factory'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import Attachment from '../extensions/attachment'; @@ -43,6 +42,7 @@ import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import PasteMarkdown from '../extensions/paste_markdown'; import Reference from '../extensions/reference'; +import ReferenceDefinition from '../extensions/reference_definition'; import Sourcemap from '../extensions/sourcemap'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; @@ -96,7 +96,7 @@ export const createContentEditor = ({ BulletList, Code, ColorChip, - CodeBlockHighlight.configure({ lowlight }), + CodeBlockHighlight, DescriptionItem, DescriptionList, Details, @@ -110,7 +110,7 @@ export const createContentEditor = ({ FootnoteDefinition, FootnoteReference, FootnotesSection, - Frontmatter.configure({ lowlight }), + Frontmatter, Gapcursor, HardBreak, Heading, @@ -127,8 +127,9 @@ export const createContentEditor = ({ MathInline, OrderedList, Paragraph, - PasteMarkdown.configure({ renderMarkdown, eventHub }), + PasteMarkdown, Reference, + ReferenceDefinition, Sourcemap, Strike, Subscript, diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js index 312ab88de4a..28a50adca6b 100644 --- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js +++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js @@ -21,7 +21,7 @@ import { Mark } from 'prosemirror-model'; import { visitParents, SKIP } from 'unist-util-visit-parents'; -import { isFunction, isString, noop } from 'lodash'; +import { isFunction, isString, noop, mapValues } from 'lodash'; const NO_ATTRIBUTES = {}; @@ -73,28 +73,48 @@ function createSourceMapAttributes(hastNode, markdown) { } /** - * Compute ProseMirror node’s attributes from a Hast node. - * By default, this function includes sourcemap position - * information in the object returned. - * - * Other attributes are retrieved by invoking a getAttrs - * function provided by the ProseMirror node factory spec. - * - * @param {*} proseMirrorNodeSpec ProseMirror node spec object - * @param {HastNode} hastNode A hast node - * @param {Array<HastNode>} hastParents All the ancestors of the hastNode - * @param {String} markdown Markdown source file’s content - * - * @returns An object that contains a ProseMirror node’s attributes + * Creates a function that resolves the attributes + * of a ProseMirror node based on a hast node. + * + * @param {Object} params Parameters + * @param {String} params.markdown Markdown source from which the AST was generated + * @param {Object} params.attributeTransformer An object that allows applying a transformation + * function to all the attributes listed in the attributes property. + * @param {Array} params.attributeTransformer.attributes A list of attributes names + * that the getAttrs function should apply the transformation + * @param {Function} params.attributeTransformer.transform A function that applies + * a transform operation on an attribute value. + * @returns A `getAttrs` function */ -function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, markdown) { - const { getAttrs: specGetAttrs } = proseMirrorNodeSpec; +const getAttrsFactory = ({ attributeTransformer, markdown }) => + /** + * Compute ProseMirror node’s attributes from a Hast node. + * By default, this function includes sourcemap position + * information in the object returned. + * + * Other attributes are retrieved by invoking a getAttrs + * function provided by the ProseMirror node factory spec. + * + * @param {Object} proseMirrorNodeSpec ProseMirror node spec object + * @param {Object} hastNode A hast node + * @param {Array} hastParents All the ancestors of the hastNode + * @param {String} markdown Markdown source file’s content + * @returns An object that contains a ProseMirror node’s attributes + */ + function getAttrs(proseMirrorNodeSpec, hastNode, hastParents) { + const { getAttrs: specGetAttrs } = proseMirrorNodeSpec; + const attributes = { + ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}), + }; + const { transform } = attributeTransformer; - return { - ...createSourceMapAttributes(hastNode, markdown), - ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}), + return { + ...createSourceMapAttributes(hastNode, markdown), + ...mapValues(attributes, (attributeValue, attributeName) => + transform(attributeName, attributeValue, hastNode), + ), + }; }; -} /** * Keeps track of the Hast -> ProseMirror conversion process. @@ -322,7 +342,13 @@ class HastToProseMirrorConverterState { * * @returns An object that contains ProseMirror node factories */ -const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdown) => { +const createProseMirrorNodeFactories = ( + schema, + proseMirrorFactorySpecs, + attributeTransformer, + markdown, +) => { + const getAttrs = getAttrsFactory({ attributeTransformer, markdown }); const factories = { root: { selector: 'root', @@ -355,20 +381,20 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdow const nodeType = schema.nodeType(proseMirrorName); state.closeUntil(parent); - state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); + state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent), factory); }; } else if (factory.type === 'inline') { const nodeType = schema.nodeType(proseMirrorName); factory.handle = (state, hastNode, parent) => { state.closeUntil(parent); - state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); + state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent), factory); // Inline nodes do not have children therefore they are immediately closed state.closeNode(); }; } else if (factory.type === 'mark') { const markType = schema.marks[proseMirrorName]; factory.handle = (state, hastNode, parent) => { - state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); + state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent), factory); }; } else if (factory.type === 'ignore') { factory.handle = noop; @@ -581,9 +607,15 @@ export const createProseMirrorDocFromMdastTree = ({ factorySpecs, wrappableTags, tree, + attributeTransformer, markdown, }) => { - const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, markdown); + const proseMirrorNodeFactories = createProseMirrorNodeFactories( + schema, + factorySpecs, + attributeTransformer, + markdown, + ); const state = new HastToProseMirrorConverterState(); visitParents(tree, (hastNode, ancestors) => { diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index c1c7af6b1af..472a0a4815b 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -33,6 +33,7 @@ import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import Reference from '../extensions/reference'; +import ReferenceDefinition from '../extensions/reference_definition'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; import Superscript from '../extensions/superscript'; @@ -148,10 +149,13 @@ const defaultSerializerConfig = { state.renderInline(node); state.ensureNewLine(); }), - [FootnoteReference.name]: preserveUnchanged((state, node) => { - state.write(`[^${node.attrs.identifier}]`); + [FootnoteReference.name]: preserveUnchanged({ + render: (state, node) => { + state.write(`[^${node.attrs.identifier}]`); + }, + inline: true, }), - [Frontmatter.name]: (state, node) => { + [Frontmatter.name]: preserveUnchanged((state, node) => { const { language } = node.attrs; const syntax = { toml: '+++', @@ -164,19 +168,41 @@ const defaultSerializerConfig = { state.ensureNewLine(); state.write(syntax); state.closeBlock(node); - }, + }), [Figure.name]: renderHTMLNode('figure'), [FigureCaption.name]: renderHTMLNode('figcaption'), [HardBreak.name]: preserveUnchanged(renderHardBreak), [Heading.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.heading), [HorizontalRule.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.horizontal_rule), - [Image.name]: preserveUnchanged(renderImage), + [Image.name]: preserveUnchanged({ + render: renderImage, + inline: true, + }), [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), [OrderedList.name]: preserveUnchanged(renderOrderedList), [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), [Reference.name]: (state, node) => { state.write(node.attrs.originalText || node.attrs.text); }, + [ReferenceDefinition.name]: preserveUnchanged({ + render: (state, node, parent, index, same, sourceMarkdown) => { + const nextSibling = parent.maybeChild(index + 1); + + state.text(same ? sourceMarkdown : node.textContent, false); + + /** + * Do not insert a blank line between reference definitions + * because it isn’t necessary and a more compact text format + * is preferred. + */ + if (!nextSibling || nextSibling.type.name !== ReferenceDefinition.name) { + state.closeBlock(node); + } else { + state.ensureNewLine(); + } + }, + overwriteSourcePreservationStrategy: true, + }), [TableOfContents.name]: (state, node) => { state.write('[[_TOC_]]'); state.closeBlock(node); diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index 8e2c066e011..8a15633708f 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -1,4 +1,5 @@ import { render } from '~/lib/gfm'; +import { isValidAttribute } from '~/lib/dompurify'; import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter'; const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del']; @@ -125,6 +126,8 @@ const factorySpecs = { selector: 'img', getAttrs: (hastNode) => ({ src: hastNode.properties.src, + canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src, + isReference: hastNode.properties.isReference === 'true', title: hastNode.properties.title, alt: hastNode.properties.alt, }), @@ -154,7 +157,9 @@ const factorySpecs = { type: 'mark', selector: 'a', getAttrs: (hastNode) => ({ + canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.href, href: hastNode.properties.href, + isReference: hastNode.properties.isReference === 'true', title: hastNode.properties.title, }), }, @@ -170,6 +175,55 @@ const factorySpecs = { type: 'ignore', selector: (hastNode) => hastNode.type === 'comment', }, + + referenceDefinition: { + type: 'block', + selector: 'referencedefinition', + getAttrs: (hastNode) => ({ + title: hastNode.properties.title, + url: hastNode.properties.url, + identifier: hastNode.properties.identifier, + }), + }, + + frontmatter: { + type: 'block', + selector: 'frontmatter', + getAttrs: (hastNode) => ({ + language: hastNode.properties.language, + }), + }, +}; + +const SANITIZE_ALLOWLIST = ['level', 'identifier', 'numeric', 'language', 'url', 'isReference']; + +const sanitizeAttribute = (attributeName, attributeValue, hastNode) => { + if (!attributeValue || SANITIZE_ALLOWLIST.includes(attributeName)) { + return attributeValue; + } + + /** + * This is a workaround to validate the value of the canonicalSrc + * attribute using DOMPurify without passing the attribute name. canonicalSrc + * is not an allowed attribute in DOMPurify therefore the library will remove + * it regardless of its value. + * + * We want to preserve canonicalSrc, and we also want to make sure that its + * value is sanitized. + */ + const validateAttributeAs = attributeName === 'canonicalSrc' ? 'src' : attributeName; + + if (!isValidAttribute(hastNode.tagName, validateAttributeAs, attributeValue)) { + return null; + } + + return attributeValue; +}; + +const attributeTransformer = { + transform: (attributeName, attributeValue, hastNode) => { + return sanitizeAttribute(attributeName, attributeValue, hastNode); + }, }; export default () => { @@ -183,9 +237,20 @@ export default () => { factorySpecs, tree, wrappableTags, + attributeTransformer, markdown, }), - skipRendering: ['footnoteReference', 'footnoteDefinition', 'code'], + skipRendering: [ + 'footnoteReference', + 'footnoteDefinition', + 'code', + 'definition', + 'linkReference', + 'imageReference', + 'yaml', + 'toml', + 'json', + ], }); return { document }; diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 7d5e718b41c..41114571df7 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -1,4 +1,5 @@ -import { uniq, isString, omit } from 'lodash'; +import { uniq, isString, omit, isFunction } from 'lodash'; +import { removeLastSlashInUrlPath, removeUrlProtocol } from '../../lib/utils/url_utility'; const defaultAttrs = { td: { colspan: 1, rowspan: 1, colwidth: null }, @@ -306,12 +307,15 @@ export function renderHardBreak(state, node, parent, index) { } export function renderImage(state, node) { - const { alt, canonicalSrc, src, title } = node.attrs; + const { alt, canonicalSrc, src, title, isReference } = node.attrs; if (isString(src) || isString(canonicalSrc)) { const quotedTitle = title ? ` ${state.quote(title)}` : ''; + const sourceExpression = isReference + ? `[${canonicalSrc}]` + : `(${state.esc(canonicalSrc || src)}${quotedTitle})`; - state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); + state.write(`![${state.esc(alt || '')}]${sourceExpression}`); } } @@ -327,16 +331,28 @@ export function renderCodeBlock(state, node) { state.closeBlock(node); } -export function preserveUnchanged(render) { +const expandPreserveUnchangedConfig = (configOrRender) => + isFunction(configOrRender) + ? { render: configOrRender, overwriteSourcePreservationStrategy: false, inline: false } + : configOrRender; + +export function preserveUnchanged(configOrRender) { return (state, node, parent, index) => { + const { render, overwriteSourcePreservationStrategy, inline } = expandPreserveUnchangedConfig( + configOrRender, + ); + const { sourceMarkdown } = node.attrs; const same = state.options.changeTracker.get(node); - if (same) { + if (same && !overwriteSourcePreservationStrategy) { state.write(sourceMarkdown); - state.closeBlock(node); + + if (!inline) { + state.closeBlock(node); + } } else { - render(state, node, parent, index); + render(state, node, parent, index, same, sourceMarkdown); } }; } @@ -488,24 +504,16 @@ const linkType = (sourceMarkdown) => { return LINK_HTML; }; -const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, ''); - -const normalizeUrl = (url) => decodeURIComponent(removeUrlProtocol(url)); +const normalizeUrl = (url) => decodeURIComponent(removeLastSlashInUrlPath(removeUrlProtocol(url))); /** - * Validates that the provided URL is well-formed + * Validates that the provided URL is a valid GFM autolink * * @param {String} url - * @returns Returns true when the browser’s URL constructor - * can successfully parse the URL string + * @returns Returns true when the URL is a valid GFM autolink */ -const isValidUrl = (url) => { - try { - return new URL(url) && true; - } catch { - return false; - } -}; +const isValidAutolinkURL = (url) => + /(https?:\/\/)?([\w-])+\.{1}([a-zA-Z]{2,63})([/\w-]*)*\/?\??([^#\n\r]*)?#?([^\n\r]*)/.test(url); const findChildWithMark = (mark, parent) => { let child; @@ -542,7 +550,7 @@ const isAutoLink = (linkMark, parent) => { if ( !child || !child.isText || - !isValidUrl(href) || + !isValidAutolinkURL(href) || normalizeUrl(child.text) !== normalizeUrl(href) ) { return false; @@ -582,7 +590,11 @@ export const link = { return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : ''; } - const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs; + + if (isReference) { + return `][${state.esc(canonicalSrc || href)}]`; + } if (linkType(sourceMarkdown) === LINK_HTML) { return closeTag('a'); diff --git a/app/assets/javascripts/content_editor/services/table_of_contents_utils.js b/app/assets/javascripts/content_editor/services/table_of_contents_utils.js new file mode 100644 index 00000000000..dad917b2270 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/table_of_contents_utils.js @@ -0,0 +1,67 @@ +export function fillEmpty(headings) { + for (let i = 0; i < headings.length; i += 1) { + let j = headings[i - 1]?.level || 0; + if (headings[i].level - j > 1) { + while (j < headings[i].level) { + headings.splice(i, 0, { level: j + 1, text: '' }); + j += 1; + } + } + } + + return headings; +} + +const exitHeadingBranch = (heading, targetLevel) => { + let currentHeading = heading; + + while (currentHeading.level > targetLevel) { + currentHeading = currentHeading.parent; + } + + return currentHeading; +}; + +export function toTree(headings) { + fillEmpty(headings); + + const tree = []; + let currentHeading; + for (let i = 0; i < headings.length; i += 1) { + const heading = headings[i]; + if (heading.level === 1) { + const h = { ...heading, subHeadings: [] }; + tree.push(h); + currentHeading = h; + } else if (heading.level > currentHeading.level) { + const h = { ...heading, subHeadings: [], parent: currentHeading }; + currentHeading.subHeadings.push(h); + currentHeading = h; + } else if (heading.level <= currentHeading.level) { + currentHeading = exitHeadingBranch(currentHeading, heading.level - 1); + + const h = { ...heading, subHeadings: [], parent: currentHeading }; + (currentHeading?.subHeadings || headings).push(h); + currentHeading = h; + } + } + + return tree; +} + +export function getHeadings(editor) { + const headings = []; + + editor.state.doc.descendants((node) => { + if (node.type.name !== 'heading') return false; + + headings.push({ + level: node.attrs.level, + text: node.textContent, + }); + + return true; + }); + + return toTree(headings); +} diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js index 79f5c701fb8..8b432b2041a 100644 --- a/app/assets/javascripts/contributors/stores/getters.js +++ b/app/assets/javascripts/contributors/stores/getters.js @@ -4,15 +4,15 @@ export const parsedData = (state) => { const byAuthorEmail = {}; const total = {}; - state.chartData.forEach(({ date, author_name, author_email }) => { + state.chartData.forEach(({ date, author_name: name, author_email: email }) => { total[date] = total[date] ? total[date] + 1 : 1; - const normalizedEmail = author_email.toLowerCase(); + const normalizedEmail = email.toLowerCase(); const authorData = byAuthorEmail[normalizedEmail]; if (!authorData) { byAuthorEmail[normalizedEmail] = { - name: author_name, + name, commits: 1, dates: { [date]: 1, diff --git a/app/assets/javascripts/crm/components/form.vue b/app/assets/javascripts/crm/components/form.vue index 72def54aedf..ea6a6892bbd 100644 --- a/app/assets/javascripts/crm/components/form.vue +++ b/app/assets/javascripts/crm/components/form.vue @@ -1,5 +1,13 @@ <script> -import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui'; +import { + GlAlert, + GlButton, + GlDrawer, + GlFormCheckbox, + GlFormGroup, + GlFormInput, + GlFormSelect, +} from '@gitlab/ui'; import { get as getPropValueByPath, isEmpty } from 'lodash'; import { produce } from 'immer'; import { MountingPortal } from 'portal-vue'; @@ -26,6 +34,7 @@ export default { GlAlert, GlButton, GlDrawer, + GlFormCheckbox, GlFormGroup, GlFormInput, GlFormSelect, @@ -113,7 +122,9 @@ export default { const { fields, model } = this; return fields.some((field) => { - return field.required && isEmpty(model[field.name]); + return ( + field.required && isEmpty(model[field.name]) && typeof model[field.name] !== 'boolean' + ); }); }, variables() { @@ -216,6 +227,8 @@ export default { }); }, getFieldLabel(field) { + if (field.bool) return null; + const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`; return field.label + optionalSuffix; }, @@ -273,6 +286,9 @@ export default { v-model="model[field.name]" :options="field.values" /> + <gl-form-checkbox v-else-if="field.bool" :id="field.name" v-model="model[field.name]" + ><span class="gl-font-weight-bold">{{ field.label }}</span></gl-form-checkbox + > <gl-form-input v-else :id="field.name" v-bind="field.input" v-model="model[field.name]" /> </gl-form-group> <span class="gl-float-right"> diff --git a/app/assets/javascripts/crm/constants.js b/app/assets/javascripts/crm/constants.js index 3b085837aea..815289e075e 100644 --- a/app/assets/javascripts/crm/constants.js +++ b/app/assets/javascripts/crm/constants.js @@ -1,3 +1,7 @@ export const INDEX_ROUTE_NAME = 'index'; export const NEW_ROUTE_NAME = 'new'; export const EDIT_ROUTE_NAME = 'edit'; +export const trackViewsOptions = { + category: 'Customer Relations' /* eslint-disable-line @gitlab/require-i18n-strings */, + action: 'view_contacts_list', +}; diff --git a/app/assets/javascripts/crm/contacts/bundle.js b/app/assets/javascripts/crm/contacts/bundle.js index f49ec64210f..fe62b7cfbe3 100644 --- a/app/assets/javascripts/crm/contacts/bundle.js +++ b/app/assets/javascripts/crm/contacts/bundle.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import CrmContactsRoot from './components/contacts_root.vue'; import routes from './routes'; @@ -21,7 +22,14 @@ export default () => { return false; } - const { basePath, groupFullPath, groupIssuesPath, canAdminCrmContact, groupId } = el.dataset; + const { + basePath, + groupFullPath, + groupIssuesPath, + canAdminCrmContact, + groupId, + textQuery, + } = el.dataset; const router = new VueRouter({ base: basePath, @@ -33,7 +41,13 @@ export default () => { el, router, apolloProvider, - provide: { groupFullPath, groupIssuesPath, canAdminCrmContact, groupId }, + provide: { + groupFullPath, + groupIssuesPath, + canAdminCrmContact: parseBoolean(canAdminCrmContact), + groupId, + textQuery, + }, render(createElement) { return createElement(CrmContactsRoot); }, diff --git a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue index f114ffedfe6..b29089519e2 100644 --- a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue +++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue @@ -57,7 +57,7 @@ export default { getQuery() { return { query: getGroupContactsQuery, - variables: { groupFullPath: this.groupFullPath }, + variables: { groupFullPath: this.groupFullPath, ids: [this.contactGraphQLId] }, }; }, title() { @@ -74,7 +74,7 @@ export default { return { groupId: this.groupGraphQLId }; }, fields() { - return [ + const fields = [ { name: 'firstName', label: __('First name'), required: true }, { name: 'lastName', label: __('Last name'), required: true }, { name: 'email', label: __('Email'), required: true }, @@ -86,6 +86,11 @@ export default { }, { name: 'description', label: __('Description') }, ]; + + if (this.isEditMode) + fields.push({ name: 'active', label: s__('Crm|Active'), required: true, bool: true }); + + return fields; }, organizationSelectValues() { const values = this.organizations.map((o) => { diff --git a/app/assets/javascripts/crm/contacts/components/contacts_root.vue b/app/assets/javascripts/crm/contacts/components/contacts_root.vue index 9d6f34c73b7..562363ff88e 100644 --- a/app/assets/javascripts/crm/contacts/components/contacts_root.vue +++ b/app/assets/javascripts/crm/contacts/components/contacts_root.vue @@ -1,36 +1,54 @@ <script> -import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants'; -import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql'; +import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; +import { + bodyTrClass, + initialPaginationState, +} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants'; +import { convertToSnakeCase } from '~/lib/utils/text_utility'; +import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME, trackViewsOptions } from '../../constants'; +import getGroupContacts from './graphql/get_group_contacts.query.graphql'; +import getGroupContactsCountByState from './graphql/get_group_contacts_count_by_state.graphql'; export default { components: { - GlAlert, GlButton, GlLoadingIcon, GlTable, + PaginatedTableWithSearchAndTabs, }, directives: { GlTooltip: GlTooltipDirective, }, - inject: ['canAdminCrmContact', 'groupFullPath', 'groupIssuesPath'], + inject: ['canAdminCrmContact', 'groupFullPath', 'groupIssuesPath', 'textQuery'], data() { return { - contacts: [], + contacts: { list: [] }, + contactsCount: {}, error: false, + filteredByStatus: '', + pagination: initialPaginationState, + statusFilter: 'all', + searchTerm: this.textQuery, + sort: 'LAST_NAME_ASC', + sortDesc: false, }; }, apollo: { contacts: { - query() { - return getGroupContactsQuery; - }, + query: getGroupContacts, variables() { return { groupFullPath: this.groupFullPath, + searchTerm: this.searchTerm, + state: this.statusFilter, + sort: this.sort, + firstPageSize: this.pagination.firstPageSize, + lastPageSize: this.pagination.lastPageSize, + prevPageCursor: this.pagination.prevPageCursor, + nextPageCursor: this.pagination.nextPageCursor, }; }, update(data) { @@ -40,19 +58,52 @@ export default { this.error = true; }, }, + contactsCount: { + query: getGroupContactsCountByState, + variables() { + return { + groupFullPath: this.groupFullPath, + searchTerm: this.searchTerm, + }; + }, + update(data) { + return data?.group?.contactStateCounts; + }, + error() { + this.error = true; + }, + }, }, computed: { isLoading() { return this.$apollo.queries.contacts.loading; }, - canAdmin() { - return parseBoolean(this.canAdminCrmContact); + tbodyTrClass() { + return { + [bodyTrClass]: !this.loading && !this.isEmpty, + }; }, }, methods: { + errorAlertDismissed() { + this.error = true; + }, extractContacts(data) { const contacts = data?.group?.contacts?.nodes || []; - return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName)); + const pageInfo = data?.group?.contacts?.pageInfo || {}; + return { + list: contacts, + pageInfo, + }; + }, + fetchSortedData({ sortBy, sortDesc }) { + const sortingColumn = convertToSnakeCase(sortBy).toUpperCase(); + const sortingDirection = sortDesc ? 'DESC' : 'ASC'; + this.pagination = initialPaginationState; + this.sort = `${sortingColumn}_${sortingDirection}`; + }, + filtersChanged({ searchTerm }) { + this.searchTerm = searchTerm; }, getIssuesPath(path, value) { return `${path}?crm_contact_id=${value}`; @@ -60,6 +111,13 @@ export default { getEditRoute(id) { return { name: this.$options.EDIT_ROUTE_NAME, params: { id } }; }, + pageChanged(pagination) { + this.pagination = pagination; + }, + statusChanged({ filters, status }) { + this.statusFilter = filters; + this.filteredByStatus = status; + }, }, fields: [ { key: 'firstName', sortable: true }, @@ -92,57 +150,109 @@ export default { }, EDIT_ROUTE_NAME, NEW_ROUTE_NAME, + statusTabs: [ + { + title: __('Active'), + status: 'ACTIVE', + filters: 'active', + }, + { + title: __('Inactive'), + status: 'INACTIVE', + filters: 'inactive', + }, + { + title: __('All'), + status: 'ALL', + filters: 'all', + }, + ], + trackViewsOptions, + emptyArray: [], }; </script> <template> <div> - <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false"> - {{ $options.i18n.errorText }} - </gl-alert> - <div - class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6" + <paginated-table-with-search-and-tabs + :show-items="true" + :show-error-msg="false" + :i18n="$options.i18n" + :items="contacts.list" + :page-info="contacts.pageInfo" + :items-count="contactsCount" + :status-tabs="$options.statusTabs" + :track-views-options="$options.trackViewsOptions" + :filter-search-tokens="$options.emptyArray" + filter-search-key="contacts" + @page-changed="pageChanged" + @tabs-changed="statusChanged" + @filters-changed="filtersChanged" + @error-alert-dismissed="errorAlertDismissed" > - <h2 class="gl-font-size-h2 gl-my-0"> - {{ $options.i18n.title }} - </h2> - <div v-if="canAdmin"> - <router-link :to="{ name: $options.NEW_ROUTE_NAME }"> - <gl-button variant="confirm" data-testid="new-contact-button"> + <template #header-actions> + <router-link v-if="canAdminCrmContact" :to="{ name: $options.NEW_ROUTE_NAME }"> + <gl-button class="gl-my-3 gl-mr-5" variant="confirm" data-testid="new-contact-button"> {{ $options.i18n.newContact }} </gl-button> </router-link> - </div> - </div> - <router-view /> - <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> - <gl-table - v-else - class="gl-mt-5" - :items="contacts" - :fields="$options.fields" - :empty-text="$options.i18n.emptyText" - show-empty - > - <template #cell(id)="{ value: id }"> - <gl-button - v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" - class="gl-mr-3" - data-testid="issues-link" - icon="issues" - :aria-label="$options.i18n.issuesButtonLabel" - :href="getIssuesPath(groupIssuesPath, id)" - /> - <router-link :to="getEditRoute(id)"> - <gl-button - v-if="canAdmin" - v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" - data-testid="edit-contact-button" - icon="pencil" - :aria-label="$options.i18n.editButtonLabel" - /> - </router-link> </template> - </gl-table> + + <template #title> + {{ $options.i18n.title }} + </template> + + <template #table> + <gl-table + :items="contacts.list" + :fields="$options.fields" + :busy="isLoading" + stacked="md" + :tbody-tr-class="tbodyTrClass" + sort-direction="asc" + :sort-desc.sync="sortDesc" + sort-by="createdAt" + show-empty + no-local-sorting + sort-icon-left + fixed + @sort-changed="fetchSortedData" + > + <template #cell(id)="{ value: id }"> + <gl-button + v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" + class="gl-mr-3" + data-testid="issues-link" + icon="issues" + :aria-label="$options.i18n.issuesButtonLabel" + :href="getIssuesPath(groupIssuesPath, id)" + /> + <router-link :to="getEditRoute(id)"> + <gl-button + v-if="canAdminCrmContact" + v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" + data-testid="edit-contact-button" + icon="pencil" + :aria-label="$options.i18n.editButtonLabel" + /> + </router-link> + </template> + + <template #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> + </template> + + <template #empty> + <span v-if="error"> + {{ $options.i18n.errorText }} + </span> + <span v-else> + {{ $options.i18n.emptyText }} + </span> + </template> + </gl-table> + </template> + </paginated-table-with-search-and-tabs> + <router-view /> </div> </template> diff --git a/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql b/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql index cef4083b446..545ddbf5f72 100644 --- a/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql +++ b/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql @@ -1,13 +1,12 @@ fragment ContactFragment on CustomerRelationsContact { - __typename id firstName lastName email phone description + active organization { - __typename id name } diff --git a/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql index 2a8150e42e3..f04d02122fc 100644 --- a/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql +++ b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql @@ -1,13 +1,37 @@ #import "./crm_contact_fields.fragment.graphql" -query contacts($groupFullPath: ID!) { +query contacts( + $groupFullPath: ID! + $state: CustomerRelationsContactState + $searchTerm: String + $sort: ContactSort + $firstPageSize: Int + $lastPageSize: Int + $prevPageCursor: String = "" + $nextPageCursor: String = "" + $ids: [CustomerRelationsContactID!] +) { group(fullPath: $groupFullPath) { - __typename id - contacts { + contacts( + state: $state + search: $searchTerm + sort: $sort + first: $firstPageSize + last: $lastPageSize + after: $nextPageCursor + before: $prevPageCursor + ids: $ids + ) { nodes { ...ContactFragment } + pageInfo { + hasNextPage + endCursor + hasPreviousPage + startCursor + } } } } diff --git a/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql new file mode 100644 index 00000000000..6b591240096 --- /dev/null +++ b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql @@ -0,0 +1,11 @@ +query contactsCountByState($groupFullPath: ID!, $searchTerm: String) { + group(fullPath: $groupFullPath) { + __typename + id + contactStateCounts(search: $searchTerm) { + all + active + inactive + } + } +} diff --git a/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql b/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql index 4adc5742d3a..d723bf32ef5 100644 --- a/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql +++ b/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql @@ -1,7 +1,7 @@ fragment OrganizationFragment on CustomerRelationsOrganization { - __typename id name defaultRate description + active } diff --git a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql index e8d8109431e..97b75091cac 100644 --- a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql +++ b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql @@ -2,7 +2,6 @@ query organizations($groupFullPath: ID!) { group(fullPath: $groupFullPath) { - __typename id organizations { nodes { diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue index 38468e1f4e4..5fd0294b0ea 100644 --- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue +++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue @@ -52,16 +52,23 @@ export default { additionalCreateParams() { return { groupId: this.groupGraphQLId }; }, - }, - fields: [ - { name: 'name', label: __('Name'), required: true }, - { - name: 'defaultRate', - label: s__('Crm|Default rate'), - input: { type: 'number', step: '0.01' }, + fields() { + const fields = [ + { name: 'name', label: __('Name'), required: true }, + { + name: 'defaultRate', + label: s__('Crm|Default rate'), + input: { type: 'number', step: '0.01' }, + }, + { name: 'description', label: __('Description') }, + ]; + + if (this.isEditMode) + fields.push({ name: 'active', label: s__('Crm|Active'), required: true, bool: true }); + + return fields; }, - { name: 'description', label: __('Description') }, - ], + }, }; </script> @@ -73,7 +80,7 @@ export default { :mutation="mutation" :additional-create-params="additionalCreateParams" :existing-id="organizationGraphQLId" - :fields="$options.fields" + :fields="fields" :title="title" :success-message="successMessage" /> diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index 1883030e51f..f06544f50c6 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -47,15 +47,13 @@ export default { 'selectedStage', 'selectedStageEvents', 'selectedStageError', - 'stages', - 'summary', - 'permissions', 'stageCounts', 'endpoints', 'features', 'createdBefore', 'createdAfter', 'pagination', + 'hasNoAccessError', ]), ...mapGetters(['pathNavigationData', 'filterParams']), isLoaded() { @@ -69,9 +67,7 @@ export default { return !this.isLoadingStage && this.isEmptyStage; }, displayNoAccess() { - return ( - !this.isLoadingStage && this.selectedStage?.id && !this.isUserAllowed(this.selectedStage.id) - ); + return !this.isLoadingStage && this.hasNoAccessError; }, displayPathNavigation() { return this.isLoading || (this.selectedStage && this.pathNavigationData.length); @@ -137,10 +133,6 @@ export default { this.isOverviewDialogDismissed = true; setCookie(OVERVIEW_DIALOG_COOKIE, '1'); }, - isUserAllowed(id) { - const { permissions } = this; - return Boolean(permissions?.[id]); - }, onHandleUpdatePagination(data) { this.updateStageTablePagination(data); }, diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index e0156b24f9d..5c2e29bfa74 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -1,7 +1,6 @@ import { getProjectValueStreamStages, getProjectValueStreams, - getProjectValueStreamMetrics, getValueStreamStageMedian, getValueStreamStageRecords, getValueStreamStageCounts, @@ -52,24 +51,6 @@ export const fetchValueStreams = ({ commit, dispatch, state }) => { commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); }); }; -export const fetchCycleAnalyticsData = ({ - state: { - endpoints: { requestPath }, - }, - getters: { legacyFilterParams }, - commit, -}) => { - commit(types.REQUEST_CYCLE_ANALYTICS_DATA); - - return getProjectValueStreamMetrics(requestPath, legacyFilterParams) - .then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data)) - .catch(() => { - commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR); - createFlash({ - message: __('There was an error while fetching value stream summary data.'), - }); - }); -}; export const fetchStageData = ({ getters: { requestParams, filterParams, paginationParams }, @@ -153,7 +134,6 @@ export const fetchStageCountValues = ({ export const fetchValueStreamStageData = ({ dispatch }) => Promise.all([ - dispatch('fetchCycleAnalyticsData'), dispatch('fetchStageData'), dispatch('fetchStageMedians'), dispatch('fetchStageCountValues'), @@ -178,6 +158,11 @@ export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore }; export const setInitialStage = ({ dispatch, commit, state: { stages } }, stage) => { + if (!stages.length && !stage) { + commit(types.SET_NO_ACCESS_ERROR); + return null; + } + const selectedStage = stage || stages[0]; commit(types.SET_SELECTED_STAGE, selectedStage); return dispatch('fetchValueStreamStageData'); diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js index 962e1d50d12..6fe353405d4 100644 --- a/app/assets/javascripts/cycle_analytics/store/getters.js +++ b/app/assets/javascripts/cycle_analytics/store/getters.js @@ -49,12 +49,6 @@ const dateRangeParams = ({ createdAfter, createdBefore }) => ({ created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null, }); -export const legacyFilterParams = ({ daysInPast }) => { - return { - 'cycle_analytics[start_date]': daysInPast, - }; -}; - export const filterParams = (state) => { return { ...filterBarParams(state), diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js index 0ad67d4e6bd..9376d81f317 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js +++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js @@ -5,6 +5,7 @@ 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 SET_NO_ACCESS_ERROR = 'SET_NO_ACCESS_ERROR'; export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS'; export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS'; @@ -14,10 +15,6 @@ export const REQUEST_VALUE_STREAM_STAGES = 'REQUEST_VALUE_STREAM_STAGES'; export const RECEIVE_VALUE_STREAM_STAGES_SUCCESS = 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS'; export const RECEIVE_VALUE_STREAM_STAGES_ERROR = 'RECEIVE_VALUE_STREAM_STAGES_ERROR'; -export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA'; -export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS'; -export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR'; - export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA'; export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS'; export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR'; diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js index 64930a5b51f..8567529caf2 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/cycle_analytics/store/mutations.js @@ -41,6 +41,9 @@ export default { direction: direction || PAGINATION_SORT_DIRECTION_DESC, }); }, + [types.SET_NO_ACCESS_ERROR](state) { + state.hasNoAccessError = true; + }, [types.REQUEST_VALUE_STREAMS](state) { state.valueStreams = []; }, @@ -59,23 +62,12 @@ export default { [types.RECEIVE_VALUE_STREAM_STAGES_ERROR](state) { state.stages = []; }, - [types.REQUEST_CYCLE_ANALYTICS_DATA](state) { - state.isLoading = true; - state.hasError = false; - }, - [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { - state.permissions = data?.permissions || {}; - state.hasError = false; - }, - [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { - state.isLoading = false; - state.hasError = true; - }, [types.REQUEST_STAGE_DATA](state) { state.isLoadingStage = true; state.isEmptyStage = false; state.selectedStageEvents = []; - state.hasError = false; + + state.hasNoAccessError = false; }, [types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) { state.isLoadingStage = false; @@ -83,13 +75,14 @@ export default { state.selectedStageEvents = events.map((ev) => convertObjectPropsToCamelCase(ev, { deep: true }), ); - state.hasError = false; + + state.hasNoAccessError = false; }, [types.RECEIVE_STAGE_DATA_ERROR](state, error) { state.isLoadingStage = false; state.isEmptyStage = true; state.selectedStageEvents = []; - state.hasError = true; + state.selectedStageError = error; }, [types.REQUEST_STAGE_MEDIANS](state) { diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js index 52bc01cafa4..8d662333afa 100644 --- a/app/assets/javascripts/cycle_analytics/store/state.js +++ b/app/assets/javascripts/cycle_analytics/store/state.js @@ -10,9 +10,7 @@ export default () => ({ createdAfter: null, createdBefore: null, stages: [], - summary: [], analytics: [], - stats: [], valueStreams: [], selectedValueStream: {}, selectedStage: {}, @@ -20,11 +18,10 @@ export default () => ({ selectedStageError: '', medians: {}, stageCounts: {}, - hasError: false, + hasNoAccessError: false, isLoading: false, isLoadingStage: false, isEmptyStage: false, - permissions: {}, pagination: { page: null, hasNextPage: false, diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 618096c5bea..ac00af2ab34 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -290,7 +290,6 @@ export default { <template v-else> <reply-placeholder v-if="!isFormVisible" - class="qa-discussion-reply" :placeholder-text="__('Reply…')" @focus="showForm" /> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 818299e36bd..1b6458668f5 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -90,7 +90,6 @@ export default { <form class="new-note common-note-form" @submit.prevent> <markdown-field :markdown-preview-path="markdownPreviewPath" - :can-attach-file="false" :enable-autocomplete="true" :textarea-value="value" :markdown-docs-path="markdownDocsPath" diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql index 3fe20705ce2..9d9e3a4ede9 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql @@ -1,11 +1,9 @@ fragment DesignTodoItem on Design { id image - __typename currentUserTodos(state: pending) { nodes { id - __typename } } } diff --git a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql index b715633a9f2..09a0b39e1cd 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql @@ -3,7 +3,6 @@ fragment VersionListItem on DesignVersion { sha createdAt author { - __typename id name avatarUrl diff --git a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql index 3200327e03d..6fe2cae7346 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql @@ -6,9 +6,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { designs { ...DesignItem versions { - __typename nodes { - __typename ...VersionListItem } } diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 51983b19677..91e35ad3764 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -372,7 +372,7 @@ export default { </div> <div v-show="hasDesigns" - class="qa-selector-toolbar gl-display-flex gl-align-items-center gl-my-2" + class="gl-display-flex gl-align-items-center gl-my-2" data-testid="design-selector-toolbar" > <gl-button diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 8388458b11c..833fbb8789e 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -175,6 +175,7 @@ export default class Diff { } } + // eslint-disable-next-line class-methods-use-this formatElementToObject = (element) => { const key = element.attributes['data-file-hash'].value; const name = element.attributes['data-diff-toggle-entity'].value; @@ -192,6 +193,7 @@ export default class Diff { return $elements.toArray().map(diff.formatElementToObject).reduce(merge); }; + // eslint-disable-next-line class-methods-use-this showRawViewer = (fileHash, elements) => { if (elements === undefined) return; @@ -202,6 +204,7 @@ export default class Diff { elements.rawViewer.classList.remove('hidden'); }; + // eslint-disable-next-line class-methods-use-this showRenderedViewer = (fileHash, elements) => { if (elements === undefined) return; diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index ad163a2a615..0e5acd0928b 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -104,12 +104,9 @@ export default { class="d-inline-flex mb-2" /> <gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group"> - <gl-button - label - class="gl-font-monospace" - data-testid="commit-sha-short-id" - v-text="commit.short_id" - /> + <gl-button label class="gl-font-monospace" data-testid="commit-sha-short-id">{{ + commit.short_id + }}</gl-button> <modal-copy-button :text="commit.id" :title="__('Copy commit SHA')" diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index fc5766a23ef..3082ba0f16f 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -217,52 +217,47 @@ export default { </script> <template> - <div class="diff-grid-row diff-grid-row-full diff-tr line_holder match expansion"> - <div :class="{ parallel: !inline }" class="diff-grid-left diff-grid-2-col left-side"> - <div - class="diff-td diff-line-num gl-text-center! gl-p-0! gl-w-full! gl-display-flex gl-flex-direction-column" + <div> + <div + class="diff-td diff-line-num gl-text-center! gl-p-0! gl-w-full! gl-display-flex gl-flex-direction-column" + > + <button + v-if="showExpandDown" + :title="s__('Diffs|Next 20 lines')" + :disabled="loading.down" + type="button" + class="js-unfold-down gl-rounded-0 gl-border-0 diff-line-expand-button" + @click="handleExpandLines($options.EXPAND_DOWN)" > - <button - v-if="showExpandDown" - v-gl-tooltip.left - :title="s__('Diffs|Next 20 lines')" - :disabled="loading.down" - type="button" - class="js-unfold-down gl-rounded-0 gl-border-0 diff-line-expand-button" - @click="handleExpandLines($options.EXPAND_DOWN)" - > - <gl-loading-icon v-if="loading.down" size="sm" color="dark" inline /> - <gl-icon v-else name="expand-down" /> - </button> - <button - v-if="lineCountBetween !== -1 && lineCountBetween < 20" - v-gl-tooltip.left - :title="s__('Diffs|Expand all lines')" - :disabled="loading.all" - type="button" - class="js-unfold-all gl-rounded-0 gl-border-0 diff-line-expand-button" - @click="handleExpandLines()" - > - <gl-loading-icon v-if="loading.all" size="sm" color="dark" inline /> - <gl-icon v-else name="expand" /> - </button> - <button - v-if="showExpandUp" - v-gl-tooltip.left - :title="s__('Diffs|Previous 20 lines')" - :disabled="loading.up" - type="button" - class="js-unfold gl-rounded-0 gl-border-0 diff-line-expand-button" - @click="handleExpandLines($options.EXPAND_UP)" - > - <gl-loading-icon v-if="loading.up" size="sm" color="dark" inline /> - <gl-icon v-else name="expand-up" /> - </button> - </div> - <div - v-safe-html="line.rich_text" - class="gl-display-flex! gl-flex-direction-column gl-justify-content-center diff-td line_content left-side gl-white-space-normal!" - ></div> + <gl-loading-icon v-if="loading.down" size="sm" color="dark" inline /> + <gl-icon v-else name="expand-down" /> + </button> + <button + v-if="lineCountBetween !== -1 && lineCountBetween < 20" + :title="s__('Diffs|Expand all lines')" + :disabled="loading.all" + type="button" + class="js-unfold-all gl-rounded-0 gl-border-0 diff-line-expand-button" + @click="handleExpandLines()" + > + <gl-loading-icon v-if="loading.all" size="sm" color="dark" inline /> + <gl-icon v-else name="expand" /> + </button> + <button + v-if="showExpandUp" + :title="s__('Diffs|Previous 20 lines')" + :disabled="loading.up" + type="button" + class="js-unfold gl-rounded-0 gl-border-0 diff-line-expand-button" + @click="handleExpandLines($options.EXPAND_UP)" + > + <gl-loading-icon v-if="loading.up" size="sm" color="dark" inline /> + <gl-icon v-else name="expand-up" /> + </button> </div> + <div + v-safe-html="line.rich_text" + class="gl-display-flex! gl-flex-direction-column gl-justify-content-center diff-td line_content left-side gl-white-space-normal!" + ></div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index ad406947561..ea94df1ad5b 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -197,17 +197,33 @@ export default { @mousedown="handleParallelLineMouseDown" > <template v-for="(line, index) in diffLines"> - <template v-if="line.isMatchLineLeft || line.isMatchLineRight"> + <div + v-if="line.isMatchLineLeft || line.isMatchLineRight" + :key="`expand-${index}`" + class="diff-grid-row diff-tr line_holder match expansion" + > <diff-expansion-cell - :key="`expand-${index}`" :file="diffFile" :line="line.left" :is-top="index === 0" :is-bottom="index + 1 === diffLinesLength" :inline="inline" :line-count-between="getCountBetweenIndex(index)" + :class="{ parallel: !inline }" + class="diff-grid-left diff-grid-2-col left-side" /> - </template> + <diff-expansion-cell + v-if="!inline" + :file="diffFile" + :line="line.left" + :is-top="index === 0" + :is-bottom="index + 1 === diffLinesLength" + :inline="inline" + :line-count-between="getCountBetweenIndex(index)" + :class="{ parallel: !inline }" + class="diff-grid-right diff-grid-2-col right-side" + /> + </div> <diff-row v-if="!line.isMatchLineLeft && !line.isMatchLineRight" :key="line.line_code" diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 6c0c9c4e1d0..1cc96ef3d54 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -71,12 +71,15 @@ export const DIFF_FILE_MANUAL_COLLAPSE = 'manual'; export const STATE_IDLING = 'idle'; export const STATE_LOADING = 'loading'; export const STATE_ERRORED = 'errored'; +export const STATE_PENDING_REVIEW = 'pending_comments'; // State machine transitions export const TRANSITION_LOAD_START = 'LOAD_START'; export const TRANSITION_LOAD_ERROR = 'LOAD_ERROR'; export const TRANSITION_LOAD_SUCCEED = 'LOAD_SUCCEED'; export const TRANSITION_ACKNOWLEDGE_ERROR = 'ACKNOWLEDGE_ERROR'; +export const TRANSITION_HAS_PENDING_REVIEW = 'PENDING_REVIEW'; +export const TRANSITION_NO_REVIEW = 'NO_REVIEW'; export const RENAMED_DIFF_TRANSITIONS = { [`${STATE_IDLING}:${TRANSITION_LOAD_START}`]: STATE_LOADING, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index ace507f601a..5e74a7206b3 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -119,10 +119,10 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { const getBatch = (page = startPage) => axios .get(mergeUrlParams({ ...urlParams, page, per_page: perPage }, state.endpointBatch)) - .then(({ data: { pagination, diff_files } }) => { - totalLoaded += diff_files.length; + .then(({ data: { pagination, diff_files: diffFiles } }) => { + totalLoaded += diffFiles.length; - commit(types.SET_DIFF_DATA_BATCH, { diff_files }); + commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffFiles }); commit(types.SET_BATCH_LOADING_STATE, 'loaded'); if (!scrolledVirtualScroller) { @@ -138,7 +138,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { } if (!isNoteLink && !state.currentDiffFileId) { - commit(types.SET_CURRENT_DIFF_FILE, diff_files[0]?.file_hash); + commit(types.SET_CURRENT_DIFF_FILE, diffFiles[0]?.file_hash); } if (isNoteLink) { @@ -293,8 +293,8 @@ export const assignDiscussionsToDiff = ( }; export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { - const { file_hash, line_code, id } = removeDiscussion; - commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id }); + const { file_hash: fileHash, line_code: lineCode, id } = removeDiscussion; + commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash, lineCode, id }); }; export const toggleLineDiscussions = ({ commit }, options) => { diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 491c2ced358..e6f7a31e07b 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -28,7 +28,6 @@ function getErrorMessage(res) { export default function dropzoneInput(form, config = { parallelUploads: 2 }) { const divHover = '<div class="div-dropzone-hover"></div>'; const iconPaperclip = spriteIcon('paperclip', 'div-dropzone-icon s24'); - const $attachButton = form.find('.button-attach-file'); const $attachingFileMessage = form.find('.attaching-file-message'); const $cancelButton = form.find('.button-cancel-uploading-files'); const $retryLink = form.find('.retry-uploading-link'); @@ -89,8 +88,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { const shouldPad = processingFileCount >= 1; pasteText(response.link.markdown, shouldPad); - // Show 'Attach a file' link only when all files have been uploaded. - if (!processingFileCount) $attachButton.removeClass('hide'); addFileToForm(response.link.url); }, error: (file, errorMessage = __('Attaching the file failed.'), xhr) => { @@ -104,7 +101,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { $uploadingErrorContainer.removeClass('hide'); $uploadingErrorMessage.html(message); - $attachButton.addClass('hide'); $cancelButton.addClass('hide'); }, totaluploadprogress(totalUploadProgress) { @@ -115,13 +111,11 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { // DOM elements already exist. // Instead of dynamically generating them, // we just either hide or show them. - $attachButton.addClass('hide'); $uploadingErrorContainer.addClass('hide'); $uploadingProgressContainer.removeClass('hide'); $cancelButton.removeClass('hide'); }, removedfile: () => { - $attachButton.removeClass('hide'); $cancelButton.addClass('hide'); $uploadingProgressContainer.addClass('hide'); $uploadingErrorContainer.addClass('hide'); @@ -282,11 +276,18 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { messageContainer.text(`${attachingMessage} -`); }; - form.find('.markdown-selector').click(function onMarkdownClick(e) { + function handleAttachFile(e) { e.preventDefault(); $(this).closest('.gfm-form').find('.div-dropzone').click(); formTextarea.focus(); - }); + } + + form.find('.markdown-selector').click(handleAttachFile); + + const $attachFileButton = form.find('.js-attach-file-button'); + if ($attachFileButton.length) { + $attachFileButton.get(0).addEventListener('click', handleAttachFile); + } return $formDropzone.get(0) ? Dropzone.forElement($formDropzone.get(0)) : null; } diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue index 194b482c12e..6ce48ddf89a 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue @@ -52,6 +52,7 @@ export default { :icon="icon" :title="label" :aria-label="label" + data-qa-selector="editor_toolbar_button" @click="clickHandler" /> </template> diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js index e4ad0bf8e76..bc3cb163c39 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js @@ -1,3 +1,4 @@ +import { KeyMod, KeyCode } from 'monaco-editor'; import { debounce } from 'lodash'; import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants'; import createFlash from '~/flash'; @@ -158,8 +159,8 @@ export class EditorMarkdownPreviewExtension { if (instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; const actionBasis = { keybindings: [ - // eslint-disable-next-line no-bitwise,no-undef - monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P), + // eslint-disable-next-line no-bitwise + KeyMod.chord(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KEY_P), ], contextMenuGroupId: 'navigation', contextMenuOrder: 1.5, diff --git a/app/assets/javascripts/editor/graphql/typedefs.graphql b/app/assets/javascripts/editor/graphql/typedefs.graphql index 2433ebf6c66..49beae033f1 100644 --- a/app/assets/javascripts/editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/editor/graphql/typedefs.graphql @@ -12,12 +12,22 @@ type Items { nodes: [Item]! } +input ItemInput { + id: ID! + label: String! + icon: String + selected: Boolean + group: Int! + category: String + selectedLabel: String +} + extend type Query { items: Items } extend type Mutation { - updateToolbarItem(id: ID!, propsToUpdate: Item!): LocalErrors + updateToolbarItem(id: ID!, propsToUpdate: ItemInput!): LocalErrors removeToolbarItems(ids: [ID!]): LocalErrors - addToolbarItems(items: [Item]): LocalErrors + addToolbarItems(items: [ItemInput]): LocalErrors } diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index e8b96c25965..848ba7dbeef 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -245,6 +245,10 @@ "terraform": { "$ref": "#/definitions/string_file_list", "description": "Path to file or list of files with terraform plan(s)." + }, + "cyclonedx": { + "$ref": "#/definitions/string_file_list", + "markdownDescription": "Path to file or list of files with cyclonedx report(s). [Learn More](https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportscyclonedx)." } } } @@ -292,7 +296,7 @@ "project": { "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.", "type": "string", - "pattern": "\\S/\\S" + "pattern": "\\S/\\S|\\$(\\S+)" }, "ref": { "description": "Branch/Tag/Commit-hash for the target project.", @@ -606,11 +610,33 @@ "markdownDescription": "Expression to evaluate whether additional attributes should be provided to the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rulesif)." }, "changes": { - "type": "array", "markdownDescription": "Additional attributes will be provided to job if any of the provided paths matches a modified file. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#ruleschanges).", - "items": { - "type": "string" - } + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["paths"], + "properties": { + "paths": { + "type": "array", + "description": "List of file paths.", + "items": { + "type": "string" + } + }, + "compare_to": { + "type": "string", + "description": "Ref for comparing changes." + } + } + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "exists": { "type": "array", @@ -623,11 +649,11 @@ "markdownDescription": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rulesvariables).", "anyOf": [ { - "type": "object", + "type": "object", "additionalProperties": { "type": ["string", "integer", "array"] } - }, + }, { "type": "array", "items": { @@ -1204,6 +1230,10 @@ "description": "The tag_name must be specified. It can refer to an existing Git tag or can be specified by the user.", "minLength": 1 }, + "tag_message": { + "type": "string", + "description": "Message to use if creating a new annotated tag." + }, "description": { "type": "string", "description": "Specifies the longer description of the Release.", diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 7ffe8140a21..1e9924246b9 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -627,7 +627,7 @@ export default { :title="model.name" class="environment-name table-mobile-content" > - <a class="qa-environment-link" :href="environmentPath"> + <a data-qa-selector="environment_link" :href="environmentPath"> <span v-if="model.size === 1">{{ model.name }}</span> <span v-else>{{ model.name_without_type }}</span> </a> diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue index 13b9cf14f52..bd67908a6b4 100644 --- a/app/assets/javascripts/environments/components/environments_detail_header.vue +++ b/app/assets/javascripts/environments/components/environments_detail_header.vue @@ -135,6 +135,7 @@ export default { > <gl-button v-if="shouldShowExternalUrlButton" + v-gl-tooltip.hover data-testid="metrics-button" :href="metricsPath" :title="$options.i18n.metricsButtonTitle" diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql index 2c17c42dd6d..c3ab9cf7fca 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql @@ -4,6 +4,5 @@ query getEnvironmentApp($page: Int, $scope: String) { stoppedCount environments reviewApp - stoppedCount } } diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index 28a3c54cc8f..d9c627f5c93 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -1,8 +1,8 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { const reviewerToken = { - formattedKey: __('Reviewer'), + formattedKey: s__('SearchToken|Reviewer'), key: 'reviewer', type: 'string', param: 'username', @@ -13,21 +13,6 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { IssuableTokenKeys.tokenKeys.splice(2, 0, reviewerToken); IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, reviewerToken); - if (window.gon?.features?.mrAttentionRequests) { - const attentionRequestedToken = { - formattedKey: __('Attention'), - key: 'attention', - type: 'string', - param: '', - symbol: '@', - icon: 'user', - tag: '@attention', - hideNotEqual: true, - }; - IssuableTokenKeys.tokenKeys.splice(2, 0, attentionRequestedToken); - IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, attentionRequestedToken); - } - const draftToken = { token: { formattedKey: __('Draft'), diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index 2c58506985a..acb7449f830 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -1,5 +1,5 @@ import { flattenDeep } from 'lodash'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; export const tokenKeys = [ @@ -13,7 +13,7 @@ export const tokenKeys = [ tag: '@author', }, { - formattedKey: __('Assignee'), + formattedKey: s__('SearchToken|Assignee'), key: 'assignee', type: 'string', param: 'username', 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 6b1676eca8a..9fb69a3cae3 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 @@ -75,6 +75,7 @@ export default { <project-avatar class="gl-float-left gl-mr-3" :project-avatar-url="avatarUrl" + :project-id="itemId" :project-name="itemName" aria-hidden="true" /> diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index d4dafbdc94f..01d218438cf 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -7,11 +7,20 @@ import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; import { s__, __, sprintf } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; import SidebarMediator from '~/sidebar/sidebar_mediator'; +import { state } from '~/sidebar/components/reviewers/sidebar_reviewers.vue'; import AjaxCache from './lib/utils/ajax_cache'; import { spriteIcon } from './lib/utils/common_utils'; import { parsePikadayDate } from './lib/utils/datetime_utility'; import glRegexp from './lib/utils/regexp'; +const USERS_ALIAS = 'users'; +const ISSUES_ALIAS = 'issues'; +const MILESTONES_ALIAS = 'milestones'; +const MERGEREQUESTS_ALIAS = 'mergerequests'; +const LABELS_ALIAS = 'labels'; +const SNIPPETS_ALIAS = 'snippets'; +const CONTACTS_ALIAS = 'contacts'; +export const AT_WHO_ACTIVE_CLASS = 'at-who-active'; /** * Escapes user input before we pass it to at.js, which * renders it as HTML in the autocomplete dropdown. @@ -29,6 +38,15 @@ function escape(string) { return lodashEscape(string).replace(/\$/g, '$'); } +export function showAndHideHelper($input, alias = '') { + $input.on(`hidden${alias ? '-' : ''}${alias}.atwho`, () => { + $input.removeClass(AT_WHO_ACTIVE_CLASS); + }); + $input.on(`shown${alias ? '-' : ''}${alias}.atwho`, () => { + $input.addClass(AT_WHO_ACTIVE_CLASS); + }); +} + function createMemberSearchString(member) { return `${member.name.replace(/ /g, '')} ${member.username}`; } @@ -237,10 +255,18 @@ class GfmAutoComplete { callbacks: { ...this.getDefaultCallbacks(), matcher(flag, subtext) { - const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi'); + const regexp = new RegExp( + `(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^ :][^:]*)?$`, + 'gi', + ); const match = regexp.exec(subtext); - return match && match.length ? match[1] : null; + if (match && match.length) { + // Since we have "?" on the group, it's possible it is undefined + return match[1] || ''; + } + + return null; }, filter(query, items) { if (GfmAutoComplete.isLoading(items)) { @@ -265,6 +291,7 @@ class GfmAutoComplete { }, }, }); + showAndHideHelper($input); } setupMembers($input) { @@ -276,8 +303,6 @@ class GfmAutoComplete { UNASSIGN_REVIEWER: '/unassign_reviewer', REASSIGN: '/reassign', CC: '/cc', - ATTENTION: '/attention', - REMOVE_ATTENTION: '/remove_attention', }; let assignees = []; let reviewers = []; @@ -286,7 +311,7 @@ class GfmAutoComplete { // Team Members $input.atwho({ at: '@', - alias: 'users', + alias: USERS_ALIAS, displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; const { avatarTag, username, title, icon, availability } = value; @@ -328,8 +353,7 @@ class GfmAutoComplete { // Cache assignees & reviewers list for easier filtering later assignees = SidebarMediator.singleton?.store?.assignees?.map(createMemberSearchString) || []; - reviewers = - SidebarMediator.singleton?.store?.reviewers?.map(createMemberSearchString) || []; + reviewers = state.issuable?.reviewers?.nodes?.map(createMemberSearchString) || []; const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); return match && match.length ? match[1] : null; @@ -356,23 +380,6 @@ class GfmAutoComplete { } else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) { // Only include members which are not assigned as a reviewer to Issuable currently return data.filter((member) => reviewers.includes(member.search)); - } else if ( - command === MEMBER_COMMAND.ATTENTION || - command === MEMBER_COMMAND.REMOVE_ATTENTION - ) { - const attentionUsers = [ - ...(SidebarMediator.singleton?.store?.assignees || []), - ...(SidebarMediator.singleton?.store?.reviewers || []), - ]; - const attentionRequested = command === MEMBER_COMMAND.REMOVE_ATTENTION; - - return data.filter((member) => - attentionUsers.find( - (u) => - createMemberSearchString(u).includes(member.search) && - u.attention_requested === attentionRequested, - ), - ); } return data; @@ -393,12 +400,13 @@ class GfmAutoComplete { }, }, }); + showAndHideHelper($input, USERS_ALIAS); } setupIssues($input) { $input.atwho({ at: '#', - alias: 'issues', + alias: ISSUES_ALIAS, searchKey: 'search', displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; @@ -427,12 +435,13 @@ class GfmAutoComplete { }, }, }); + showAndHideHelper($input, ISSUES_ALIAS); } setupMilestones($input) { $input.atwho({ at: '%', - alias: 'milestones', + alias: MILESTONES_ALIAS, searchKey: 'search', // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${title}', @@ -483,12 +492,13 @@ class GfmAutoComplete { }, }, }); + showAndHideHelper($input, MILESTONES_ALIAS); } setupMergeRequests($input) { $input.atwho({ at: '!', - alias: 'mergerequests', + alias: MERGEREQUESTS_ALIAS, searchKey: 'search', displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; @@ -517,6 +527,7 @@ class GfmAutoComplete { }, }, }); + showAndHideHelper($input, MERGEREQUESTS_ALIAS); } setupLabels($input) { @@ -527,7 +538,7 @@ class GfmAutoComplete { $input.atwho({ at: '~', - alias: 'labels', + alias: LABELS_ALIAS, searchKey: 'search', data: GfmAutoComplete.defaultLoadingData, displayTpl(value) { @@ -617,12 +628,13 @@ class GfmAutoComplete { }, }, }); + showAndHideHelper($input, LABELS_ALIAS); } setupSnippets($input) { $input.atwho({ at: '$', - alias: 'snippets', + alias: SNIPPETS_ALIAS, searchKey: 'search', displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; @@ -650,13 +662,14 @@ class GfmAutoComplete { }, }, }); + showAndHideHelper($input, SNIPPETS_ALIAS); } setupContacts($input) { $input.atwho({ at: '[contact:', suffix: ']', - alias: 'contacts', + alias: CONTACTS_ALIAS, searchKey: 'search', displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; @@ -686,6 +699,7 @@ class GfmAutoComplete { }, }, }); + showAndHideHelper($input, CONTACTS_ALIAS); } getDefaultCallbacks() { diff --git a/app/assets/javascripts/gitlab_pages/new.js b/app/assets/javascripts/gitlab_pages/new.js new file mode 100644 index 00000000000..e23b08dcd56 --- /dev/null +++ b/app/assets/javascripts/gitlab_pages/new.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlToast } from '@gitlab/ui'; +import createDefaultClient from '~/lib/graphql'; +import Pages from './components/pages_pipeline_wizard.vue'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + batchMax: 1, + assumeImmutableResults: true, + }, + ), +}); + +export default function initPages() { + const el = document.querySelector('#js-pages'); + + if (!el) { + return false; + } + + return new Vue({ + el, + name: 'GitlabPagesNewRoot', + apolloProvider, + render(createElement) { + return createElement(Pages, { + props: { + ...el.dataset, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql deleted file mode 100644 index b202ed12f80..00000000000 --- a/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql +++ /dev/null @@ -1,7 +0,0 @@ -fragment BlobViewer on SnippetBlobViewer { - collapsed - renderError - tooLarge - type - fileType -} diff --git a/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql deleted file mode 100644 index 78a368089a8..00000000000 --- a/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql +++ /dev/null @@ -1,4 +0,0 @@ -fragment Iteration on Iteration { - id - title -} diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 45c5cca68cc..eac325f184f 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -3,6 +3,12 @@ "AlertManagementHttpIntegration", "AlertManagementPrometheusIntegration" ], + "CiVariable": [ + "CiGroupVariable", + "CiInstanceVariable", + "CiManualVariable", + "CiProjectVariable" + ], "CurrentUserTodos": [ "BoardEpic", "Design", @@ -134,6 +140,9 @@ "WorkItemWidgetAssignees", "WorkItemWidgetDescription", "WorkItemWidgetHierarchy", + "WorkItemWidgetLabels", + "WorkItemWidgetStartAndDueDate", + "WorkItemWidgetVerificationStatus", "WorkItemWidgetWeight" ] } diff --git a/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql index 12b391e41ac..50ed38e0492 100644 --- a/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql @@ -1,11 +1,8 @@ query getUser { currentUser { id - __typename callouts { - __typename nodes { - __typename featureName } } diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue index 06aea26830d..8011090f1cb 100644 --- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue +++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue @@ -1,6 +1,6 @@ <script> import { GlToggle, GlAlert } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; +import { updateGroup } from '~/api/groups_api'; import { I18N_UPDATE_ERROR_MESSAGE, I18N_REFRESH_MESSAGE } from '../constants'; export default { @@ -9,7 +9,7 @@ export default { GlAlert, }, inject: [ - 'updatePath', + 'groupId', 'sharedRunnersSetting', 'parentSharedRunnersSetting', 'runnerEnabledValue', @@ -54,8 +54,7 @@ export default { this.isLoading = true; - axios - .put(this.updatePath, { shared_runners_setting: setting }) + updateGroup(this.groupId, { shared_runners_setting: setting }) .then(() => { this.value = setting; }) diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js index aeb6d57a11a..e7e104d61b3 100644 --- a/app/assets/javascripts/group_settings/mount_shared_runners.js +++ b/app/assets/javascripts/group_settings/mount_shared_runners.js @@ -5,7 +5,7 @@ export default (containerId = 'update-shared-runners-form') => { const containerEl = document.getElementById(containerId); const { - updatePath, + groupId, sharedRunnersSetting, parentSharedRunnersSetting, runnerEnabledValue, @@ -16,7 +16,7 @@ export default (containerId = 'update-shared-runners-form') => { return new Vue({ el: containerEl, provide: { - updatePath, + groupId, sharedRunnersSetting, parentSharedRunnersSetting, runnerEnabledValue, diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 7345afb8545..2f182b86d2c 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -16,12 +16,9 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge. import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; -import { - VISIBILITY_TYPE_ICON, - GROUP_VISIBILITY_TYPE, - ITEM_TYPE, - VISIBILITY_PRIVATE, -} from '../constants'; +import { VISIBILITY_LEVELS_ENUM } from '~/visibility_level/constants'; +import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants'; + import eventHub from '../event_hub'; import itemActions from './item_actions.vue'; @@ -114,8 +111,8 @@ export default { shouldShowVisibilityWarning() { return ( this.action === 'shared' && - this.currentGroupVisibility === VISIBILITY_PRIVATE && - this.group.visibility !== VISIBILITY_PRIVATE + VISIBILITY_LEVELS_ENUM[this.group.visibility] > + VISIBILITY_LEVELS_ENUM[this.currentGroupVisibility] ); }, }, @@ -142,7 +139,7 @@ export default { shareProjectsWithGroupsHelpPagePath: helpPagePath( 'user/project/members/share_project_with_groups', { - anchor: 'share-a-public-project-with-private-group', + anchor: 'sharing-projects-with-groups-of-a-higher-restrictive-visibility-level', }, ), safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, @@ -182,6 +179,7 @@ export default { > <gl-avatar :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :entity-id="group.id" :entity-name="group.name" :src="group.avatarUrl" :alt="group.name" diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue index 983535d3e9c..9a1ea2f1812 100644 --- a/app/assets/javascripts/groups/components/group_name_and_path.vue +++ b/app/assets/javascripts/groups/components/group_name_and_path.vue @@ -6,6 +6,13 @@ import { GlInputGroupText, GlLink, GlAlert, + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlTruncate, + GlSearchBoxByType, } from '@gitlab/ui'; import { debounce } from 'lodash'; @@ -15,6 +22,11 @@ import { createAlert } from '~/flash'; import { slugify } from '~/lib/utils/text_utility'; import axios from '~/lib/utils/axios_utils'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; + +import searchGroupsWhereUserCanCreateSubgroups from '../queries/search_groups_where_user_can_create_subgroups.query.graphql'; const DEBOUNCE_DURATION = 1000; @@ -22,7 +34,6 @@ export default { i18n: { inputs: { name: { - label: s__('Groups|Group name'), placeholder: __('My awesome group'), description: s__( 'Groups|Must start with letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.', @@ -30,7 +41,6 @@ export default { invalidFeedback: s__('Groups|Enter a descriptive name for your group.'), }, path: { - label: s__('Groups|Group URL'), placeholder: __('my-awesome-group'), invalidFeedbackInvalidPattern: s__( 'GroupSettings|Choose a group path that does not start with a dash or end with a period. It can also contain alphanumeric characters and underscores.', @@ -40,9 +50,6 @@ export default { ), validFeedback: s__('Groups|Group path is available.'), }, - groupId: { - label: s__('Groups|Group ID'), - }, }, apiLoadingMessage: s__('Groups|Checking group URL availability...'), apiErrorMessage: __( @@ -51,7 +58,7 @@ export default { changingUrlWarningMessage: s__('Groups|Changing group URL can have unintended side effects.'), learnMore: s__('Groups|Learn more'), }, - nameInputSize: { md: 'lg' }, + inputSize: { md: 'lg' }, changingGroupPathHelpPagePath: helpPagePath('user/group/index', { anchor: 'change-a-groups-path', }), @@ -63,8 +70,35 @@ export default { GlInputGroupText, GlLink, GlAlert, + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlTruncate, + GlSearchBoxByType, + }, + apollo: { + currentUserGroups: { + query: searchGroupsWhereUserCanCreateSubgroups, + variables() { + return { + search: this.search, + }; + }, + update(data) { + return data.currentUser?.groups?.nodes || []; + }, + skip() { + const hasNotEnoughSearchCharacters = + this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH; + + return this.shouldSkipQuery || hasNotEnoughSearchCharacters; + }, + debounce: DEBOUNCE_DELAY, + }, }, - inject: ['fields', 'basePath', 'mattermostEnabled'], + inject: ['fields', 'basePath', 'newSubgroup', 'mattermostEnabled'], data() { return { name: this.fields.name.value, @@ -76,9 +110,27 @@ export default { pathFeedbackState: null, pathInvalidFeedback: null, activeApiRequestAbortController: null, + search: '', + currentUserGroups: {}, + shouldSkipQuery: true, + selectedGroup: { + id: this.fields.parentId.value, + fullPath: this.fields.parentFullPath.value, + }, }; }, computed: { + inputLabels() { + return { + name: this.newSubgroup ? s__('Groups|Subgroup name') : s__('Groups|Group name'), + path: this.newSubgroup ? s__('Groups|Subgroup slug') : s__('Groups|Group URL'), + subgroupPath: s__('Groups|Subgroup URL'), + groupId: s__('Groups|Group ID'), + }; + }, + pathInputSize() { + return this.newSubgroup ? {} : this.$options.inputSize; + }, computedPath() { return this.apiSuggestedPath || this.path; }, @@ -129,9 +181,11 @@ export default { try { const { data: { exists, suggests }, - } = await getGroupPathAvailability(this.path, this.fields.parentId?.value, { - signal: this.activeApiRequestAbortController.signal, - }); + } = await getGroupPathAvailability( + this.path, + this.selectedGroup.id || this.fields.parentId.value, + { signal: this.activeApiRequestAbortController.signal }, + ); this.apiLoading = false; @@ -198,6 +252,21 @@ export default { this.pathInvalidFeedback = this.$options.i18n.inputs.path.invalidFeedbackInvalidPattern; this.pathFeedbackState = false; }, + handleDropdownShown() { + if (this.shouldSkipQuery) { + this.shouldSkipQuery = false; + } + + this.$refs.search.focusInput(); + }, + handleDropdownItemClick({ id, fullPath }) { + this.selectedGroup = { + id: getIdFromGraphQLId(id), + fullPath, + }; + + this.debouncedValidatePath(); + }, }, }; </script> @@ -208,10 +277,10 @@ export default { :id="fields.parentId.id" type="hidden" :name="fields.parentId.name" - :value="fields.parentId.value" + :value="selectedGroup.id" /> <gl-form-group - :label="$options.i18n.inputs.name.label" + :label="inputLabels.name" :description="$options.i18n.inputs.name.description" :label-for="fields.name.id" :invalid-feedback="$options.i18n.inputs.name.invalidFeedback" @@ -220,46 +289,102 @@ export default { <gl-form-input :id="fields.name.id" v-model="name" - class="gl-field-error-ignore" + class="gl-field-error-ignore gl-h-auto!" required :name="fields.name.name" :placeholder="$options.i18n.inputs.name.placeholder" data-qa-selector="group_name_field" - :size="$options.nameInputSize" + :size="$options.inputSize" :state="nameFeedbackState" @invalid="handleInvalidName" /> </gl-form-group> - <gl-form-group - :label="$options.i18n.inputs.path.label" - :label-for="fields.path.id" - :description="pathDescription" - :state="pathFeedbackState" - :valid-feedback="$options.i18n.inputs.path.validFeedback" - :invalid-feedback="pathInvalidFeedback" - > - <gl-form-input-group> - <template #prepend> - <gl-input-group-text class="group-root-path">{{ basePath }}</gl-input-group-text> - </template> - <gl-form-input - :id="fields.path.id" - class="gl-field-error-ignore" - :name="fields.path.name" - :value="computedPath" - :placeholder="$options.i18n.inputs.path.placeholder" - :maxlength="fields.path.maxLength" - :pattern="fields.path.pattern" - :state="pathFeedbackState" - :size="$options.nameInputSize" - required - data-qa-selector="group_path_field" - :data-bind-in="mattermostEnabled ? $options.mattermostDataBindName : null" - @input="handlePathInput" - @invalid="handleInvalidPath" - /> - </gl-form-input-group> - </gl-form-group> + + <div :class="newSubgroup && 'row gl-mb-3'"> + <gl-form-group v-if="newSubgroup" class="col-sm-6 gl-pr-0" :label="inputLabels.subgroupPath"> + <div class="input-group gl-flex-nowrap"> + <gl-button-group class="gl-w-full"> + <gl-button class="js-group-namespace-button gl-text-truncate gl-flex-grow-0!" label> + {{ basePath }} + </gl-button> + + <gl-dropdown + class="js-group-namespace-dropdown gl-flex-grow-1" + toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20" + @shown="handleDropdownShown" + > + <template #button-text> + <gl-truncate + v-if="selectedGroup.fullPath" + :text="selectedGroup.fullPath" + position="start" + with-tooltip + /> + </template> + + <gl-search-box-by-type + ref="search" + v-model.trim="search" + :is-loading="$apollo.queries.currentUserGroups.loading" + /> + + <template v-if="!$apollo.queries.currentUserGroups.loading"> + <template v-if="currentUserGroups.length"> + <gl-dropdown-item + v-for="group of currentUserGroups" + :key="group.id" + data-testid="select_group_dropdown_item" + @click="handleDropdownItemClick(group)" + > + {{ group.fullPath }} + </gl-dropdown-item> + </template> + <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text> + </template> + </gl-dropdown> + </gl-button-group> + + <div class="gl-align-self-center gl-pl-5"> + <span class="gl-display-none gl-md-display-inline">/</span> + </div> + </div> + </gl-form-group> + + <gl-form-group + :class="newSubgroup && 'col-sm-6'" + :label="inputLabels.path" + :label-for="fields.path.id" + :description="pathDescription" + :state="pathFeedbackState" + :valid-feedback="$options.i18n.inputs.path.validFeedback" + :invalid-feedback="pathInvalidFeedback" + > + <gl-form-input-group> + <template v-if="!newSubgroup" #prepend> + <gl-input-group-text class="group-root-path"> + {{ basePath.concat(fields.parentFullPath.value) }} + </gl-input-group-text> + </template> + <gl-form-input + :id="fields.path.id" + class="gl-field-error-ignore gl-h-auto!" + :name="fields.path.name" + :value="computedPath" + :placeholder="$options.i18n.inputs.path.placeholder" + :maxlength="fields.path.maxLength" + :pattern="fields.path.pattern" + :state="pathFeedbackState" + :size="pathInputSize" + required + data-qa-selector="group_path_field" + :data-bind-in="mattermostEnabled ? $options.mattermostDataBindName : null" + @input="handlePathInput" + @invalid="handleInvalidPath" + /> + </gl-form-input-group> + </gl-form-group> + </div> + <template v-if="isEditingGroup"> <gl-alert class="gl-mb-5" :dismissible="false" variant="warning"> {{ $options.i18n.changingUrlWarningMessage }} @@ -267,7 +392,7 @@ export default { >{{ $options.i18n.learnMore }} </gl-link> </gl-alert> - <gl-form-group :label="$options.i18n.inputs.groupId.label" :label-for="fields.groupId.id"> + <gl-form-group :label="inputLabels.groupId" :label-for="fields.groupId.id"> <gl-form-input :id="fields.groupId.id" :value="fields.groupId.value" diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 5706df0dd1b..3a05c308a2a 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -42,7 +42,7 @@ export default { </script> <template> - <div class="groups-list-tree-container qa-groups-list-tree-container"> + <div class="groups-list-tree-container" data-qa-selector="groups_list_tree_container"> <div v-if="searchEmpty" class="has-no-search-results gl-font-style-italic gl-text-center gl-text-gray-600 gl-p-5" diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue index e848f10352d..7e7282a27b0 100644 --- a/app/assets/javascripts/groups/components/transfer_group_form.vue +++ b/app/assets/javascripts/groups/components/transfer_group_form.vue @@ -70,7 +70,6 @@ export default { <input type="hidden" name="new_parent_group_id" :value="selectedId" /> </gl-form-group> <confirm-danger - button-class="qa-transfer-button" :disabled="disableSubmitButton" :phrase="confirmationPhrase" :button-text="confirmButtonText" diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index 29981d09155..0d09ad9442b 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -1,4 +1,9 @@ import { __, s__ } from '~/locale'; +import { + VISIBILITY_LEVEL_PRIVATE, + VISIBILITY_LEVEL_INTERNAL, + VISIBILITY_LEVEL_PUBLIC, +} from '~/visibility_level/constants'; export const MAX_CHILDREN_COUNT = 20; @@ -28,32 +33,30 @@ export const ITEM_TYPE = { GROUP: 'group', }; -export const VISIBILITY_PUBLIC = 'public'; -export const VISIBILITY_INTERNAL = 'internal'; -export const VISIBILITY_PRIVATE = 'private'; - export const GROUP_VISIBILITY_TYPE = { - [VISIBILITY_PUBLIC]: __( + [VISIBILITY_LEVEL_PUBLIC]: __( 'Public - The group and any public projects can be viewed without any authentication.', ), - [VISIBILITY_INTERNAL]: __( + [VISIBILITY_LEVEL_INTERNAL]: __( 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', ), - [VISIBILITY_PRIVATE]: __('Private - The group and its projects can only be viewed by members.'), + [VISIBILITY_LEVEL_PRIVATE]: __( + 'Private - The group and its projects can only be viewed by members.', + ), }; export const PROJECT_VISIBILITY_TYPE = { - [VISIBILITY_PUBLIC]: __('Public - The project can be accessed without any authentication.'), - [VISIBILITY_INTERNAL]: __( + [VISIBILITY_LEVEL_PUBLIC]: __('Public - The project can be accessed without any authentication.'), + [VISIBILITY_LEVEL_INTERNAL]: __( 'Internal - The project can be accessed by any logged in user except external users.', ), - [VISIBILITY_PRIVATE]: __( + [VISIBILITY_LEVEL_PRIVATE]: __( 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', ), }; export const VISIBILITY_TYPE_ICON = { - [VISIBILITY_PUBLIC]: 'earth', - [VISIBILITY_INTERNAL]: 'shield', - [VISIBILITY_PRIVATE]: 'lock', + [VISIBILITY_LEVEL_PUBLIC]: 'earth', + [VISIBILITY_LEVEL_INTERNAL]: 'shield', + [VISIBILITY_LEVEL_PRIVATE]: 'lock', }; diff --git a/app/assets/javascripts/groups/create_edit_form.js b/app/assets/javascripts/groups/create_edit_form.js index 8ca0e6077e9..330d343b776 100644 --- a/app/assets/javascripts/groups/create_edit_form.js +++ b/app/assets/javascripts/groups/create_edit_form.js @@ -1,8 +1,12 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import { parseRailsFormFields } from '~/lib/utils/forms'; import { parseBoolean } from '~/lib/utils/common_utils'; import GroupNameAndPath from './components/group_name_and_path.vue'; +Vue.use(VueApollo); + export const initGroupNameAndPath = () => { const elements = document.querySelectorAll('.js-group-name-and-path'); @@ -12,13 +16,17 @@ export const initGroupNameAndPath = () => { elements.forEach((element) => { const fields = parseRailsFormFields(element); - const { basePath, mattermostEnabled } = element.dataset; + const { basePath, newSubgroup, mattermostEnabled } = element.dataset; return new Vue({ el: element, + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), provide: { fields, basePath, + newSubgroup: parseBoolean(newSubgroup), mattermostEnabled: parseBoolean(mattermostEnabled), }, render(h) { diff --git a/app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql b/app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql new file mode 100644 index 00000000000..c45a31ef387 --- /dev/null +++ b/app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql @@ -0,0 +1,11 @@ +query searchGroupsWhereUserCanCreateSubgroups($search: String) { + currentUser { + id + groups(permissionScope: TRANSFER_PROJECTS, search: $search) { + nodes { + id + fullPath + } + } + } +} diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index 0c4f9640972..f4b939fb20f 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -23,6 +23,9 @@ import { SEARCH_SHORTCUTS_MIN_CHARACTERS, SCOPE_TOKEN_MAX_LENGTH, INPUT_FIELD_PADDING, + IS_SEARCHING, + IS_FOCUSED, + IS_NOT_FOCUSED, } from '../constants'; import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue'; @@ -65,6 +68,7 @@ export default { data() { return { showDropdown: false, + isFocused: false, currentFocusIndex: SEARCH_BOX_INDEX, }; }, @@ -92,20 +96,18 @@ export default { if (!this.showDropdown || !this.isLoggedIn) { return false; } - return this.searchOptions?.length > 0; }, showDefaultItems() { return !this.searchText; }, - showScopes() { + searchTermOverMin() { return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; }, defaultIndex() { if (this.showDefaultItems) { return SEARCH_BOX_INDEX; } - return FIRST_DROPDOWN_INDEX; }, @@ -132,12 +134,15 @@ export default { count: this.searchOptions.length, }); }, - searchBarStateIndicator() { - const hasIcon = - this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon'; - const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching'; - const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active'; - return `${isActive} ${isSearching} ${hasIcon}`; + searchBarClasses() { + return { + [IS_SEARCHING]: this.searchTermOverMin, + [IS_FOCUSED]: this.isFocused, + [IS_NOT_FOCUSED]: !this.isFocused, + }; + }, + showScopeHelp() { + return this.searchTermOverMin && this.isFocused; }, searchBarItem() { return this.searchOptions?.[0]; @@ -158,11 +163,22 @@ export default { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), openDropdown() { this.showDropdown = true; - this.$emit('toggleDropdown', this.showDropdown); + this.isFocused = true; + this.$emit('expandSearchBar', true); }, closeDropdown() { this.showDropdown = false; - this.$emit('toggleDropdown', this.showDropdown); + }, + collapseAndCloseSearchBar() { + // we need a delay on this method + // for the search bar not to remove + // the clear button from dom + // and register clicks on dropdown items + setTimeout(() => { + this.showDropdown = false; + this.isFocused = false; + this.$emit('collapseSearchBar'); + }, 200); }, submitSearch() { if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) { @@ -171,6 +187,7 @@ export default { return visitUrl(this.currentFocusedOption?.url || this.searchQuery); }, getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { + this.openDropdown(); if (!searchTerm) { this.clearAutocomplete(); } else { @@ -201,7 +218,7 @@ export default { role="search" :aria-label="$options.i18n.searchGitlab" class="header-search gl-relative gl-rounded-base gl-w-full" - :class="searchBarStateIndicator" + :class="searchBarClasses" data-testid="header-search-form" > <gl-search-box-by-type @@ -217,12 +234,13 @@ export default { :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" @focus="openDropdown" @click="openDropdown" + @blur="collapseAndCloseSearchBar" @input="getAutocompleteOptions" @keydown.enter.stop.prevent="submitSearch" @keydown.esc.stop.prevent="closeDropdown" /> <gl-token - v-if="showScopes" + v-if="showScopeHelp" v-gl-resize-observer-directive="observeTokenWidth" class="in-search-scope-help" :view-only="true" @@ -242,6 +260,7 @@ export default { }} </gl-token> <kbd + v-show="!isFocused" v-gl-tooltip.bottom.hover.html class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper" :title="$options.i18n.kbdHelp" @@ -262,9 +281,9 @@ export default { <div v-if="showSearchDropdown" data-testid="header-search-dropdown-menu" - 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" + class="header-search-dropdown-menu gl-overflow-y-auto 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 gl-mt-3" > - <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2"> + <div class="header-search-dropdown-content gl-py-2"> <dropdown-keyboard-navigation v-model="currentFocusIndex" :max="searchOptions.length - 1" @@ -278,7 +297,7 @@ export default { /> <template v-else> <header-search-scoped-items - v-if="showScopes" + v-if="searchTermOverMin" :current-focused-option="currentFocusedOption" /> <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index a026386b2bd..3a20fb0216d 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -51,3 +51,7 @@ export const SCOPE_TOKEN_MAX_LENGTH = 36; export const INPUT_FIELD_PADDING = 52; export const HEADER_INIT_EVENTS = ['input', 'focus']; + +export const IS_SEARCHING = 'is-searching'; +export const IS_FOCUSED = 'is-focused'; +export const IS_NOT_FOCUSED = 'is-not-focused'; diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js index b2c505d569f..f6f5c6a14fa 100644 --- a/app/assets/javascripts/header_search/index.js +++ b/app/assets/javascripts/header_search/index.js @@ -26,12 +26,11 @@ export const initHeaderSearchApp = (search = '') => { render(createElement) { return createElement(HeaderSearchApp, { on: { - toggleDropdown: (isVisible = false) => { - if (isVisible) { - navBarEl?.classList.add('header-search-is-active'); - } else { - navBarEl?.classList.remove('header-search-is-active'); - } + expandSearchBar: () => { + navBarEl?.classList.add('header-search-is-active'); + }, + collapseSearchBar: () => { + navBarEl?.classList.remove('header-search-is-active'); }, }, }); diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 05a254d3fbf..d02dc67d933 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -156,7 +156,7 @@ export default { category="primary" variant="confirm" block - class="qa-begin-commit-button" + data-qa-selector="begin_commit_button" data-testid="begin-commit-button" @click="beginCommit" > @@ -184,7 +184,7 @@ export default { :disabled="commitButtonDisabled" :loading="submitCommitLoading" data-testid="commit-button" - class="qa-commit-button" + data-qa-selector="commit_button" category="primary" variant="confirm" type="submit" diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue index 0921b5a5424..ba679ae7c9b 100644 --- a/app/assets/javascripts/ide/components/file_templates/bar.vue +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -80,7 +80,9 @@ export default { <template> <div - class="gl-display-flex gl-align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1" + class="gl-display-flex gl-align-items-center ide-file-templates gl-relative gl-z-index-1" + data-testid="file-templates-bar" + data-qa-selector="file_templates_container" > <strong class="gl-mr-3"> {{ $options.i18n.barLabel }} </strong> <gl-dropdown @@ -97,7 +99,8 @@ export default { </gl-dropdown> <gl-dropdown v-if="showTemplatesDropdown" - class="gl-mr-6 qa-file-template-dropdown" + class="gl-mr-6" + data-qa-selector="file_template_dropdown" :text="$options.i18n.templateListDropdownLabel" @show="fetchTemplateTypes" > diff --git a/app/assets/javascripts/ide/components/ide_project_header.vue b/app/assets/javascripts/ide/components/ide_project_header.vue index 1c25a8e634d..3296dc2060c 100644 --- a/app/assets/javascripts/ide/components/ide_project_header.vue +++ b/app/assets/javascripts/ide/components/ide_project_header.vue @@ -18,6 +18,7 @@ export default { <div class="context-header ide-context-header"> <a :href="project.web_url" :title="s__('IDE|Go to project')" data-testid="go-to-project-link"> <project-avatar + :project-id="project.id" :project-name="project.name" :project-avatar-url="project.avatar_url" :size="48" diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index c9bf84be6ac..737ff49f74c 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -52,7 +52,7 @@ export default { </script> <template> - <div class="ide-file-list qa-file-list"> + <div class="ide-file-list" data-qa-selector="file_list_container"> <template v-if="showLoading"> <div v-for="n in 3" :key="n" class="multi-file-loading-container"> <gl-skeleton-loader /> diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 46128651547..5f67eee5f18 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -51,7 +51,7 @@ export default class Model { } get language() { - return this.model.getModeId(); + return this.model.getLanguageId(); } get path() { diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index b4ceec22822..fe687ea9767 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -133,9 +133,6 @@ export default { this.model = null; } }, - helpHtmlConfig: { - ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented - }, }; </script> @@ -147,7 +144,7 @@ export default { :state="valid" > <template v-if="!isCheckbox" #description> - <span v-safe-html:[$options.helpHtmlConfig]="help"></span> + <span v-safe-html="help"></span> </template> <template v-if="isCheckbox"> @@ -155,7 +152,7 @@ export default { <gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting"> {{ checkboxLabel || humanizedTitle }} <template #help> - <span v-safe-html:[$options.helpHtmlConfig]="help"></span> + <span v-safe-html="help"></span> </template> </gl-form-checkbox> </template> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index f1f574c6424..7a6f1a953a8 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -192,11 +192,7 @@ export default { this.integrationActive = integrationActive; }, }, - descriptionHtmlConfig: { - ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented - }, helpHtmlConfig: { - ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented ADD_TAGS: ['use'], // to support icon SVGs FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes }, @@ -254,7 +250,7 @@ export default { {{ $options.billingPlanNames[section.plan] }} </gl-badge> </h4> - <p v-safe-html:[$options.descriptionHtmlConfig]="section.description"></p> + <p v-safe-html="section.description"></p> </div> <div class="col-lg-8"> diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue index 1255ed01f6d..a8389e32b40 100644 --- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue +++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue @@ -125,6 +125,7 @@ export default { > <project-avatar class="gl-mr-3" + :project-id="item.id" :project-avatar-url="item.avatar_url" :project-name="item.name" aria-hidden="true" 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 b71cfbb6112..87f1ed31a7f 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -6,6 +6,9 @@ import { GlLink, GlSprintf, GlFormCheckboxGroup, + GlButton, + GlCollapse, + GlIcon, } from '@gitlab/ui'; import { partition, isString, uniqueId, isEmpty } from 'lodash'; import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; @@ -13,7 +16,7 @@ import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { getParameterValues } from '~/lib/utils/url_utility'; -import { n__ } from '~/locale'; +import { n__, sprintf } from '~/locale'; import { CLOSE_TO_LIMIT_COUNT, USERS_FILTER_ALL, @@ -38,6 +41,9 @@ export default { GlDropdownItem, GlSprintf, GlFormCheckboxGroup, + GlButton, + GlCollapse, + GlIcon, InviteModalBase, MembersTokenSelect, ModalConfetti, @@ -110,6 +116,8 @@ export default { mode: 'default', // Kept in sync with "base" selectedAccessLevel: undefined, + errorsLimit: 2, + isErrorsSectionExpanded: false, }; }, computed: { @@ -135,7 +143,7 @@ export default { return n__( "InviteMembersModal|The following member couldn't be invited", "InviteMembersModal|The following %d members couldn't be invited", - Object.keys(this.invalidMembers).length, + this.errorList.length, ); }, tasksToBeDoneEnabled() { @@ -187,6 +195,29 @@ export default { ? this.$options.labels.placeHolderDisabled : this.$options.labels.placeHolder; }, + errorList() { + return Object.entries(this.invalidMembers).map(([member, error]) => { + return { member, displayedMemberName: this.tokenName(member), message: error }; + }); + }, + errorsLimited() { + return this.errorList.slice(0, this.errorsLimit); + }, + errorsExpanded() { + return this.errorList.slice(this.errorsLimit); + }, + shouldErrorsSectionExpand() { + return Boolean(this.errorsExpanded.length); + }, + errorCollapseText() { + if (this.isErrorsSectionExpanded) { + return this.$options.labels.expandedErrors; + } + + return sprintf(this.$options.labels.collapsedErrors, { + count: this.errorsExpanded.length, + }); + }, }, mounted() { eventHub.$on('openModal', (options) => { @@ -311,6 +342,9 @@ export default { delete this.invalidMembers[memberName(token)]; this.invalidMembers = { ...this.invalidMembers }; }, + toggleErrorExpansion() { + this.isErrorsSectionExpanded = !this.isErrorsSectionExpanded; + }, }, labels: MEMBER_MODAL_LABELS, }; @@ -356,11 +390,37 @@ export default { data-testid="alert-member-error" > {{ $options.labels.memberErrorListText }} - <ul class="gl-pl-5"> - <li v-for="(error, member) in invalidMembers" :key="member"> - <strong>{{ tokenName(member) }}:</strong> {{ error }} + <ul class="gl-pl-5 gl-mb-0"> + <li v-for="error in errorsLimited" :key="error.member" data-testid="errors-limited-item"> + <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} </li> </ul> + <template v-if="shouldErrorsSectionExpand"> + <gl-collapse v-model="isErrorsSectionExpanded"> + <ul class="gl-pl-5 gl-mb-0"> + <li + v-for="error in errorsExpanded" + :key="error.member" + data-testid="errors-expanded-item" + > + <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} + </li> + </ul> + </gl-collapse> + <gl-button + class="gl-text-decoration-none! gl-shadow-none! gl-mt-3" + data-testid="accordion-button" + variant="link" + @click="toggleErrorExpansion" + > + {{ errorCollapseText }} + <gl-icon + name="chevron-down" + class="gl-transition-medium" + :class="{ 'gl-rotate-180': isErrorsSectionExpanded }" + /> + </gl-button> + </template> </gl-alert> <user-limit-notification v-else diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index b2bcb9a5906..2ddb04e1eeb 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -1,10 +1,16 @@ <script> import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, isEmpty } from 'lodash'; import { __ } from '~/locale'; import { getUsers } from '~/rest_api'; import { memberName } from '../utils/member_utils'; -import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants'; +import { + SEARCH_DELAY, + USERS_FILTER_ALL, + USERS_FILTER_SAML_PROVIDER_ID, + VALID_TOKEN_BACKGROUND, + INVALID_TOKEN_BACKGROUND, +} from '../constants'; export default { components: { @@ -75,6 +81,25 @@ export default { } return this.$options.defaultQueryOptions; }, + hasInvalidMembers() { + return !isEmpty(this.invalidMembers); + }, + }, + watch: { + // We might not really want this to be *reactive* since we want the "class" state to be + // tied to the specific `selectedToken` such that if the token is removed and re-added, this + // state is reset. + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90076#note_1027165312 + hasInvalidMembers: { + handler(updatedInvalidMembers) { + // Only update tokens if we receive invalid members + if (!updatedInvalidMembers) { + return; + } + + this.updateTokenClasses(); + }, + }, }, methods: { handleTextInput(query) { @@ -83,6 +108,12 @@ export default { this.loading = true; this.retrieveUsers(query); }, + updateTokenClasses() { + this.selectedTokens = this.selectedTokens.map((token) => ({ + ...token, + class: this.tokenClass(token), + })); + }, retrieveUsers: debounce(function debouncedRetrieveUsers() { return getUsers(this.query, this.queryOptions) .then((response) => { @@ -98,6 +129,14 @@ export default { this.loading = false; }); }, SEARCH_DELAY), + tokenClass(token) { + if (this.hasError(token)) { + return INVALID_TOKEN_BACKGROUND; + } + + // assume success for this token + return VALID_TOKEN_BACKGROUND; + }, handleInput() { this.$emit('input', this.selectedTokens); }, diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue index ae5c3c11386..6c9b1f8e6d0 100644 --- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue +++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue @@ -10,7 +10,6 @@ import { CLOSE_TO_LIMIT_MESSAGE, CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE, DANGER_ALERT_TITLE_PERSONAL_NAMESPACE, - WARNING_ALERT_TITLE_PERSONAL_NAMESPACE, } from '../constants'; export default { @@ -46,13 +45,6 @@ export default { return this.usersLimitDataset.purchasePath; }, warningAlertTitle() { - if (this.usersLimitDataset.userNamespace) { - return sprintf(WARNING_ALERT_TITLE_PERSONAL_NAMESPACE, { - count: this.freeUsersLimit - this.membersCount, - members: this.pluralMembers(this.freeUsersLimit - this.membersCount), - }); - } - return sprintf(WARNING_ALERT_TITLE, { count: this.freeUsersLimit - this.membersCount, members: this.pluralMembers(this.freeUsersLimit - this.membersCount), diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 6141e5e9e0b..1ceb63e2146 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -2,6 +2,8 @@ import { s__ } from '~/locale'; export const CLOSE_TO_LIMIT_COUNT = 2; export const SEARCH_DELAY = 200; +export const VALID_TOKEN_BACKGROUND = 'gl-bg-green-100'; +export const INVALID_TOKEN_BACKGROUND = 'gl-bg-red-100'; export const INVITE_MEMBERS_FOR_TASK = { minimum_access_level: 30, name: 'invite_members_for_task', @@ -77,6 +79,8 @@ export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team memb export const MEMBER_ERROR_LIST_TEXT = s__( 'InviteMembersModal|Review the invite errors and try again:', ); +export const COLLAPSED_ERRORS = s__('InviteMembersModal|Show more (%{count})'); +export const EXPANDED_ERRORS = s__('InviteMembersModal|Show less'); export const MEMBER_MODAL_LABELS = { modal: { @@ -113,6 +117,8 @@ export const MEMBER_MODAL_LABELS = { }, toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, memberErrorListText: MEMBER_ERROR_LIST_TEXT, + collapsedErrors: COLLAPSED_ERRORS, + expandedErrors: EXPANDED_ERRORS, }; export const GROUP_MODAL_LABELS = { @@ -136,9 +142,6 @@ export const ON_SUBMIT_TRACK_LABEL = 'manage_members_clicked'; export const WARNING_ALERT_TITLE = s__( 'InviteMembersModal|You only have space for %{count} more %{members} in %{name}', ); -export const WARNING_ALERT_TITLE_PERSONAL_NAMESPACE = s__( - 'InviteMembersModal|You only have space for %{count} more %{members} in your personal projects', -); export const DANGER_ALERT_TITLE = s__( "InviteMembersModal|You've reached your %{count} %{members} limit for %{name}", ); @@ -153,12 +156,12 @@ export const REACHED_LIMIT_MESSAGE = s__( export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.concat( s__( - 'InviteMembersModal| To get more members and access to additional paid features, an owner of this namespace can start a trial or upgrade to a paid tier.', + 'InviteMembersModal| To get more members and access to additional paid features, an owner of the group can start a trial or upgrade to a paid tier.', ), ); export const CLOSE_TO_LIMIT_MESSAGE = s__( - 'InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.', + 'InviteMembersModal|To get more members an owner of the group can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.', ); export const CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE = s__( 'InviteMembersModal|To make more space, you can remove members who no longer need access.', diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index a4be3f205a3..6e2c0ecb5bb 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -20,6 +20,8 @@ export default (function initInviteMembersModal() { return false; } + const usersLimitDataset = JSON.parse(el.dataset.usersLimitDataset || '{}'); + inviteMembersModal = new Vue({ el, name: 'InviteMembersModalRoot', @@ -38,9 +40,10 @@ export default (function initInviteMembersModal() { projects: JSON.parse(el.dataset.projects || '[]'), usersFilter: el.dataset.usersFilter, filterId: parseInt(el.dataset.filterId, 10), - usersLimitDataset: convertObjectPropsToCamelCase( - JSON.parse(el.dataset.usersLimitDataset || '{}'), - ), + usersLimitDataset: convertObjectPropsToCamelCase({ + ...usersLimitDataset, + user_namespace: parseBoolean(usersLimitDataset.user_namespace), + }), }, }), }); diff --git a/app/assets/javascripts/issuable/components/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue index 11fc032f34f..4f1001e8c3b 100644 --- a/app/assets/javascripts/issuable/components/issue_milestone.vue +++ b/app/assets/javascripts/issuable/components/issue_milestone.vue @@ -73,7 +73,9 @@ export default { <template> <div ref="milestoneDetails" class="issue-milestone-details"> <gl-icon :size="16" class="gl-mr-2 flex-shrink-0" name="clock" /> - <span class="milestone-title d-inline-block">{{ milestone.title }}</span> + <span class="milestone-title gl-display-inline-block gl-text-truncate">{{ + milestone.title + }}</span> <gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone"> <span class="bold">{{ __('Milestone') }}</span> <br /> <span>{{ milestone.title }}</span> <br /> diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index a505a988360..667c712d3be 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -2,29 +2,35 @@ import '~/commons/bootstrap'; import { GlIcon, + GlLink, GlTooltip, GlTooltipDirective, GlButton, GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; +import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import relatedIssuableMixin from '../mixins/related_issuable_mixin'; import IssueAssignees from './issue_assignees.vue'; import IssueMilestone from './issue_milestone.vue'; export default { - name: 'IssueItem', components: { IssueMilestone, IssueAssignees, CiIcon, GlIcon, + GlLink, GlTooltip, IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), IssueDueDate, GlButton, + WorkItemDetailModal, }, directives: { GlTooltip: GlTooltipDirective, @@ -47,6 +53,11 @@ export default { required: false, default: '', }, + workItemType: { + type: String, + required: false, + default: '', + }, }, computed: { stateTitle() { @@ -62,6 +73,27 @@ export default { iconClasses() { return `${this.iconClass} ic-${this.iconName}`; }, + workItemId() { + return convertToGraphQLId(TYPE_WORK_ITEM, this.idKey); + }, + }, + methods: { + handleTitleClick(event) { + if (this.workItemType === 'TASK') { + event.preventDefault(); + this.$refs.modal.show(); + this.updateWorkItemIdUrlQuery(this.idKey); + } + }, + handleWorkItemDeleted(workItemId) { + this.$emit('relatedIssueRemoveRequest', workItemId); + }, + updateWorkItemIdUrlQuery(workItemId) { + updateHistory({ + url: setUrlParams({ work_item_id: workItemId }), + replace: true, + }); + }, }, }; </script> @@ -102,7 +134,13 @@ export default { class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0" :aria-label="__('Confidential')" /> - <a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a> + <gl-link + :href="computedPath" + class="sortable-link gl-font-weight-normal" + @click="handleTitleClick" + > + {{ title }} + </gl-link> </div> <!-- Info area: meta, path, and assignees --> @@ -178,16 +216,15 @@ export default { <span v-if="isLocked" - ref="lockIcon" v-gl-tooltip class="gl-px-3 gl-display-inline-block gl-cursor-not-allowed" :title="lockedMessage" + data-testid="lockIcon" > <gl-icon name="lock" /> </span> <gl-button v-else-if="canRemove" - ref="removeButton" v-gl-tooltip icon="close" category="tertiary" @@ -198,5 +235,11 @@ export default { :aria-label="__('Remove')" @click="onRemoveRequest" /> + <work-item-detail-modal + ref="modal" + :work-item-id="workItemId" + @close="updateWorkItemIdUrlQuery" + @workItemDeleted="handleWorkItemDeleted" + /> </div> </template> diff --git a/app/assets/javascripts/issuable/issuable_template_selector.js b/app/assets/javascripts/issuable/issuable_template_selector.js index cce903d388d..6b8f3de8d49 100644 --- a/app/assets/javascripts/issuable/issuable_template_selector.js +++ b/app/assets/javascripts/issuable/issuable_template_selector.js @@ -17,7 +17,15 @@ export default class IssuableTemplateSelector extends TemplateSelector { name: this.dropdown.data('selected'), }; - if (initialQuery.name) this.requestFile(initialQuery); + // Only use the default template if we don't have description data from autosave + if (!initialQuery.name && this.dropdown.data('default') && !this.editor.getValue().length) { + initialQuery.name = this.dropdown.data('default'); + } + + if (initialQuery.name) { + this.requestFile(initialQuery); + this.setToggleText(initialQuery.name); + } $('.reset-template', this.dropdown.parent()).on('click', () => { this.setInputValueToTemplateContent(); @@ -53,10 +61,14 @@ export default class IssuableTemplateSelector extends TemplateSelector { } this.setInputValueToTemplateContent(); - $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template')); + this.setToggleText(__('Choose a template')); this.previousSelectedIndex = null; } + setToggleText(text) { + $('.dropdown-toggle-text', this.dropdown).text(text); + } + setSelectedIndex() { this.previousSelectedIndex = this.dropdown.data('deprecatedJQueryDropdown').selectedIndex; } diff --git a/app/assets/javascripts/issuable/popover/components/issue_popover.vue b/app/assets/javascripts/issuable/popover/components/issue_popover.vue index 0cafaa1e500..945a3782642 100644 --- a/app/assets/javascripts/issuable/popover/components/issue_popover.vue +++ b/app/assets/javascripts/issuable/popover/components/issue_popover.vue @@ -1,14 +1,26 @@ <script> -import { GlPopover, GlSkeletonLoader } from '@gitlab/ui'; +import { GlIcon, GlPopover, GlSkeletonLoader, GlTooltipDirective } from '@gitlab/ui'; +import query from 'ee_else_ce/issuable/popover/queries/issue.query.graphql'; +import IssueDueDate from '~/boards/components/issue_due_date.vue'; +import IssueMilestone from '~/issuable/components/issue_milestone.vue'; import StatusBox from '~/issuable/components/status_box.vue'; +import { IssuableStatus } from '~/issues/constants'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import query from '../queries/issue.query.graphql'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; export default { components: { + GlIcon, GlPopover, GlSkeletonLoader, + IssueDueDate, + IssueMilestone, + IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), StatusBox, + WorkItemTypeIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, }, mixins: [timeagoMixin], props: { @@ -44,6 +56,9 @@ export default { showDetails() { return Object.keys(this.issue).length > 0; }, + isIssueClosed() { + return this.issue?.state === IssuableStatus.Closed; + }, }, apollo: { issue: { @@ -69,15 +84,46 @@ export default { </gl-skeleton-loader> <div v-else-if="showDetails" class="gl-display-flex gl-align-items-center"> <status-box issuable-type="issue" :initial-state="issue.state" /> + <gl-icon + v-if="issue.confidential" + v-gl-tooltip + name="eye-slash" + :title="__('Confidential')" + class="gl-text-orange-500 gl-mr-2" + :aria-label="__('Confidential')" + /> <span class="gl-text-secondary"> {{ __('Opened') }} <time :datetime="issue.createdAt">{{ formattedTime }}</time> </span> </div> <h5 v-if="!$apollo.queries.issue.loading" class="gl-my-3">{{ title }}</h5> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> - <div class="gl-text-secondary"> - {{ `${projectPath}#${iid}` }} + <div> + <work-item-type-icon v-if="!$apollo.queries.issue.loading" :work-item-type="issue.type" /> + <span class="gl-text-secondary">{{ `${projectPath}#${iid}` }}</span> </div> <!-- eslint-enable @gitlab/vue-require-i18n-strings --> + + <div v-if="!$apollo.queries.issue.loading" class="gl-display-flex gl-text-secondary gl-mt-2"> + <issue-due-date + v-if="issue.dueDate" + :date="issue.dueDate.toString()" + :closed="isIssueClosed" + tooltip-placement="top" + class="gl-mr-4" + css-class="gl-display-flex gl-white-space-nowrap" + /> + <issue-weight + v-if="issue.weight" + :weight="issue.weight" + tag-name="span" + class="gl-display-flex gl-mr-4" + /> + <issue-milestone + v-if="issue.milestone" + :milestone="issue.milestone" + class="gl-display-flex gl-overflow-hidden" + /> + </div> </gl-popover> </template> diff --git a/app/assets/javascripts/issuable/popover/queries/issue.query.graphql b/app/assets/javascripts/issuable/popover/queries/issue.query.graphql index 47a62e2b6ea..1bbe6cb6eac 100644 --- a/app/assets/javascripts/issuable/popover/queries/issue.query.graphql +++ b/app/assets/javascripts/issuable/popover/queries/issue.query.graphql @@ -6,6 +6,15 @@ query issue($projectPath: ID!, $iid: String!) { title createdAt state + confidential + dueDate + milestone { + id + title + startDate + dueDate + } + type } } } diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index 380bb5f5346..22ac37656ea 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -9,7 +9,7 @@ import { IssueType } from '~/issues/constants'; import Issue from '~/issues/issue'; import { initTitleSuggestions, initTypePopover } from '~/issues/new'; import { initRelatedMergeRequests } from '~/issues/related_merge_requests'; -import initRelatedIssues from '~/related_issues'; +import { initRelatedIssues } from '~/related_issues'; import { initHeaderActions, initIncidentApp, 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 f567b0f1d68..11911adb401 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -39,13 +39,13 @@ import { TOKEN_TITLE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; -import { - IssuableListTabs, - IssuableStates, - IssuableTypes, -} from '~/vue_shared/issuable/list/constants'; +import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { WORK_ITEM_TYPE_ENUM_TASK } from '~/work_items/constants'; import { CREATED_DESC, + defaultTypeTokenOptions, + defaultWorkItemTypes, i18n, ISSUE_REFERENCE, MAX_LIST_SIZE, @@ -67,6 +67,7 @@ import { TOKEN_TYPE_ORGANIZATION, TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, + TYPE_TOKEN_TASK_OPTION, UPDATED_DESC, urlSortParams, } from '../constants'; @@ -107,7 +108,6 @@ const CrmOrganizationToken = () => export default { i18n, IssuableListTabs, - IssuableTypes: [IssuableTypes.Issue, IssuableTypes.Incident, IssuableTypes.TestCase], components: { CsvImportExportButtons, GlButton, @@ -123,6 +123,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagMixin()], inject: [ 'autocompleteAwardEmojisPath', 'calendarPath', @@ -180,9 +181,7 @@ export default { issues: { query: getIssuesQuery, variables() { - const { types } = this.queryVariables; - - return { ...this.queryVariables, types: types ? [types] : this.$options.IssuableTypes }; + return this.queryVariables; }, update(data) { return data[this.namespace]?.issues.nodes ?? []; @@ -206,9 +205,7 @@ export default { issuesCounts: { query: getIssuesCountsQuery, variables() { - const { types } = this.queryVariables; - - return { ...this.queryVariables, types: types ? [types] : this.$options.IssuableTypes }; + return this.queryVariables; }, update(data) { return data[this.namespace] ?? {}; @@ -240,11 +237,22 @@ export default { state: this.state, ...this.pageParams, ...this.apiFilterParams, + types: this.apiFilterParams.types || this.defaultWorkItemTypes, }; }, namespace() { return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; }, + defaultWorkItemTypes() { + return this.isWorkItemsEnabled + ? defaultWorkItemTypes.concat(WORK_ITEM_TYPE_ENUM_TASK) + : defaultWorkItemTypes; + }, + typeTokenOptions() { + return this.isWorkItemsEnabled + ? defaultTypeTokenOptions.concat(TYPE_TOKEN_TASK_OPTION) + : defaultTypeTokenOptions; + }, hasSearch() { return ( this.searchQuery || @@ -262,6 +270,9 @@ export default { isOpenTab() { return this.state === IssuableStates.Opened; }, + isWorkItemsEnabled() { + return this.glFeatures.workItems; + }, showCsvButtons() { return this.isProject && this.isSignedIn; }, @@ -340,11 +351,7 @@ export default { title: TOKEN_TITLE_TYPE, icon: 'issues', token: GlFilteredSearchToken, - options: [ - { icon: 'issue-type-issue', title: 'issue', value: 'issue' }, - { icon: 'issue-type-incident', title: 'incident', value: 'incident' }, - { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' }, - ], + options: this.typeTokenOptions, }, ]; @@ -767,6 +774,7 @@ export default { :show-page-size-change-controls="showPageSizeControls" :has-next-page="pageInfo.hasNextPage" :has-previous-page="pageInfo.hasPreviousPage" + show-work-item-type-icon @click-tab="handleClickTab" @dismiss-alert="handleDismissAlert" @filter="handleFilter" diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index a921eb62e26..38fe4c33792 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -8,6 +8,11 @@ import { OPERATOR_IS, OPERATOR_IS_NOT, } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + WORK_ITEM_TYPE_ENUM_INCIDENT, + WORK_ITEM_TYPE_ENUM_ISSUE, + WORK_ITEM_TYPE_ENUM_TEST_CASE, +} from '~/work_items/constants'; export const i18n = { anonymousSearchingMessage: __('You must sign in to search for specific terms.'), @@ -147,6 +152,20 @@ export const TOKEN_TYPE_WEIGHT = 'weight'; export const TOKEN_TYPE_CONTACT = 'crm_contact'; export const TOKEN_TYPE_ORGANIZATION = 'crm_organization'; +export const TYPE_TOKEN_TASK_OPTION = { icon: 'task-done', title: 'task', value: 'task' }; + +export const defaultWorkItemTypes = [ + WORK_ITEM_TYPE_ENUM_ISSUE, + WORK_ITEM_TYPE_ENUM_INCIDENT, + WORK_ITEM_TYPE_ENUM_TEST_CASE, +]; + +export const defaultTypeTokenOptions = [ + { icon: 'issue-type-issue', title: 'issue', value: 'issue' }, + { icon: 'issue-type-incident', title: 'incident', value: 'incident' }, + { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' }, +]; + export const filters = { [TOKEN_TYPE_AUTHOR]: { [API_PARAM]: { diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index 35762120f71..040763f2ba4 100644 --- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -1,5 +1,4 @@ fragment IssueFragment on Issue { - __typename id iid confidential @@ -18,9 +17,9 @@ fragment IssueFragment on Issue { userDiscussionsCount @include(if: $isSignedIn) webPath webUrl + type assignees @skip(if: $hideUsers) { nodes { - __typename id avatarUrl name @@ -29,7 +28,6 @@ fragment IssueFragment on Issue { } } author @skip(if: $hideUsers) { - __typename id avatarUrl name diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue index 1d48446b083..a5cba3daafa 100644 --- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue @@ -66,7 +66,7 @@ export default { <template> <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)"> <div class="card card-slim gl-mt-5"> - <div class="card-header"> + <div class="card-header gl-bg-gray-10"> <div class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0" > diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 449da394841..a6747d67611 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -133,7 +133,7 @@ export default { }, computed: { workItemsEnabled() { - return this.glFeatures.workItems; + return this.glFeatures.workItemsCreateFromMarkdown; }, taskWorkItemType() { return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; @@ -302,7 +302,9 @@ export default { if (taskRegexMatches) { $tasks.text(this.taskStatus); $tasksShort.text( - `${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`, + `${taskRegexMatches[1]}/${taskRegexMatches[2]} checklist item${ + taskRegexMatches[2] > 1 ? 's' : '' + }`, ); } else { $tasks.text(''); @@ -315,7 +317,7 @@ export default { } this.taskButtons = []; - const taskListFields = this.$el.querySelectorAll('.task-list-item'); + const taskListFields = this.$el.querySelectorAll('.task-list-item:not(.inapplicable)'); taskListFields.forEach((item, index) => { const taskLink = item.querySelector('.gfm-issue'); @@ -326,6 +328,7 @@ export default { } const workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue); this.addHoverListeners(taskLink, workItemId); + taskLink.classList.add('gl-link'); taskLink.addEventListener('click', (e) => { e.preventDefault(); this.openWorkItemDetailModal(taskLink); diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index 9fc5027d457..77d13fe085a 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; export const timelineTabI18n = Object.freeze({ title: s__('Incident|Timeline'), @@ -12,6 +12,9 @@ export const timelineFormI18n = Object.freeze({ 'Incident|Something went wrong while creating the incident timeline event.', ), areaPlaceholder: s__('Incident|Timeline text...'), + save: __('Save'), + cancel: __('Cancel'), + description: __('Description'), saveAndAdd: s__('Incident|Save and add another event'), areaLabel: s__('Incident|Timeline text'), }); diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue new file mode 100644 index 00000000000..c902895702e --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -0,0 +1,117 @@ +<script> +import { produce } from 'immer'; +import { sortBy } from 'lodash'; +import { sprintf } from '~/locale'; +import { createAlert } from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { timelineFormI18n } from './constants'; +import TimelineEventsForm from './timeline_events_form.vue'; + +import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql'; +import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; + +export default { + name: 'CreateTimelineEvent', + i18n: timelineFormI18n, + components: { + TimelineEventsForm, + }, + inject: ['fullPath', 'issuableId'], + props: { + hasTimelineEvents: { + type: Boolean, + required: true, + }, + }, + data() { + return { createTimelineEventActive: false }; + }, + methods: { + clearForm() { + this.$refs.eventForm.clear(); + }, + focusDate() { + this.$refs.eventForm.focusDate(); + }, + updateCache(store, { data }) { + const { timelineEvent: event, errors } = data?.timelineEventCreate || {}; + + if (errors.length) { + return; + } + + const variables = { + incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + fullPath: this.fullPath, + }; + + const sourceData = store.readQuery({ + query: getTimelineEvents, + variables, + }); + + const newData = produce(sourceData, (draftData) => { + const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents; + draftEventList.push(event); + // ISOStrings sort correctly in lexical order + const sortedEvents = sortBy(draftEventList, 'occurredAt'); + draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents; + }); + + store.writeQuery({ + query: getTimelineEvents, + variables, + data: newData, + }); + }, + createIncidentTimelineEvent(eventDetails, addAnotherEvent = false) { + this.createTimelineEventActive = true; + return this.$apollo + .mutate({ + mutation: CreateTimelineEvent, + variables: { + input: { + incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + note: eventDetails.note, + occurredAt: eventDetails.occurredAt, + }, + }, + update: this.updateCache, + }) + .then(({ data = {} }) => { + this.createTimelineEventActive = false; + const errors = data.timelineEventCreate?.errors; + if (errors.length) { + createAlert({ + message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false), + }); + return; + } + if (addAnotherEvent) { + this.$refs.eventForm.clear(); + } else { + this.$emit('hide-new-timeline-events-form'); + } + }) + .catch((error) => { + createAlert({ + message: this.$options.i18n.createErrorGeneric, + captureError: true, + error, + }); + }); + }, + }, +}; +</script> + +<template> + <timeline-events-form + ref="eventForm" + :is-event-processed="createTimelineEventActive" + :has-timeline-events="hasTimelineEvents" + @save-event="createIncidentTimelineEvent" + @cancel="$emit('hide-new-timeline-events-form')" + /> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index 36ec6362a22..0d84fabb1be 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -1,21 +1,12 @@ <script> import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui'; -import { produce } from 'immer'; -import { sortBy } from 'lodash'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_ISSUE } from '~/graphql_shared/constants'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { createAlert } from '~/flash'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; -import { sprintf } from '~/locale'; -import { getUtcShiftedDateNow } from './utils'; import { timelineFormI18n } from './constants'; - -import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql'; -import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; +import { getUtcShiftedDateNow } from './utils'; export default { - name: 'IncidentTimelineEventForm', + name: 'TimelineEventsForm', restrictedToolBarItems: [ 'quote', 'strikethrough', @@ -38,112 +29,55 @@ export default { directives: { autofocusonshow, }, - inject: ['fullPath', 'issuableId'], props: { hasTimelineEvents: { type: Boolean, required: true, }, + isEventProcessed: { + type: Boolean, + required: true, + }, }, data() { - // Create shifted date to force the datepicker to format in UTC - const utcShiftedDate = getUtcShiftedDateNow(); + // if occurredAt is undefined, returns "now" in UTC + const placeholderDate = getUtcShiftedDateNow(); + return { - currentDate: utcShiftedDate, - currentHour: utcShiftedDate.getHours(), - currentMinute: utcShiftedDate.getMinutes(), timelineText: '', - createTimelineEventActive: false, + placeholderDate, + hourPickerInput: placeholderDate.getHours(), + minutePickerInput: placeholderDate.getMinutes(), datepickerTextInput: null, }; }, + computed: { + occurredAt() { + const [years, months, days] = this.datepickerTextInput.split('-'); + const utcDate = new Date( + Date.UTC(years, months - 1, days, this.hourPickerInput, this.minutePickerInput), + ); + + return utcDate.toISOString(); + }, + }, methods: { clear() { - const utcShiftedDate = getUtcShiftedDateNow(); - this.currentDate = utcShiftedDate; - this.currentHour = utcShiftedDate.getHours(); - this.currentMinute = utcShiftedDate.getMinutes(); - }, - hideIncidentTimelineEventForm() { - this.$emit('hide-incident-timeline-event-form'); + const utcShiftedDateNow = getUtcShiftedDateNow(); + this.placeholderDate = utcShiftedDateNow; + this.hourPickerInput = utcShiftedDateNow.getHours(); + this.minutePickerInput = utcShiftedDateNow.getMinutes(); + this.timelineText = ''; }, focusDate() { this.$refs.datepicker.$el.focus(); }, - updateCache(store, { data }) { - const { timelineEvent: event, errors } = data?.timelineEventCreate || {}; - - if (errors.length) { - return; - } - - const variables = { - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), - fullPath: this.fullPath, + handleSave(addAnotherEvent) { + const eventDetails = { + note: this.timelineText, + occurredAt: this.occurredAt, }; - - const sourceData = store.readQuery({ - query: getTimelineEvents, - variables, - }); - - const newData = produce(sourceData, (draftData) => { - const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents; - draftEventList.push(event); - // ISOStrings sort correctly in lexical order - const sortedEvents = sortBy(draftEventList, 'occurredAt'); - draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents; - }); - - store.writeQuery({ - query: getTimelineEvents, - variables, - data: newData, - }); - }, - createIncidentTimelineEvent(addOneEvent) { - this.createTimelineEventActive = true; - return this.$apollo - .mutate({ - mutation: CreateTimelineEvent, - variables: { - input: { - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), - note: this.timelineText, - occurredAt: this.createDateString(), - }, - }, - update: this.updateCache, - }) - .then(({ data = {} }) => { - const errors = data.timelineEventCreate?.errors; - if (errors.length) { - createAlert({ - message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false), - }); - } - }) - .catch((error) => { - createAlert({ - message: this.$options.i18n.createErrorGeneric, - captureError: true, - error, - }); - }) - .finally(() => { - this.createTimelineEventActive = false; - this.timelineText = ''; - if (addOneEvent) { - this.hideIncidentTimelineEventForm(); - } - }); - }, - createDateString() { - const [years, months, days] = this.datepickerTextInput.split('-'); - const utcDate = new Date( - Date.UTC(years, months - 1, days, this.currentHour, this.currentMinute), - ); - return utcDate.toISOString(); + this.$emit('save-event', eventDetails, addAnotherEvent); }, }, }; @@ -165,7 +99,7 @@ export default { class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker" > <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5"> - <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="currentDate"> + <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="placeholderDate"> <gl-form-input id="incident-date" ref="datepicker" @@ -184,7 +118,7 @@ export default { <label label-for="timeline-input-hours" class="sr-only"></label> <gl-form-input id="timeline-input-hours" - v-model="currentHour" + v-model="hourPickerInput" data-testid="input-hours" size="xs" type="number" @@ -194,7 +128,7 @@ export default { <label label-for="timeline-input-minutes" class="sr-only"></label> <gl-form-input id="timeline-input-minutes" - v-model="currentMinute" + v-model="minutePickerInput" class="gl-ml-3" data-testid="input-minutes" size="xs" @@ -223,9 +157,10 @@ export default { <textarea v-model="timelineText" class="note-textarea js-gfm-input js-autosize markdown-area" + data-testid="input-note" dir="auto" data-supports-quick-actions="false" - :aria-label="__('Description')" + :aria-label="$options.i18n.description" :placeholder="$options.i18n.areaPlaceholder" > </textarea> @@ -238,26 +173,22 @@ export default { variant="confirm" category="primary" class="gl-mr-3" - :loading="createTimelineEventActive" - @click="createIncidentTimelineEvent(true)" + :loading="isEventProcessed" + @click="handleSave(false)" > - {{ __('Save') }} + {{ $options.i18n.save }} </gl-button> <gl-button variant="confirm" category="secondary" class="gl-mr-3 gl-ml-n2" - :loading="createTimelineEventActive" - @click="createIncidentTimelineEvent(false)" + :loading="isEventProcessed" + @click="handleSave(true)" > {{ $options.i18n.saveAndAdd }} </gl-button> - <gl-button - class="gl-ml-n2" - :disabled="createTimelineEventActive" - @click="hideIncidentTimelineEventForm" - > - {{ __('Cancel') }} + <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> + {{ $options.i18n.cancel }} </gl-button> <div class="gl-border-b gl-pt-5"></div> </gl-form-group> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index 62ccd696ef6..6175c9969ec 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -1,5 +1,12 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlIcon, + GlSafeHtmlDirective, + GlSprintf, +} from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; import { getEventIcon } from './utils'; @@ -12,6 +19,7 @@ export default { timeUTC: __('%{time} UTC'), }, components: { + GlButton, GlDropdown, GlDropdownItem, GlIcon, @@ -83,7 +91,7 @@ export default { no-caret > <gl-dropdown-item @click="$emit('delete')"> - {{ $options.i18n.delete }} + <gl-button>{{ $options.i18n.delete }}</gl-button> </gl-dropdown-item> </gl-dropdown> </div> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue index 519c0d402a0..80ac1c372cd 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -4,7 +4,7 @@ import { createAlert } from '~/flash'; import { sprintf } from '~/locale'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; -import IncidentTimelineEventListItem from './timeline_events_list_item.vue'; +import IncidentTimelineEventItem from './timeline_events_item.vue'; import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql'; import { timelineListI18n } from './constants'; @@ -12,7 +12,7 @@ export default { name: 'IncidentTimelineEventList', i18n: timelineListI18n, components: { - IncidentTimelineEventListItem, + IncidentTimelineEventItem, }, props: { timelineEventLoading: { @@ -99,16 +99,21 @@ export default { <div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid"> <strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong> </div> - <ul class="notes main-notes-list gl-pl-n3"> - <incident-timeline-event-list-item + <ul class="notes main-notes-list"> + <li v-for="(event, eventIndex) in events" - :key="event.id" - :action="event.action" - :occurred-at="event.occurredAt" - :note-html="event.noteHtml" - :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)" - @delete="handleDelete(event)" - /> + :key="eventIndex" + class="timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!" + > + <incident-timeline-event-item + :key="event.id" + :action="event.action" + :occurred-at="event.occurredAt" + :note-html="event.noteHtml" + :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)" + @delete="handleDelete(event)" + /> + </li> </ul> </div> </div> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index e1946ef4d07..7c2a7878c58 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -7,7 +7,7 @@ import getTimelineEvents from './graphql/queries/get_timeline_events.query.graph import { displayAndLogError } from './utils'; import { timelineTabI18n } from './constants'; -import IncidentTimelineEventForm from './timeline_events_form.vue'; +import CreateTimelineEvent from './create_timeline_event.vue'; import IncidentTimelineEventsList from './timeline_events_list.vue'; export default { @@ -16,7 +16,7 @@ export default { GlEmptyState, GlLoadingIcon, GlTab, - IncidentTimelineEventForm, + CreateTimelineEvent, IncidentTimelineEventsList, }, i18n: timelineTabI18n, @@ -61,10 +61,10 @@ export default { this.isEventFormVisible = false; }, async showEventForm() { - this.$refs.eventForm.clear(); + this.$refs.createEventForm.clearForm(); this.isEventFormVisible = true; await this.$nextTick(); - this.$refs.eventForm.focusDate(); + this.$refs.createEventForm.focusDate(); }, }, }; @@ -82,14 +82,15 @@ export default { v-if="hasTimelineEvents" :timeline-event-loading="timelineEventLoading" :timeline-events="timelineEvents" + @hide-new-timeline-events-form="hideEventForm" /> - <incident-timeline-event-form + <create-timeline-event v-show="isEventFormVisible" - ref="eventForm" + ref="createEventForm" :has-timeline-events="hasTimelineEvents" class="timeline-event-note timeline-event-note-form" :class="{ 'gl-pl-0': !hasTimelineEvents }" - @hide-incident-timeline-event-form="hideEventForm" + @hide-new-timeline-events-form="hideEventForm" /> <gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm"> {{ $options.i18n.addEventButton }} diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js index 256e3025f19..cf790a11b67 100644 --- a/app/assets/javascripts/issues/show/components/incidents/utils.js +++ b/app/assets/javascripts/issues/show/components/incidents/utils.js @@ -11,6 +11,7 @@ export const displayAndLogError = (error) => const EVENT_ICONS = { comment: 'comment', issues: 'issues', + label: 'label', status: 'status', default: 'comment', }; diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index da72cbeb856..4046e1ade82 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -189,25 +189,23 @@ export default { v-if="hasEnvironment" :href="environmentLink.link" data-testid="job-environment-link" - v-text="environmentLink.name" - /> + >{{ environmentLink.name }}</gl-link + > </template> <template #clusterNameOrLink> <gl-link v-if="clusterNameOrLink.path" :href="clusterNameOrLink.path" data-testid="job-cluster-link" - v-text="clusterNameOrLink.name" - /> + >{{ clusterNameOrLink.name }}</gl-link + > <template v-else>{{ clusterNameOrLink.name }}</template> </template> <template #kubernetesNamespace>{{ kubernetesNamespace }}</template> <template #deploymentLink> - <gl-link - :href="deploymentLink.path" - data-testid="job-deployment-link" - v-text="deploymentLink.name" - /> + <gl-link :href="deploymentLink.path" data-testid="job-deployment-link">{{ + deploymentLink.name + }}</gl-link> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index f9e6c64aad1..d5ee3423d70 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -287,6 +287,7 @@ export default { :is-scroll-top-disabled="isScrollTopDisabled" :is-job-log-size-visible="isJobLogSizeVisible" :is-scrolling-down="isScrollingDown" + :is-complete="isJobLogComplete" :job-log="jobLog" @scrollJobLogTop="scrollTop" @scrollJobLogBottom="scrollBottom" diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index 5e89dd5acc2..e9809ac661b 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui'; -import { scrollToElement } from '~/lib/utils/common_utils'; +import { scrollToElement, backOff } from '~/lib/utils/common_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __, s__, sprintf } from '~/locale'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; @@ -10,6 +10,7 @@ export default { i18n: { scrollToBottomButtonLabel: s__('Job|Scroll to bottom'), scrollToTopButtonLabel: s__('Job|Scroll to top'), + scrollToNextFailureButtonLabel: s__('Job|Scroll to next failure'), showRawButtonLabel: s__('Job|Show complete raw'), searchPlaceholder: s__('Job|Search job log'), noResults: s__('Job|No search results found'), @@ -55,6 +56,10 @@ export default { type: Boolean, required: true, }, + isComplete: { + type: Boolean, + required: true, + }, jobLog: { type: Array, required: true, @@ -64,6 +69,8 @@ export default { return { searchTerm: '', searchResults: [], + failureCount: null, + failureIndex: 0, }; }, computed: { @@ -72,16 +79,49 @@ export default { size: numberToHumanSize(this.size), }); }, - showJobLogSearch() { - return this.glFeatures.jobLogSearch; + showJumpToFailures() { + return this.glFeatures.jobLogJumpToFailures; + }, + hasFailures() { + return this.failureCount > 0; + }, + shouldDisableJumpToFailures() { + return !this.hasFailures; }, }, + mounted() { + this.checkFailureCount(); + }, methods: { + checkFailureCount() { + if (this.glFeatures.jobLogJumpToFailures) { + backOff((next, stop) => { + this.failureCount = document.querySelectorAll('.term-fg-l-red').length; + + if (this.hasFailures || (this.isComplete && !this.hasFailures)) { + stop(); + } else { + next(); + } + }); + } + }, + handleScrollToNextFailure() { + const failures = document.querySelectorAll('.term-fg-l-red'); + const nextFailure = failures[this.failureIndex]; + + if (nextFailure) { + nextFailure.scrollIntoView({ block: 'center' }); + this.failureIndex = (this.failureIndex + 1) % failures.length; + } + }, handleScrollToTop() { this.$emit('scrollJobLogTop'); + this.failureIndex = 0; }, handleScrollToBottom() { this.$emit('scrollJobLogBottom'); + this.failureIndex = 0; }, searchJobLog() { this.searchResults = []; @@ -135,10 +175,10 @@ export default { }; </script> <template> - <div class="top-bar"> + <div class="top-bar gl-display-flex gl-justify-content-space-between"> <!-- truncate information --> <div - class="truncated-info gl-display-none gl-sm-display-block gl-float-left" + class="truncated-info gl-display-none gl-sm-display-flex gl-flex-wrap gl-align-items-center" data-testid="log-truncated-info" > <template v-if="isJobLogSizeVisible"> @@ -154,25 +194,23 @@ export default { </div> <!-- eo truncate information --> - <div class="controllers gl-float-right"> - <template v-if="showJobLogSearch"> - <gl-search-box-by-click - v-model="searchTerm" - class="gl-mr-3" - :placeholder="$options.i18n.searchPlaceholder" - data-testid="job-log-search-box" - @clear="$emit('searchResults', [])" - @submit="searchJobLog" - /> + <div class="controllers"> + <gl-search-box-by-click + v-model="searchTerm" + class="gl-mr-3" + :placeholder="$options.i18n.searchPlaceholder" + data-testid="job-log-search-box" + @clear="$emit('searchResults', [])" + @submit="searchJobLog" + /> - <help-popover class="gl-mr-3"> - <template #title>{{ $options.i18n.searchPopoverTitle }}</template> + <help-popover class="gl-mr-3"> + <template #title>{{ $options.i18n.searchPopoverTitle }}</template> - <p class="gl-mb-0"> - {{ $options.i18n.searchPopoverDescription }} - </p> - </help-popover> - </template> + <p class="gl-mb-0"> + {{ $options.i18n.searchPopoverDescription }} + </p> + </help-popover> <!-- links --> <gl-button @@ -187,6 +225,18 @@ export default { <!-- eo links --> <!-- scroll buttons --> + <gl-button + v-if="showJumpToFailures" + v-gl-tooltip + :title="$options.i18n.scrollToNextFailureButtonLabel" + :aria-label="$options.i18n.scrollToNextFailureButtonLabel" + :disabled="shouldDisableJumpToFailures" + class="btn-scroll gl-ml-3" + data-testid="job-controller-scroll-to-failure" + icon="soft-wrap" + @click="handleScrollToNextFailure" + /> + <div v-gl-tooltip :title="$options.i18n.scrollToTopButtonLabel" class="gl-ml-3"> <gl-button :disabled="isScrollTopDisabled" diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index 15c4e503685..3b1509e5be5 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -1,7 +1,6 @@ <script> import { mapState } from 'vuex'; import { GlBadge } from '@gitlab/ui'; -import { helpPagePath } from '~/helpers/help_page_helper'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -47,11 +46,6 @@ export default { this.job.coverage, ); }, - runnerHelpUrl() { - return helpPagePath('ci/runners/configure_runners.html', { - anchor: 'set-maximum-job-timeout-for-a-runner', - }); - }, runnerId() { const { id, short_sha: token, description } = this.job.runner; @@ -85,6 +79,7 @@ export default { TAGS: __('Tags:'), TIMEOUT: __('Timeout'), }, + RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html', }; </script> @@ -101,7 +96,7 @@ export default { <detail-row v-if="job.queued_duration" :value="queuedDuration" :title="$options.i18n.QUEUED" /> <detail-row v-if="hasTimeout" - :help-url="runnerHelpUrl" + :help-url="$options.RUNNER_HELP_URL" :value="timeout" data-testid="job-timeout" :title="$options.i18n.TIMEOUT" diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql index 5b1032c6448..98b51e8c2c4 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -1,7 +1,6 @@ query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJobStatus!]) { project(fullPath: $fullPath) { id - __typename jobs(after: $after, first: $first, statuses: $statuses) { count pageInfo { @@ -9,15 +8,12 @@ query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJo hasNextPage hasPreviousPage startCursor - __typename } nodes { - __typename artifacts { nodes { downloadPath fileType - __typename } } allowFailure diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index b3db5a94ac5..c2f460cb647 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -3,7 +3,6 @@ import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from import { __ } from '~/locale'; import createFlash from '~/flash'; import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue'; -import eventHub from './event_hub'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; import JobsTable from './jobs_table.vue'; import JobsTableEmptyState from './jobs_table_empty_state.vue'; @@ -108,16 +107,7 @@ export default { } }, }, - mounted() { - eventHub.$on('jobActionPerformed', this.handleJobAction); - }, - beforeDestroy() { - eventHub.$off('jobActionPerformed', this.handleJobAction); - }, methods: { - handleJobAction() { - this.$apollo.queries.jobs.refetch({ statuses: this.scope }); - }, fetchJobsByStatus(scope) { this.infiniteScrollingTriggered = false; @@ -169,6 +159,7 @@ export default { v-if="shouldShowAlert" class="gl-mt-2" variant="danger" + data-testid="jobs-table-error-alert" dismissible @dismiss="isAlertDismissed = true" > diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js index 9d8ee165df2..3e5396c5bd8 100644 --- a/app/assets/javascripts/labels/labels_select.js +++ b/app/assets/javascripts/labels/labels_select.js @@ -438,7 +438,7 @@ export default class LabelsSelect { [ '<% if (isScopedLabel(label) && enableScopedLabels) { %>', "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>", - '<br />', + '<br>', '<%= escapeStr(label.description) %>', '<% } else { %>', '<%= escapeStr(label.description) %>', diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index a01c6df0003..3e28ca2a0f7 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -33,6 +33,22 @@ const removeUnsafeHref = (node, attr) => { }; /** + * Appends 'noopener' & 'noreferrer' to rel + * attr values to prevent reverse tabnabbing. + * + * @param {String} rel + * @returns {String} + */ +const appendSecureRelValue = (rel) => { + const attributes = new Set(rel ? rel.toLowerCase().split(' ') : []); + + attributes.add('noopener'); + attributes.add('noreferrer'); + + return Array.from(attributes).join(' '); +}; + +/** * Sanitize icons' <use> tag attributes, to safely include * svgs such as in: * @@ -57,4 +73,25 @@ addHook('afterSanitizeAttributes', (node) => { } }); +const TEMPORARY_ATTRIBUTE = 'data-temp-href-target'; + +addHook('beforeSanitizeAttributes', (node) => { + if (node.tagName === 'A' && node.hasAttribute('target')) { + node.setAttribute(TEMPORARY_ATTRIBUTE, node.getAttribute('target')); + } +}); + +addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'A' && node.hasAttribute(TEMPORARY_ATTRIBUTE)) { + node.setAttribute('target', node.getAttribute(TEMPORARY_ATTRIBUTE)); + node.removeAttribute(TEMPORARY_ATTRIBUTE); + if (node.getAttribute('target') === '_blank') { + const rel = node.getAttribute('rel'); + node.setAttribute('rel', appendSecureRelValue(rel)); + } + } +}); + export const sanitize = (val, config) => dompurifySanitize(val, { ...defaultConfig, ...config }); + +export { isValidAttribute } from 'dompurify'; diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js index 92118c8929f..eaf653e9924 100644 --- a/app/assets/javascripts/lib/gfm/index.js +++ b/app/assets/javascripts/lib/gfm/index.js @@ -1,10 +1,15 @@ import { pick } from 'lodash'; +import normalize from 'mdurl/encode'; import { unified } from 'unified'; import remarkParse from 'remark-parse'; +import remarkFrontmatter from 'remark-frontmatter'; import remarkGfm from 'remark-gfm'; import remarkRehype, { all } from 'remark-rehype'; import rehypeRaw from 'rehype-raw'; +const skipFrontmatterHandler = (language) => (h, node) => + h(node.position, 'frontmatter', { language }, [{ type: 'text', value: node.value }]); + const skipRenderingHandlers = { footnoteReference: (h, node) => h(node.position, 'footnoteReference', { identifier: node.identifier, label: node.label }, []), @@ -19,12 +24,57 @@ const skipRenderingHandlers = { h(node.position, 'codeBlock', { language: node.lang, meta: node.meta }, [ { type: 'text', value: node.value }, ]), + definition: (h, node) => { + const title = node.title ? ` "${node.title}"` : ''; + + return h( + node.position, + 'referenceDefinition', + { identifier: node.identifier, url: node.url, title: node.title }, + [{ type: 'text', value: `[${node.identifier}]: ${node.url}${title}` }], + ); + }, + linkReference: (h, node) => { + const definition = h.definition(node.identifier); + + return h( + node.position, + 'a', + { + href: normalize(definition.url ?? ''), + identifier: node.identifier, + isReference: 'true', + title: definition.title, + }, + all(h, node), + ); + }, + imageReference: (h, node) => { + const definition = h.definition(node.identifier); + + return h( + node.position, + 'img', + { + src: normalize(definition.url ?? ''), + alt: node.alt, + identifier: node.identifier, + isReference: 'true', + title: definition.title, + }, + all(h, node), + ); + }, + toml: skipFrontmatterHandler('toml'), + yaml: skipFrontmatterHandler('yaml'), + json: skipFrontmatterHandler('json'), }; const createParser = ({ skipRendering = [] }) => { return unified() .use(remarkParse) .use(remarkGfm) + .use(remarkFrontmatter, ['yaml', 'toml', { type: 'json', marker: ';' }]) .use(remarkRehype, { allowDangerousHtml: true, handlers: { diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index cfcce234bfb..98e45f95b38 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -221,6 +221,7 @@ export default (resolvers = {}, config = {}) => { ac = new ApolloClient({ typeDefs, link: appLink, + connectToDevTools: process.env.NODE_ENV !== 'production', cache: new InMemoryCache({ ...cacheConfig, typePolicies: { diff --git a/app/assets/javascripts/lib/markdown_it.js b/app/assets/javascripts/lib/markdown_it.js new file mode 100644 index 00000000000..0b7a553737d --- /dev/null +++ b/app/assets/javascripts/lib/markdown_it.js @@ -0,0 +1,11 @@ +/** + * This module replaces markdown-it with an empty function. markdown-it + * is a dependency of the prosemirror-markdown package. prosemirror-markdown + * uses markdown-it to parse markdown and produce an AST. However, the + * features that use prosemirror-markdown in the GitLab application do not + * require markdown parsing. + * + * Replacing markdown-it with this empty function removes unnecessary javascript + * from the production builds. + */ +export default () => {}; diff --git a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js index 6473683c3af..5e621ca3216 100644 --- a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js +++ b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js @@ -1,3 +1 @@ -// Import from `src/to_markdown` to avoid unnecessary bundling of unused libs -// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79859 -export * from 'prosemirror-markdown/src/to_markdown'; +export { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown'; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 243de48948c..9f4e12a3010 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -4,12 +4,14 @@ import Shortcuts from '~/behaviors/shortcuts/shortcuts'; import { insertText } from '~/lib/utils/common_utils'; const LINK_TAG_PATTERN = '[{text}](url)'; +const INDENT_CHAR = ' '; +const INDENT_LENGTH = 2; // at the start of a line, find any amount of whitespace followed by // a bullet point character (*+-) and an optional checkbox ([ ] [x]) // OR a number with a . after it and an optional checkbox ([ ] [x]) // followed by one or more whitespace characters -const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX\s])\])?\s)(?<content>.)?/; +const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX~\s])\])?\s)(?<content>.)?/; // detect a horizontal rule that might be mistaken for a list item (not full pattern for an <hr>) const HR_PATTERN = /^((\s{0,3}-+\s*-+\s*-+\s*[\s-]*)|(\s{0,3}\*+\s*\*+\s*\*+\s*[\s*]*))$/; @@ -24,33 +26,104 @@ function addBlockTags(blockTag, selected) { return `${blockTag}\n${selected}\n${blockTag}`; } -function lineBefore(text, textarea, trimNewlines = true) { - let split = text.substring(0, textarea.selectionStart); - - if (trimNewlines) { - split = split.trim(); - } +/** + * Returns the line of text that is before the first line + * of the current selection + * + * @param {String} text - the text of the targeted text area + * @param {Object} textArea - the targeted text area + * @returns {String} + */ +function lineBeforeSelection(text, textArea) { + let split = text.substring(0, textArea.selectionStart); split = split.split('\n'); - return split[split.length - 1]; -} + // Last item, at -1, is the line where the start of selection is. + // Line before selection is therefore at -2 + const lineBefore = split[split.length - 2]; -function lineAfter(text, textarea, trimNewlines = true) { - let split = text.substring(textarea.selectionEnd); + return lineBefore === undefined ? '' : lineBefore; +} - if (trimNewlines) { - split = split.trim(); - } else { - // remove possible leading newline to get at the real line - split = split.replace(/^\n/, ''); - } +/** + * Returns the line of text that is after the last line + * of the current selection + * + * @param {String} text - the text of the targeted text area + * @param {Object} textArea - the targeted text area + * @returns {String} + */ +function lineAfterSelection(text, textArea) { + let split = text.substring(textArea.selectionEnd); + // remove possible leading newline to get at the real line + split = split.replace(/^\n/, ''); split = split.split('\n'); return split[0]; } +/** + * Returns the text lines that encompass the current selection + * + * @param {Object} textArea - the targeted text area + * @returns {Object} + */ +function linesFromSelection(textArea) { + const text = textArea.value; + const { selectionStart, selectionEnd } = textArea; + + let startPos = text[selectionStart] === '\n' ? selectionStart - 1 : selectionStart; + startPos = text.lastIndexOf('\n', startPos) + 1; + + let endPos = selectionEnd === selectionStart ? selectionEnd : selectionEnd - 1; + endPos = text.indexOf('\n', endPos); + if (endPos < 0) endPos = text.length; + + const selectedRange = text.substring(startPos, endPos); + const lines = selectedRange.split('\n'); + + return { + lines, + selectionStart, + selectionEnd, + startPos, + endPos, + }; +} + +/** + * Set the selection of a textarea such that it maintains the + * previous selection before the lines were indented/outdented + * + * @param {Object} textArea - the targeted text area + * @param {Number} selectionStart - start position of original selection + * @param {Number} selectionEnd - end position of original selection + * @param {Number} lineStart - start pos of first line + * @param {Number} firstLineChange - number of characters changed on first line + * @param {Number} totalChanged - total number of characters changed + */ +function setNewSelectionRange( + textArea, + selectionStart, + selectionEnd, + lineStart, + firstLineChange, + totalChanged, +) { + let newStart = Math.max(lineStart, selectionStart + firstLineChange); + let newEnd = Math.max(lineStart, selectionEnd + totalChanged); + + if (selectionStart === selectionEnd) { + newEnd = newStart; + } else if (selectionStart === lineStart) { + newStart = lineStart; + } + + textArea.setSelectionRange(newStart, newEnd); +} + function convertMonacoSelectionToAceFormat(sel) { return { start: { @@ -93,7 +166,8 @@ function editorBlockTagText(text, blockTag, selected, editor) { function blockTagText(text, textArea, blockTag, selected) { const shouldRemoveBlock = - lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag; + lineBeforeSelection(text, textArea) === blockTag && + lineAfterSelection(text, textArea) === blockTag; if (shouldRemoveBlock) { // To remove the block tag we have to select the line before & after @@ -312,9 +386,100 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo }); } +/** + * Indents selected lines to the right by 2 spaces + * + * @param {Object} textArea - the targeted text area + */ +function indentLines(textArea) { + const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea); + const shiftedLines = []; + let totalAdded = 0; + + textArea.setSelectionRange(startPos, endPos); + + lines.forEach((line) => { + line = INDENT_CHAR.repeat(INDENT_LENGTH) + line; + totalAdded += INDENT_LENGTH; + + shiftedLines.push(line); + }); + + const textToInsert = shiftedLines.join('\n'); + + insertText(textArea, textToInsert); + setNewSelectionRange(textArea, selectionStart, selectionEnd, startPos, INDENT_LENGTH, totalAdded); +} + +/** + * Outdents selected lines to the left by 2 spaces + * + * @param {Object} textArea - the targeted text area + */ +function outdentLines(textArea) { + const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea); + const shiftedLines = []; + let totalRemoved = 0; + let removedFromFirstline = -1; + let removedFromLine = 0; + + textArea.setSelectionRange(startPos, endPos); + + lines.forEach((line) => { + removedFromLine = 0; + + if (line.length > 0) { + // need to count how many spaces are actually removed, so can't use `replace` + while (removedFromLine < INDENT_LENGTH && line[removedFromLine] === INDENT_CHAR) { + removedFromLine += 1; + } + + if (removedFromLine > 0) { + line = line.slice(removedFromLine); + totalRemoved += removedFromLine; + } + } + + if (removedFromFirstline === -1) removedFromFirstline = removedFromLine; + shiftedLines.push(line); + }); + + const textToInsert = shiftedLines.join('\n'); + + if (totalRemoved > 0) insertText(textArea, textToInsert); + + setNewSelectionRange( + textArea, + selectionStart, + selectionEnd, + startPos, + -removedFromFirstline, + -totalRemoved, + ); +} + +function handleIndentOutdent(e, textArea) { + if (e.altKey || e.ctrlKey || e.shiftKey) return; + if (!e.metaKey) return; + + switch (e.key) { + case ']': + e.preventDefault(); + indentLines(textArea); + break; + case '[': + e.preventDefault(); + outdentLines(textArea); + break; + default: + break; + } +} + /* eslint-disable @gitlab/require-i18n-strings */ function handleSurroundSelectedText(e, textArea) { if (!gon.markdown_surround_selection) return; + if (e.metaKey) return; if (textArea.selectionStart === textArea.selectionEnd) return; const keys = { @@ -348,13 +513,13 @@ function handleSurroundSelectedText(e, textArea) { /** * Returns the content for a new line following a list item. * - * @param {Object} result - regex match of the current line - * @param {Object?} nextLineResult - regex match of the next line + * @param {Object} listLineMatch - regex match of the current line + * @param {Object?} nextLineMatch - regex match of the next line * @returns string with the new list item */ -function continueOlText(result, nextLineResult) { - const { indent, leader } = result.groups; - const { indent: nextIndent, isOl: nextIsOl } = nextLineResult?.groups ?? {}; +function continueOlText(listLineMatch, nextLineMatch) { + const { indent, leader } = listLineMatch.groups; + const { indent: nextIndent, isOl: nextIsOl } = nextLineMatch?.groups ?? {}; const [numStr, postfix = ''] = leader.split('.'); @@ -368,20 +533,20 @@ function handleContinueList(e, textArea) { if (!(e.key === 'Enter')) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (textArea.selectionStart !== textArea.selectionEnd) return; - // prevent unintended line breaks were inserted using Japanese IME on MacOS + // prevent unintended line breaks inserted using Japanese IME on MacOS if (compositioningNoteText) return; - const currentLine = lineBefore(textArea.value, textArea, false); - const result = currentLine.match(LIST_LINE_HEAD_PATTERN); + const firstSelectedLine = linesFromSelection(textArea).lines[0]; + const listLineMatch = firstSelectedLine.match(LIST_LINE_HEAD_PATTERN); - if (result) { - const { leader, indent, content, isOl } = result.groups; - const prevLineEmpty = !content; + if (listLineMatch) { + const { leader, indent, content, isOl } = listLineMatch.groups; + const emptyListItem = !content; - if (prevLineEmpty) { - // erase previous empty list item - select the text and allow the - // natural line feed erase the text - textArea.selectionStart = textArea.selectionStart - result[0].length; + if (emptyListItem) { + // erase empty list item - select the text and allow the + // natural line feed to erase the text + textArea.selectionStart = textArea.selectionStart - listLineMatch[0].length; return; } @@ -389,17 +554,17 @@ function handleContinueList(e, textArea) { // Behaviors specific to either `ol` or `ul` if (isOl) { - const nextLine = lineAfter(textArea.value, textArea, false); - const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN); + const nextLine = lineAfterSelection(textArea.value, textArea); + const nextLineMatch = nextLine.match(LIST_LINE_HEAD_PATTERN); - itemToInsert = continueOlText(result, nextLineResult); + itemToInsert = continueOlText(listLineMatch, nextLineMatch); } else { - if (currentLine.match(HR_PATTERN)) return; + if (firstSelectedLine.match(HR_PATTERN)) return; itemToInsert = `${indent}${leader}`; } - itemToInsert = itemToInsert.replace(/\[x\]/i, '[ ]'); + itemToInsert = itemToInsert.replace(/\[[x~]\]/i, '[ ]'); e.preventDefault(); @@ -419,6 +584,7 @@ export function keypressNoteText(e) { if ($(textArea).atwho?.('isSelecting')) return; + handleIndentOutdent(e, textArea); handleContinueList(e, textArea); handleSurroundSelectedText(e, textArea); } diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index ff60fd2aecb..ca90eee69c7 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -397,6 +397,7 @@ export function relativePathToAbsolute(path, basePath) { const absolute = isAbsolute(basePath); const base = absolute ? basePath : `file:///${basePath}`; const url = new URL(path, base); + url.pathname = url.pathname.replace(/\/\/+/g, '/'); return absolute ? url.href : decodeURIComponent(url.pathname); } @@ -668,3 +669,27 @@ export function constructWebIDEPath({ webIDEUrl(`/${sourceProjectFullPath}/merge_requests/${iid}`), ); } + +/** + * Examples + * + * http://gitlab.com => gitlab.com + * https://gitlab.com => gitlab.com + * + * @param {String} url + * @returns A url without a protocol / scheme + */ +export const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, ''); + +/** + * Examples + * + * https://www.gitlab.com/path/ => https://www.gitlab.com/path + * https://www.gitlab.com/?query=search => https://www.gitlab.com?query=search + * https://www.gitlab.com/#fragment => https://www.gitlab.com#fragment + * + * @param {String} url + * @returns A URL that does not have a path that ends with slash + */ +export const removeLastSlashInUrlPath = (url) => + url.replace(/\/$/, '').replace(/\/(\?|#){1}([^/]*)$/, '$1$2'); diff --git a/app/assets/javascripts/lib/utils/yaml.js b/app/assets/javascripts/lib/utils/yaml.js index 9270d388342..48f34624140 100644 --- a/app/assets/javascripts/lib/utils/yaml.js +++ b/app/assets/javascripts/lib/utils/yaml.js @@ -16,18 +16,17 @@ function getPath(ancestry) { function getFirstChildNode(collection) { let firstChildKey; - let type; - switch (collection.constructor.name) { - case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings - return collection.items.find((i) => isNode(i)); - case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings - firstChildKey = collection.items[0]?.key; - if (!firstChildKey) return undefined; - return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey); - default: - type = collection.constructor?.name || typeof collection; - throw Error(`Cannot identify a child Node for type ${type}`); + if (isSeq(collection)) { + return collection.items.find((i) => isNode(i)); } + if (isMap(collection)) { + firstChildKey = collection.items[0]?.key; + if (!firstChildKey) return undefined; + return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey); + } + throw Error( + `Cannot identify a child Node for Collection. Expecting a YAMLMap or a YAMLSeq. Got: ${collection}`, + ); } function moveMetaPropsToFirstChildNode(collection) { diff --git a/app/assets/javascripts/linked_resources/index.js b/app/assets/javascripts/linked_resources/index.js index 244adca86c9..6d799d30b4b 100644 --- a/app/assets/javascripts/linked_resources/index.js +++ b/app/assets/javascripts/linked_resources/index.js @@ -1,25 +1,34 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import ResourceLinksBlock from 'ee_component/linked_resources/components/resource_links_block.vue'; +import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; +Vue.use(VueApollo); + export default function initLinkedResources() { const linkedResourcesRootElement = document.querySelector('.js-linked-resources-root'); if (linkedResourcesRootElement) { const { issuableId, canAddResourceLinks, helpPath } = linkedResourcesRootElement.dataset; + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + // eslint-disable-next-line no-new new Vue({ el: linkedResourcesRootElement, name: 'LinkedResourcesRoot', + apolloProvider, components: { resourceLinksBlock: ResourceLinksBlock, }, render: (createElement) => createElement('resource-links-block', { props: { - issuableId, helpPath, + issuableId: parseInt(issuableId, 10), canAddResourceLinks: parseBoolean(canAddResourceLinks), }, }), diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 349a28ace52..c16ed68096d 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -134,12 +134,6 @@ function deferredInitialisation() { // Adding a helper class to activate animations only after all is rendered setTimeout(() => $body.addClass('page-initialised'), 1000); - - if (window.gon?.features?.mrAttentionRequests) { - import('~/attention_requests') - .then((module) => module.default()) - .catch(() => {}); - } } // header search vue component bootstrap diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue index 971b1a8435e..ecc2ed82ad0 100644 --- a/app/assets/javascripts/members/components/table/member_action_buttons.vue +++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue @@ -1,5 +1,5 @@ <script> -import { MEMBER_TYPES } from '../../constants'; +import { MEMBER_TYPES, EE_ACTION_BUTTONS } from 'ee_else_ce/members/constants'; import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue'; import GroupActionButtons from '../action_buttons/group_action_buttons.vue'; import InviteActionButtons from '../action_buttons/invite_action_buttons.vue'; @@ -12,6 +12,8 @@ export default { GroupActionButtons, InviteActionButtons, AccessRequestActionButtons, + BannedActionButtons: () => + import('ee_component/members/components/action_buttons/banned_action_buttons.vue'), }, props: { member: { @@ -42,6 +44,7 @@ export default { [MEMBER_TYPES.group]: 'group-action-buttons', [MEMBER_TYPES.invite]: 'invite-action-buttons', [MEMBER_TYPES.accessRequest]: 'access-request-action-buttons', + ...EE_ACTION_BUTTONS, }; return dictionary[this.memberType]; diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 2fe816c7ea2..93d113d1afe 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -9,6 +9,8 @@ export const EE_APP_OPTIONS = {}; // Overridden in EE export const EE_TABS = []; +export const EE_ACTION_BUTTONS = {}; + export const FIELD_KEY_ACCOUNT = 'account'; export const FIELD_KEY_SOURCE = 'source'; export const FIELD_KEY_GRANTED = 'granted'; diff --git a/app/assets/javascripts/merge_conflicts/utils.js b/app/assets/javascripts/merge_conflicts/utils.js index cf7a7c304e3..ca0649cc048 100644 --- a/app/assets/javascripts/merge_conflicts/utils.js +++ b/app/assets/javascripts/merge_conflicts/utils.js @@ -61,7 +61,7 @@ export const decorateLineForInlineView = (line, id, conflict) => { }; export const getLineForParallelView = (line, id, lineType, isHead) => { - const { old_line, new_line, rich_text } = line; + const { old_line: oldLine, new_line: newLine, rich_text: richText } = line; const hasConflict = lineType === 'conflict'; return { @@ -71,10 +71,9 @@ export const getLineForParallelView = (line, id, lineType, isHead) => { isHead: hasConflict && isHead, isOrigin: hasConflict && !isHead, hasMatch: lineType === 'match', - // eslint-disable-next-line camelcase - lineNumber: isHead ? new_line : old_line, + lineNumber: isHead ? newLine : oldLine, section: isHead ? 'head' : 'origin', - richText: rich_text, + richText, isSelected: false, isUnselected: false, }; diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index a95b143920b..b74da3ee89b 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -237,10 +237,10 @@ export default { recentDeployments() { return this.deploymentData.reduce((acc, deployment) => { if (deployment.created_at >= this.earliestDatapoint) { - const { id, created_at, sha, ref, tag } = deployment; + const { id, created_at: createdAt, sha, ref, tag } = deployment; acc.push({ id, - createdAt: created_at, + createdAt, sha, commitUrl: `${this.projectPath}/-/commit/${sha}`, tag, diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 7f75a501635..02a2435d575 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -16,8 +16,8 @@ export const gqClient = createGqClient( ); /** - * Metrics loaded from project-defined dashboards do not have a metric_id. - * This method creates a unique ID combining metric_id and id, if either is present. + * Metrics loaded from project-defined dashboards do not have a metricId. + * This method creates a unique ID combining metricId and id, if either is present. * This is hopefully a temporary solution until BE processes metrics before passing to FE * * Related: @@ -25,12 +25,11 @@ export const gqClient = createGqClient( * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27447 * * @param {Object} metric - metric - * @param {Number} metric.metric_id - Database metric id + * @param {Number} metric.metricId - Database metric id * @param {String} metric.id - User-defined identifier * @returns {Object} - normalized metric with a uniqueID */ -// eslint-disable-next-line camelcase -export const uniqMetricsId = ({ metric_id, id }) => `${metric_id || NOT_IN_DB_PREFIX}_${id}`; +export const uniqMetricsId = ({ metricId, id }) => `${metricId || NOT_IN_DB_PREFIX}_${id}`; /** * Project path has a leading slash that doesn't work well @@ -100,19 +99,28 @@ export const parseAnnotationsResponse = (response) => { * @returns {Object} */ const mapToMetricsViewModel = (metrics) => - metrics.map(({ label, id, metric_id, query_range, prometheus_endpoint_path, ...metric }) => ({ - label, - queryRange: query_range, - prometheusEndpointPath: prometheus_endpoint_path, - metricId: uniqMetricsId({ metric_id, id }), + metrics.map( + ({ + label, + id, + metric_id: metricId, + query_range: queryRange, + prometheus_endpoint_path: prometheusEndpointPath, + ...metric + }) => ({ + label, + queryRange, + prometheusEndpointPath, + metricId: uniqMetricsId({ metricId, id }), - // metric data - loading: false, - result: null, - state: null, + // metric data + loading: false, + result: null, + state: null, - ...metric, - })); + ...metric, + }), + ); /** * Maps X-axis view model @@ -169,26 +177,26 @@ export const mapPanelToViewModel = ({ id = null, title = '', type, - x_axis = {}, // eslint-disable-line camelcase - x_label, - y_label, - y_axis = {}, // eslint-disable-line camelcase + x_axis: xAxisBase = {}, + x_label: xLabel, + y_label: yLabel, + y_axis: yAxisBase = {}, field, metrics = [], links = [], - min_value, - max_value, + min_value: minValue, + max_value: maxValue, split, thresholds, format, }) => { // Both `x_axis.name` and `x_label` are supported for now // https://gitlab.com/gitlab-org/gitlab/issues/210521 - const xAxis = mapXAxisToViewModel({ name: x_label, ...x_axis }); // eslint-disable-line camelcase + const xAxis = mapXAxisToViewModel({ name: xLabel, ...xAxisBase }); // Both `y_axis.name` and `y_label` are supported for now // https://gitlab.com/gitlab-org/gitlab/issues/208385 - const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line camelcase + const yAxis = mapYAxisToViewModel({ name: yLabel, ...yAxisBase }); return { id, @@ -199,8 +207,8 @@ export const mapPanelToViewModel = ({ yAxis, xAxis, field, - minValue: min_value, - maxValue: max_value, + minValue, + maxValue, split, thresholds, format, @@ -295,13 +303,13 @@ export const mapToDashboardViewModel = ({ dashboard = '', templating = {}, links = [], - panel_groups = [], // eslint-disable-line camelcase + panel_groups: panelGroups = [], }) => { return { dashboard, variables: mergeURLVariables(parseTemplatingVariables(templating.variables)), links: links.map(mapLinksToViewModel), - panelGroups: panel_groups.map(mapToPanelGroupViewModel), + panelGroups: panelGroups.map(mapToPanelGroupViewModel), }; }; diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index d85fd10be45..cf24d18c7b6 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -44,7 +44,13 @@ export default () => { }, watch: { discussionTabCounter() { - this.updateDiscussionTabCounter(); + if (window.gon?.features?.paginatedMrDiscussions) { + if (this.$store.state.notes.doneFetchingBatchDiscussions) { + this.updateDiscussionTabCounter(); + } + } else { + this.updateDiscussionTabCounter(); + } }, isShowTabActive: { handler(newVal) { diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 073b27605bb..8351ae7ced6 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -1,6 +1,6 @@ <script> import katex from 'katex'; -import marked from 'marked'; +import { marked } from 'marked'; import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { sanitize } from '~/lib/dompurify'; import { hasContent, markdownConfig } from '~/lib/utils/text_utility'; diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index 0e213028c7c..3cf47f42e0c 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -1,19 +1,17 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlSafeHtmlDirective as SafeHtml, GlAvatar, GlAvatarLink } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions } from 'vuex'; - import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; - -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import noteEditedText from './note_edited_text.vue'; import noteHeader from './note_header.vue'; export default { name: 'DiffDiscussionHeader', components: { - userAvatarLink, + GlAvatar, + GlAvatarLink, noteEditedText, noteHeader, }, @@ -86,6 +84,9 @@ export default { return sprintf(text, { commitDisplay, linkStart, linkEnd }, false); }, + adaptiveAvatarSize() { + return { default: 24, md: 32 }; + }, }, methods: { ...mapActions(['toggleDiscussion']), @@ -100,16 +101,11 @@ export default { <div class="discussion-header gl-display-flex gl-align-items-center gl-p-5"> <div v-once - class="timeline-icon gl-align-self-start gl-flex-shrink-0 gl-flex-shrink gl-ml-3 gl-mr-4" + class="timeline-icon gl-align-self-start gl-flex-shrink-0 gl-flex-shrink gl-mx-3 gl-md-ml-2 gl-md-mr-5" > - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="24" - :img-css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (https://gitlab.com/groups/gitlab-org/-/epics/7731) */" - /> + <gl-avatar-link v-if="author" :href="author.path"> + <gl-avatar :src="author.avatar_url" :alt="author.name" :size="adaptiveAvatarSize" /> + </gl-avatar-link> </div> <div class="timeline-content w-100"> <note-header @@ -127,14 +123,14 @@ export default { :edited-at="discussion.resolved_at" :edited-by="discussion.resolved_by" :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline" + class-name="discussion-headline-light js-discussion-headline gl-pl-2" /> <note-edited-text v-else-if="lastUpdatedAt" :edited-at="lastUpdatedAt" :edited-by="lastUpdatedBy" :action-text="__('Last updated')" - class-name="discussion-headline-light js-discussion-headline" + class-name="discussion-headline-light js-discussion-headline gl-pl-2" /> </div> </div> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 10e3f57a56d..c7f293a219a 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -170,7 +170,7 @@ export default { return this.targetType === 'issue'; }, canAssign() { - return this.getNoteableData.current_user?.can_update && this.isIssue; + return this.getNoteableData.current_user?.can_set_issue_metadata && this.isIssue; }, displayAuthorBadgeText() { return sprintf(__('This user is the author of this %{noteable}.'), { diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index cc74c2ee605..f1c41eea428 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -8,7 +8,6 @@ import { __ } from '~/locale'; import '~/behaviors/markdown/render_gfm'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import autosave from '../mixins/autosave'; -import { INTERNAL_NOTE_CLASSES } from '../constants'; import noteAttachment from './note_attachment.vue'; import noteAwardsList from './note_awards_list.vue'; import noteEditedText from './note_edited_text.vue'; @@ -55,11 +54,6 @@ export default { required: false, default: '', }, - isInternalNote: { - type: Boolean, - required: false, - default: false, - }, }, computed: { ...mapGetters(['getDiscussion', 'suggestionsCount', 'getSuggestionsFilePaths']), @@ -101,12 +95,6 @@ export default { return escape(suggestion); }, - internalNoteContainerClasses() { - if (this.isInternalNote && !this.isEditing) { - return INTERNAL_NOTE_CLASSES; - } - return ''; - }, }, mounted() { this.renderGFM(); @@ -179,54 +167,52 @@ export default { }" class="note-body" > - <div :class="internalNoteContainerClasses" data-testid="note-internal-container"> - <suggestions - v-if="hasSuggestion && !isEditing" - :suggestions="note.suggestions" - :suggestions-count="suggestionsCount" - :batch-suggestions-info="batchSuggestionsInfo" - :note-html="note.note_html" - :line-type="lineType" - :help-page-path="helpPagePath" - :default-commit-message="commitMessage" - :failed-to-load-metadata="failedToLoadMetadata" - @apply="applySuggestion" - @applyBatch="applySuggestionBatch" - @addToBatch="addSuggestionToBatch" - @removeFromBatch="removeSuggestionFromBatch" - /> - <div v-else v-safe-html:[$options.safeHtmlConfig]="note.note_html" class="note-text md"></div> - <note-form - v-if="isEditing" - ref="noteForm" - :note-body="noteBody" - :note-id="note.id" - :line="line" - :note="note" - :save-button-title="saveButtonTitle" - :help-page-path="helpPagePath" - :discussion="discussion" - :resolve-discussion="note.resolve_discussion" - @handleFormUpdate="handleFormUpdate" - @cancelForm="formCancelHandler" - /> - <!-- eslint-disable vue/no-mutating-props --> - <textarea - v-if="canEdit" - v-model="note.note" - :data-update-url="note.path" - class="hidden js-task-list-field" - dir="auto" - ></textarea> - <!-- eslint-enable vue/no-mutating-props --> - <note-edited-text - v-if="note.last_edited_at" - :edited-at="note.last_edited_at" - :edited-by="note.last_edited_by" - action-text="Edited" - class="note_edited_ago" - /> - </div> + <suggestions + v-if="hasSuggestion && !isEditing" + :suggestions="note.suggestions" + :suggestions-count="suggestionsCount" + :batch-suggestions-info="batchSuggestionsInfo" + :note-html="note.note_html" + :line-type="lineType" + :help-page-path="helpPagePath" + :default-commit-message="commitMessage" + :failed-to-load-metadata="failedToLoadMetadata" + @apply="applySuggestion" + @applyBatch="applySuggestionBatch" + @addToBatch="addSuggestionToBatch" + @removeFromBatch="removeSuggestionFromBatch" + /> + <div v-else v-safe-html:[$options.safeHtmlConfig]="note.note_html" class="note-text md"></div> + <note-form + v-if="isEditing" + ref="noteForm" + :note-body="noteBody" + :note-id="note.id" + :line="line" + :note="note" + :save-button-title="saveButtonTitle" + :help-page-path="helpPagePath" + :discussion="discussion" + :resolve-discussion="note.resolve_discussion" + @handleFormUpdate="handleFormUpdate" + @cancelForm="formCancelHandler" + /> + <!-- eslint-disable vue/no-mutating-props --> + <textarea + v-if="canEdit" + v-model="note.note" + :data-update-url="note.path" + class="hidden js-task-list-field" + dir="auto" + ></textarea> + <!-- eslint-enable vue/no-mutating-props --> + <note-edited-text + v-if="note.last_edited_at" + :edited-at="note.last_edited_at" + :edited-by="note.last_edited_by" + action-text="Edited" + class="note_edited_ago" + /> <note-awards-list v-if="note.award_emoji && note.award_emoji.length" :note-id="note.id" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index a4cd20e6db8..30579a8eb0d 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -251,8 +251,10 @@ export default { } }, cancelHandler(shouldConfirm = false) { - // Sends information about confirm message and if the textarea has changed - this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody); + // check if any dropdowns are active before sending the cancelation event + if (!this.$refs.textarea.classList.contains('at-who-active')) { + this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody); + } }, onInput() { if (this.isSubmittingWithKeydown) { diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 095ab5ddb0f..875cfff74fe 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -406,7 +406,7 @@ export default { <template> <timeline-entry-item :id="noteAnchorId" - :class="classNameBindings" + :class="{ ...classNameBindings, 'internal-note': note.confidential }" :data-award-url="note.toggle_award_path" :data-note-id="note.id" class="note note-wrapper" @@ -506,7 +506,6 @@ export default { ref="noteBody" :note="note" :can-edit="note.current_user.can_edit" - :is-internal-note="note.confidential" :line="line" :file="diffFile" :is-editing="isEditing" diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 3317f4e2383..a5f459c8910 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -51,5 +51,3 @@ export const toggleStateErrorMessage = { [REOPENED]: __('Something went wrong while closing the merge request. Please try again later.'), }, }; - -export const INTERNAL_NOTE_CLASSES = ['gl-bg-orange-50', 'gl-px-4', 'gl-py-2']; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 39d0a46d6d0..0823eacf1b7 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -7,7 +7,7 @@ import * as utils from './utils'; export default { [types.ADD_NEW_NOTE](state, data) { const note = data.discussion ? data.discussion.notes[0] : data; - const { discussion_id, type } = note; + const { discussion_id: discussionId, type } = note; const [exists] = state.discussions.filter((n) => n.id === note.discussion_id); const isDiscussion = type === constants.DISCUSSION_NOTE || type === constants.DIFF_NOTE; @@ -17,9 +17,9 @@ export default { if (!discussion) { discussion = { expanded: true, - id: discussion_id, + id: discussionId, individual_note: !isDiscussion, - reply_id: discussion_id, + reply_id: discussionId, }; if (isDiscussion && isInMRPage()) { diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js index ec18a570960..14e97fcef46 100644 --- a/app/assets/javascripts/notes/utils.js +++ b/app/assets/javascripts/notes/utils.js @@ -1,5 +1,5 @@ /* eslint-disable @gitlab/require-i18n-strings */ -import marked from 'marked'; +import { marked } from 'marked'; import { sanitize } from '~/lib/dompurify'; import { markdownConfig } from '~/lib/utils/text_utility'; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue index bfa99c01c3f..ce221a274c9 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue @@ -60,7 +60,7 @@ export default { return this.$options.i18n[`CLEANUP_STATUS_${this.status}`]; }, calculatedTimeTilNextRun() { - return timeTilRun(this.expirationPolicy?.next_run); + return timeTilRun(this.expirationPolicy?.next_run_at); }, expireIconName() { return this.failedDelete ? 'expire' : 'clock'; @@ -90,9 +90,9 @@ export default { {{ statusText }} </span> <gl-icon - v-if="failedDelete" + v-if="failedDelete && calculatedTimeTilNextRun" :id="iconId" - :size="14" + :size="16" class="gl-text-gray-500" data-testid="extra-info" name="information-o" diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue index aecc0bf92ea..80bca536b7c 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue @@ -95,7 +95,7 @@ export default { if (this.showFullPath) { return this.item.path; } - const projectPath = this.item?.project?.path ?? ''; + const projectPath = this.item?.project?.path?.toLowerCase() ?? ''; if (this.item.name) { return joinPaths(projectPath, this.item.name); } diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index 1faff1ff4de..45dc217b9e3 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -39,7 +39,7 @@ export default { directives: { GlModalDirective, }, - inject: ['groupPath', 'groupId', 'noManifestsIllustration'], + inject: ['groupPath', 'groupId', 'noManifestsIllustration', 'canClearCache'], i18n: { proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'), copyImagePrefixText: s__('DependencyProxy|Copy prefix'), @@ -114,7 +114,7 @@ export default { ); }, showDeleteDropdown() { - return this.group.dependencyProxyManifests?.nodes.length > 0; + return this.group.dependencyProxyManifests?.nodes.length > 0 && this.canClearCache; }, showDependencyProxyImagePrefix() { return this.group.dependencyProxyImagePrefix?.length > 0; diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js index 14789aafdb7..428d6d6cd75 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js @@ -1,4 +1,5 @@ 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'; @@ -10,12 +11,15 @@ export const initDependencyProxyApp = () => { if (!el) { return null; } - const { ...dataset } = el.dataset; + const { groupPath, groupId, noManifestsIllustration, canClearCache } = el.dataset; return new Vue({ el, apolloProvider, provide: { - ...dataset, + groupPath, + groupId, + noManifestsIllustration, + canClearCache: parseBoolean(canClearCache), }, render(createElement) { return createElement(app); diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js index 408d34fbe93..51a38c434cb 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js @@ -26,8 +26,7 @@ export const receivePackagesListSuccess = ({ commit }, { data, headers }) => { export const requestPackagesList = ({ dispatch, state }, params = {}) => { dispatch('setLoading', true); - // eslint-disable-next-line camelcase - const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params; + const { page = DEFAULT_PAGE, per_page: perPage = DEFAULT_PAGE_SIZE } = params; const { sort, orderBy } = state.sorting; const type = state.config.forceTerraform ? TERRAFORM_SEARCH_TYPE @@ -38,7 +37,7 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => { const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages'; return Api[apiMethod](state.config.resourceId, { - params: { page, per_page, sort, order_by: orderBy, ...packageFilters }, + params: { page, per_page: perPage, sort, order_by: orderBy, ...packageFilters }, }) .then(({ data, headers }) => { dispatch('receivePackagesListSuccess', { data, headers }); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue index a049b0eff8d..b872294d2cf 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue @@ -1,14 +1,16 @@ <script> -import { GlLink, GlTableLite, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@gitlab/ui'; +import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlButton, GlFormCheckbox } from '@gitlab/ui'; import { last } from 'lodash'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue'; import Tracking from '~/tracking'; import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { + REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION, + SELECT_PACKAGE_FILE_TRACKING_ACTION, TRACKING_LABEL_PACKAGE_ASSET, TRACKING_ACTION_EXPAND_PACKAGE_ASSET, } from '~/packages_and_registries/package_registry/constants'; @@ -17,10 +19,10 @@ export default { name: 'PackageFiles', components: { GlLink, - GlTableLite, - GlIcon, + GlTable, GlDropdown, GlDropdownItem, + GlFormCheckbox, GlButton, FileIcon, TimeAgoTooltip, @@ -33,13 +35,29 @@ export default { required: false, default: false, }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, packageFiles: { type: Array, required: false, default: () => [], }, }, + data() { + return { + selectedReferences: [], + }; + }, computed: { + areFilesSelected() { + return this.selectedReferences.length > 0; + }, + areAllFilesSelected() { + return this.packageFiles.every(this.isSelected); + }, filesTableRows() { return this.packageFiles.map((pf) => ({ ...pf, @@ -47,6 +65,9 @@ export default { pipeline: last(pf.pipelines), })); }, + hasSelectedSomeFiles() { + return this.areFilesSelected && !this.areAllFilesSelected; + }, showCommitColumn() { // note that this is always false for now since we do not return // pipelines associated to files for performance concerns @@ -55,6 +76,12 @@ export default { filesTableHeaderFields() { return [ { + key: 'checkbox', + label: __('Select all'), + class: 'gl-w-4', + hide: !this.canDelete, + }, + { key: 'name', label: __('Name'), }, @@ -77,7 +104,7 @@ export default { label: '', hide: !this.canDelete, class: 'gl-text-right', - tdClass: 'gl-w-4', + tdClass: 'gl-w-4 gl-pt-3!', }, ].filter((c) => !c.hide); }, @@ -99,21 +126,71 @@ export default { this.track(TRACKING_ACTION_EXPAND_PACKAGE_ASSET, { label: TRACKING_LABEL_PACKAGE_ASSET }); } }, + updateSelectedReferences(selection) { + this.track(SELECT_PACKAGE_FILE_TRACKING_ACTION); + this.selectedReferences = selection; + }, + isSelected(packageFile) { + return this.selectedReferences.find((reference) => reference.id === packageFile.id); + }, + handleFileDeleteSelected() { + this.track(REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION); + this.$emit('delete-files', this.selectedReferences); + }, }, i18n: { deleteFile: __('Delete file'), + deleteSelected: s__('PackageRegistry|Delete selected'), + moreActionsText: __('More actions'), }, }; </script> <template> - <div> - <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> - <gl-table-lite + <div class="gl-pt-6"> + <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> + <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> + <gl-button + v-if="canDelete" + :disabled="isLoading || !areFilesSelected" + category="secondary" + variant="danger" + data-testid="delete-selected" + @click="handleFileDeleteSelected" + > + {{ $options.i18n.deleteSelected }} + </gl-button> + </div> + <gl-table :fields="filesTableHeaderFields" :items="filesTableRows" + show-empty + selectable + select-mode="multi" + selected-variant="primary" :tbody-tr-attr="{ 'data-testid': 'file-row' }" + @row-selected="updateSelectedReferences" > + <template #head(checkbox)="{ selectAllRows, clearSelected }"> + <gl-form-checkbox + v-if="canDelete" + data-testid="package-files-checkbox-all" + :checked="areAllFilesSelected" + :indeterminate="hasSelectedSomeFiles" + @change="areAllFilesSelected ? clearSelected() : selectAllRows()" + /> + </template> + + <template #cell(checkbox)="{ rowSelected, selectRow, unselectRow }"> + <gl-form-checkbox + v-if="canDelete" + class="gl-mt-1" + :checked="rowSelected" + data-testid="package-files-checkbox" + @change="rowSelected ? unselectRow() : selectRow()" + /> + </template> + <template #cell(name)="{ item, toggleDetails, detailsShowing }"> <gl-button v-if="hasDetails(item)" @@ -156,11 +233,15 @@ export default { </template> <template #cell(actions)="{ item }"> - <gl-dropdown category="tertiary" right> - <template #button-content> - <gl-icon name="ellipsis_v" /> - </template> - <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-file', item)"> + <gl-dropdown + category="tertiary" + icon="ellipsis_v" + :text-sr-only="true" + :text="$options.i18n.moreActionsText" + no-caret + right + > + <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-files', [item])"> {{ $options.i18n.deleteFile }} </gl-dropdown-item> </gl-dropdown> @@ -180,6 +261,6 @@ export default { <file-sha v-if="item.fileSha1" data-testid="sha-1" title="SHA-1" :sha="item.fileSha1" /> </div> </template> - </gl-table-lite> + </gl-table> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue index 96b82a20364..a1fc7563de1 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 @@ -5,12 +5,17 @@ import { first } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, n__ } from '~/locale'; +import Tracking from '~/tracking'; +import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; 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'; import { GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE, FETCH_PACKAGE_PIPELINES_ERROR_MESSAGE, + TRACKING_ACTION_CLICK_PIPELINE_LINK, + TRACKING_ACTION_CLICK_COMMIT_LINK, + TRACKING_LABEL_PACKAGE_HISTORY, } from '../../constants'; import getPackagePipelinesQuery from '../../graphql/queries/get_package_pipelines.query.graphql'; import PackageHistoryLoader from './package_history_loader.vue'; @@ -37,6 +42,9 @@ export default { PackageHistoryLoader, TimeAgoTooltip, }, + mixins: [Tracking.mixin()], + TRACKING_ACTION_CLICK_PIPELINE_LINK, + TRACKING_ACTION_CLICK_COMMIT_LINK, props: { packageEntity: { type: Object, @@ -97,6 +105,11 @@ export default { first: GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE, }; }, + tracking() { + return { + category: packageTypeToTrackCategory(this.packageType), + }; + }, }, methods: { truncate(value) { @@ -105,6 +118,12 @@ export default { convertToBaseId(value) { return getIdFromGraphQLId(value); }, + trackPipelineClick() { + this.track(TRACKING_ACTION_CLICK_PIPELINE_LINK, { label: TRACKING_LABEL_PACKAGE_HISTORY }); + }, + trackCommitClick() { + this.track(TRACKING_ACTION_CLICK_COMMIT_LINK, { label: TRACKING_LABEL_PACKAGE_HISTORY }); + }, }, }; </script> @@ -140,7 +159,9 @@ export default { <history-item icon="commit" data-testid="first-pipeline-commit"> <gl-sprintf :message="$options.i18n.createdByCommitText"> <template #link> - <gl-link :href="firstPipeline.commitPath">#{{ truncate(firstPipeline.sha) }}</gl-link> + <gl-link :href="firstPipeline.commitPath" @click="trackCommitClick" + >#{{ truncate(firstPipeline.sha) }}</gl-link + > </template> <template #branch> <strong>{{ firstPipeline.ref }}</strong> @@ -150,7 +171,9 @@ export default { <history-item icon="pipeline" data-testid="first-pipeline-pipeline"> <gl-sprintf :message="$options.i18n.createdByPipelineText"> <template #link> - <gl-link :href="firstPipeline.path">#{{ convertToBaseId(firstPipeline.id) }}</gl-link> + <gl-link :href="firstPipeline.path" @click="trackPipelineClick" + >#{{ convertToBaseId(firstPipeline.id) }}</gl-link + > </template> <template #datetime> <time-ago-tooltip :time="firstPipeline.createdAt" /> @@ -189,13 +212,17 @@ export default { > <gl-sprintf :message="$options.i18n.combinedUpdateText"> <template #link> - <gl-link :href="pipeline.commitPath">#{{ truncate(pipeline.sha) }}</gl-link> + <gl-link :href="pipeline.commitPath" @click="trackCommitClick" + >#{{ truncate(pipeline.sha) }}</gl-link + > </template> <template #branch> <strong>{{ pipeline.ref }}</strong> </template> <template #pipeline> - <gl-link :href="pipeline.path">#{{ convertToBaseId(pipeline.id) }}</gl-link> + <gl-link :href="pipeline.path" @click="trackPipelineClick" + >#{{ convertToBaseId(pipeline.id) }}</gl-link + > </template> <template #datetime> <time-ago-tooltip :time="pipeline.createdAt" /> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue index f5946797626..11fd0db3106 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue @@ -23,6 +23,7 @@ export default { directives: { GlResizeObserver: GlResizeObserverDirective, }, + inject: ['isGroupPage'], i18n: { packageInfo: __('v%{version} published %{timeAgo}'), }, @@ -65,9 +66,6 @@ export default { this.checkBreakpoints(); }, methods: { - dynamicSlotName(index) { - return `metadata-tag${index}`; - }, checkBreakpoints() { this.isDesktop = GlBreakpointInstance.isDesktop(); }, @@ -83,21 +81,38 @@ export default { data-qa-selector="package_title" > <template #sub-header> - <span data-testid="sub-header"> + <div data-testid="sub-header" class="gl-display-flex gl-gap-3"> <gl-sprintf :message="$options.i18n.packageInfo"> <template #version> {{ packageEntity.version }} </template> <template #timeAgo> - <time-ago-tooltip - v-if="packageEntity.createdAt" - class="gl-ml-2" - :time="packageEntity.createdAt" - /> + <time-ago-tooltip v-if="packageEntity.createdAt" :time="packageEntity.createdAt" /> </template> </gl-sprintf> - </span> + + <package-tags + v-if="isDesktop && hasTagsToDisplay" + :tag-display-limit="2" + :tags="packageEntity.tags.nodes" + hide-label + /> + + <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap --> + <template v-else-if="hasTagsToDisplay"> + <gl-badge + v-for="(tag, index) in packageEntity.tags.nodes" + :key="index" + class="gl-my-1" + data-testid="tag-badge" + variant="info" + size="sm" + > + {{ tag.name }} + </gl-badge> + </template> + </div> </template> <template v-if="packageTypeDisplay" #metadata-type> @@ -108,7 +123,7 @@ export default { <metadata-item data-testid="package-size" icon="disk" :text="totalSize" /> </template> - <template v-if="packagePipeline" #metadata-pipeline> + <template v-if="isGroupPage && packagePipeline" #metadata-pipeline> <metadata-item data-testid="pipeline-project" icon="review-list" @@ -121,21 +136,6 @@ export default { <metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" /> </template> - <template v-if="isDesktop && hasTagsToDisplay" #metadata-tags> - <package-tags :tag-display-limit="2" :tags="packageEntity.tags.nodes" hide-label /> - </template> - - <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap --> - <template - v-for="(tag, index) in packageEntity.tags.nodes" - v-else-if="hasTagsToDisplay" - #[dynamicSlotName(index)] - > - <gl-badge :key="index" class="gl-my-1" data-testid="tag-badge" variant="info" size="sm"> - {{ tag.name }} - </gl-badge> - </template> - <template #right-actions> <slot name="delete-button"></slot> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue index a126d30f1ec..dd58f28a262 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue @@ -1,9 +1,10 @@ <script> -import { GlLink, GlSprintf } from '@gitlab/ui'; +import { GlFormGroup, GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; import { + PERSONAL_ACCESS_TOKEN_HELP_URL, TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND, TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, TRACKING_LABEL_CODE_INSTRUCTION, @@ -16,6 +17,7 @@ export default { components: { InstallationTitle, CodeInstruction, + GlFormGroup, GlLink, GlSprintf, }, @@ -43,6 +45,7 @@ password = <your personal access token>`; TRACKING_LABEL_CODE_INSTRUCTION, }, i18n: { + tokenText: s__(`PackageRegistry|You will need a %{linkStart}personal access token%{linkEnd}.`), setupText: s__( `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file.`, ), @@ -50,7 +53,10 @@ password = <your personal access token>`; 'PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}.', ), }, - links: { PYPI_HELP_PATH }, + links: { + PERSONAL_ACCESS_TOKEN_HELP_URL, + PYPI_HELP_PATH, + }, installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }], }; </script> @@ -59,14 +65,28 @@ password = <your personal access token>`; <div> <installation-title package-type="pypi" :options="$options.installOptions" /> - <code-instruction - :label="s__('PackageRegistry|Pip Command')" - :instruction="pypiPipCommand" - :copy-text="s__('PackageRegistry|Copy Pip command')" - data-testid="pip-command" - :tracking-action="$options.tracking.TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND" - :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" - /> + <gl-form-group id="installation-pip-command-group"> + <code-instruction + id="installation-pip-command" + :label="s__('PackageRegistry|Pip Command')" + :instruction="pypiPipCommand" + :copy-text="s__('PackageRegistry|Copy Pip command')" + data-testid="pip-command" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + <template #description> + <gl-sprintf :message="$options.i18n.tokenText"> + <template #link="{ content }"> + <gl-link + :href="$options.links.PERSONAL_ACCESS_TOKEN_HELP_URL" + data-testid="access-token-link" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </template> + </gl-form-group> <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> <p> @@ -87,7 +107,12 @@ password = <your personal access token>`; /> <gl-sprintf :message="$options.i18n.helpText"> <template #link="{ content }"> - <gl-link :href="$options.links.PYPI_HELP_PATH" target="_blank">{{ content }}</gl-link> + <gl-link + :href="$options.links.PYPI_HELP_PATH" + target="_blank" + data-testid="pypi-docs-link" + >{{ content }}</gl-link + > </template> </gl-sprintf> </div> 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 cea053992f8..5b2a347a4ee 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -7,9 +7,12 @@ export { CANCEL_DELETE_PACKAGE_TRACKING_ACTION, PULL_PACKAGE_TRACKING_ACTION, DELETE_PACKAGE_FILE_TRACKING_ACTION, + DELETE_PACKAGE_FILES_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, + REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, + SELECT_PACKAGE_FILE_TRACKING_ACTION, } from '~/packages_and_registries/shared/constants'; export const PACKAGE_TYPE_CONAN = 'CONAN'; @@ -69,6 +72,11 @@ export const TRACKING_ACTION_DOWNLOAD_PACKAGE_ASSET = 'download_package_asset'; export const TRACKING_ACTION_EXPAND_PACKAGE_ASSET = 'expand_package_asset'; export const TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA = 'copy_package_asset_sha'; +export const TRACKING_ACTION_CLICK_PIPELINE_LINK = 'click_pipeline_link_from_package'; +export const TRACKING_ACTION_CLICK_COMMIT_LINK = 'click_commit_link_from_package'; + +export const TRACKING_LABEL_PACKAGE_HISTORY = 'package_history'; + export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting the package file.', @@ -76,6 +84,12 @@ export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( 'PackageRegistry|Package file deleted successfully', ); +export const DELETE_PACKAGE_FILES_ERROR_MESSAGE = s__( + 'PackageRegistry|Something went wrong while deleting the package assets.', +); +export const DELETE_PACKAGE_FILES_SUCCESS_MESSAGE = s__( + 'PackageRegistry|Package assets deleted successfully', +); export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__( 'PackageRegistry|Failed to load the package data', ); @@ -162,5 +176,6 @@ export const CONAN_HELP_PATH = helpPagePath('user/packages/conan_repository/inde export const NUGET_HELP_PATH = helpPagePath('user/packages/nuget_repository/index'); export const PYPI_HELP_PATH = helpPagePath('user/packages/pypi_repository/index'); export const COMPOSER_HELP_PATH = helpPagePath('user/packages/composer_repository/index'); +export const PERSONAL_ACCESS_TOKEN_HELP_URL = helpPagePath('user/profile/personal_access_tokens'); export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql deleted file mode 100644 index f016640f57d..00000000000 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation destroyPackageFile($id: PackagesPackageFileID!) { - destroyPackageFile(input: { id: $id }) { - errors - } -} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql new file mode 100644 index 00000000000..8f9a3156492 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql @@ -0,0 +1,5 @@ +mutation destroyPackageFiles($projectPath: ID!, $ids: [PackagesPackageFileID!]!) { + destroyPackageFiles(input: { projectPath: $projectPath, ids: $ids }) { + errors + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index 5574020c9e4..f3f0d096d10 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -20,6 +20,7 @@ query getPackageDetails($id: PackagesPackageID!) { id path name + fullPath } tags(first: 10) { nodes { @@ -39,6 +40,9 @@ query getPackageDetails($id: PackagesPackageID!) { } } packageFiles(first: 100) { + pageInfo { + hasNextPage + } nodes { id fileMd5 diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index 29438fba86b..e83962bb608 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -34,16 +34,19 @@ import { REQUEST_DELETE_PACKAGE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_TRACKING_ACTION, DELETE_PACKAGE_FILE_TRACKING_ACTION, + DELETE_PACKAGE_FILES_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, SHOW_DELETE_SUCCESS_ALERT, FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + DELETE_PACKAGE_FILES_ERROR_MESSAGE, + DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION, } from '~/packages_and_registries/package_registry/constants'; -import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql'; +import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql'; import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql'; import Tracking from '~/tracking'; @@ -83,7 +86,8 @@ export default { }, data() { return { - fileToDelete: null, + filesToDelete: [], + mutationLoading: false, packageEntity: {}, }; }, @@ -114,6 +118,9 @@ export default { projectName() { return this.packageEntity.project?.name; }, + projectPath() { + return this.packageEntity.project?.fullPath; + }, packageId() { return this.$route.params.id; }, @@ -131,6 +138,9 @@ export default { isLoading() { return this.$apollo.queries.packageEntity.loading; }, + packageFilesLoading() { + return this.isLoading || this.mutationLoading; + }, isValidPackage() { return this.isLoading || Boolean(this.packageEntity.name); }, @@ -175,12 +185,14 @@ export default { window.location.replace(`${returnTo}?${modalQuery}`); }, - async deletePackageFile(id) { + async deletePackageFiles(ids) { + this.mutationLoading = true; try { const { data } = await this.$apollo.mutate({ - mutation: destroyPackageFileMutation, + mutation: destroyPackageFilesMutation, variables: { - id, + projectPath: this.projectPath, + ids, }, awaitRefetchQueries: true, refetchQueries: [ @@ -190,31 +202,53 @@ export default { }, ], }); - if (data?.destroyPackageFile?.errors[0]) { - throw data.destroyPackageFile.errors[0]; + if (data?.destroyPackageFiles?.errors[0]) { + throw data.destroyPackageFiles.errors[0]; } createFlash({ - message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + message: + ids.length === 1 + ? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE + : DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, type: 'success', }); } catch (error) { createFlash({ - message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, + message: + ids.length === 1 + ? DELETE_PACKAGE_FILE_ERROR_MESSAGE + : DELETE_PACKAGE_FILES_ERROR_MESSAGE, type: 'warning', captureError: true, error, }); } + this.mutationLoading = false; }, - handleFileDelete(file) { + handleFileDelete(files) { this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION); - this.fileToDelete = { ...file }; - this.$refs.deleteFileModal.show(); + if ( + files.length === this.packageFiles.length && + !this.packageEntity.packageFiles?.pageInfo?.hasNextPage + ) { + this.$refs.deleteModal.show(); + } else { + this.filesToDelete = files; + if (files.length === 1) { + this.$refs.deleteFileModal.show(); + } else if (files.length > 1) { + this.$refs.deleteFilesModal.show(); + } + } }, - confirmFileDelete() { - this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION); - this.deletePackageFile(this.fileToDelete.id); - this.fileToDelete = null; + confirmFilesDelete() { + if (this.filesToDelete.length === 1) { + this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION); + } else { + this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION); + } + this.deletePackageFiles(this.filesToDelete.map((file) => file.id)); + this.filesToDelete = []; }, }, i18n: { @@ -240,6 +274,10 @@ export default { text: __('Delete'), attributes: [{ variant: 'danger' }, { category: 'primary' }], }, + filesDeletePrimaryAction: { + text: s__('PackageRegistry|Permanently delete assets'), + attributes: [{ variant: 'danger' }, { category: 'primary' }], + }, cancelAction: { text: __('Cancel'), }, @@ -287,9 +325,10 @@ export default { <package-files v-if="showFiles" :can-delete="packageEntity.canDestroy" + :is-loading="packageFilesLoading" :package-files="packageFiles" @download-file="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)" - @delete-file="handleFileDelete" + @delete-files="handleFileDelete" /> </gl-tab> @@ -355,15 +394,43 @@ export default { :action-primary="$options.modal.fileDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" data-testid="delete-file-modal" - @primary="confirmFileDelete" + @primary="confirmFilesDelete" @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)" > <template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template> - <gl-sprintf v-if="fileToDelete" :message="$options.i18n.deleteFileModalContent"> + <gl-sprintf v-if="filesToDelete.length === 1" :message="$options.i18n.deleteFileModalContent"> <template #filename> - <strong>{{ fileToDelete.file_name }}</strong> + <strong>{{ filesToDelete[0].fileName }}</strong> </template> </gl-sprintf> </gl-modal> + + <gl-modal + ref="deleteFilesModal" + size="sm" + modal-id="delete-files-modal" + :action-primary="$options.modal.filesDeletePrimaryAction" + :action-cancel="$options.modal.cancelAction" + data-testid="delete-files-modal" + @primary="confirmFilesDelete" + @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)" + > + <template #modal-title>{{ + n__( + `PackageRegistry|Delete 1 asset`, + `PackageRegistry|Delete %d assets`, + filesToDelete.length, + ) + }}</template> + <span v-if="filesToDelete.length > 0"> + {{ + n__( + `PackageRegistry|You are about to delete 1 asset. This operation is irreversible.`, + `PackageRegistry|You are about to delete %d assets. This operation is irreversible.`, + filesToDelete.length, + ) + }} + </span> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue index 90a18d5cf5a..1c44d2bc38b 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue @@ -11,7 +11,7 @@ import { UNAVAILABLE_ADMIN_FEATURE_TEXT, } from '~/packages_and_registries/settings/project/constants'; import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; -import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue'; diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue index 7682754fdcb..f06e3a41bd0 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue @@ -35,22 +35,34 @@ export default { required: false, default: '', }, + dropdownClass: { + type: String, + required: false, + default: '', + }, }, }; </script> <template> <gl-form-group :id="`${name}-form-group`" :label-for="name" :label="label"> - <gl-form-select :id="name" :value="value" :disabled="disabled" @input="$emit('input', $event)"> - <option - v-for="option in formOptions" - :key="option.key" - :value="option.key" - data-testid="option" + <div :class="dropdownClass"> + <gl-form-select + :id="name" + :value="value" + :disabled="disabled" + @input="$emit('input', $event)" > - {{ option.label }} - </option> - </gl-form-select> + <option + v-for="option in formOptions" + :key="option.key" + :value="option.key" + data-testid="option" + > + {{ option.label }} + </option> + </gl-form-select> + </div> <template v-if="description" #description> <span data-testid="description" class="gl-text-gray-400"> {{ description }} diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue index 1170407a349..2f4bc35e5f7 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue @@ -6,7 +6,7 @@ import { PACKAGES_CLEANUP_POLICY_DESCRIPTION, } from '~/packages_and_registries/settings/project/constants'; import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql'; -import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; import PackagesCleanupPolicyForm from './packages_cleanup_policy_form.vue'; diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue index b1751d5174a..f1f0b970b15 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue @@ -3,10 +3,10 @@ import { GlButton } from '@gitlab/ui'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, - SET_CLEANUP_POLICY_BUTTON, KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, + SET_CLEANUP_POLICY_BUTTON, } from '~/packages_and_registries/settings/project/constants'; import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql'; import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils'; @@ -108,18 +108,17 @@ export default { <template> <form ref="form-element" @submit.prevent="submit"> - <div class="gl-md-max-w-50p"> - <expiration-dropdown - v-model="prefilledForm.keepNDuplicatedPackageFiles" - :disabled="isFieldDisabled" - :form-options="$options.formOptions.keepNDuplicatedPackageFiles" - :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL" - :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION" - name="keep-n-duplicated-package-files" - data-testid="keep-n-duplicated-package-files-dropdown" - @input="onModelChange($event, 'keepNDuplicatedPackageFiles')" - /> - </div> + <expiration-dropdown + :value="prefilledForm.keepNDuplicatedPackageFiles" + :disabled="isFieldDisabled" + :form-options="$options.formOptions.keepNDuplicatedPackageFiles" + :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL" + :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION" + dropdown-class="gl-md-max-w-50p gl-sm-pr-5" + name="keep-n-duplicated-package-files" + data-testid="keep-n-duplicated-package-files-dropdown" + @input="onModelChange($event, 'keepNDuplicatedPackageFiles')" + /> <div class="gl-mt-7 gl-display-flex gl-align-items-center"> <gl-button data-testid="save-button" 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 948520151ce..fcb4a8ee297 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -4,7 +4,7 @@ export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up im export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__( `ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`, ); -export const SET_CLEANUP_POLICY_BUTTON = __('Save'); +export const SET_CLEANUP_POLICY_BUTTON = __('Save changes'); export const UNAVAILABLE_FEATURE_TITLE = s__( `ContainerRegistry|Cleanup policy for tags is disabled`, ); diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue index 5caf95cd050..0458b914b58 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue @@ -1,7 +1,7 @@ <template> <section class="settings gl-py-7"> - <div class="gl-lg-display-flex"> - <div class="gl-lg-w-half gl-pr-10"> + <div class="gl-lg-display-flex gl-gap-6"> + <div class="gl-lg-w-40p gl-pr-10 gl-flex-shrink-0"> <h4> <slot name="title"></slot> </h4> @@ -9,7 +9,7 @@ <slot name="description"></slot> </p> </div> - <div class="gl-lg-w-half gl-pt-3"> + <div class="gl-pt-3 gl-flex-grow-1"> <slot></slot> </div> </div> diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js index 5505205cf33..6744e821565 100644 --- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js +++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js @@ -9,7 +9,11 @@ export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package'; export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package'; export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package'; export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file'; +export const DELETE_PACKAGE_FILES_TRACKING_ACTION = 'delete_package_files'; +export const SELECT_PACKAGE_FILE_TRACKING_ACTION = 'select_package_file'; export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file'; +export const REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION = + 'request_delete_selected_package_file'; export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file'; export const DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION = 'download_package_asset'; diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue index 713287f65b4..f01e5e595a3 100644 --- a/app/assets/javascripts/pages/groups/new/components/app.vue +++ b/app/assets/javascripts/pages/groups/new/components/app.vue @@ -2,51 +2,74 @@ import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg'; import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; import createGroupDescriptionDetails from './create_group_description_details.vue'; -const PANELS = [ - { - name: 'create-group-pane', - selector: '#create-group-pane', - title: s__('GroupsNew|Create group'), - description: s__( - 'GroupsNew|Assemble related projects together and grant members access to several projects at once.', - ), - illustration: newGroupIllustration, - details: createGroupDescriptionDetails, - }, - { - name: 'import-group-pane', - selector: '#import-group-pane', - title: s__('GroupsNew|Import group'), - description: s__('GroupsNew|Import a group and related data from another GitLab instance.'), - illustration: importGroupIllustration, - details: 'Migrate your existing groups from another instance of GitLab.', - }, -]; - export default { components: { NewNamespacePage, }, props: { + parentGroupName: { + type: String, + required: false, + default: '', + }, + importExistingGroupPath: { + type: String, + required: false, + default: '', + }, hasErrors: { type: Boolean, required: false, default: false, }, }, - PANELS, + computed: { + initialBreadcrumb() { + return this.parentGroupName || __('New group'); + }, + panels() { + return [ + { + name: 'create-group-pane', + selector: '#create-group-pane', + title: this.parentGroupName + ? s__('GroupsNew|Create subgroup') + : s__('GroupsNew|Create group'), + description: s__( + 'GroupsNew|Assemble related projects together and grant members access to several projects at once.', + ), + illustration: newGroupIllustration, + details: createGroupDescriptionDetails, + detailProps: { + parentGroupName: this.parentGroupName, + importExistingGroupPath: this.importExistingGroupPath, + }, + }, + { + name: 'import-group-pane', + selector: '#import-group-pane', + title: s__('GroupsNew|Import group'), + description: s__( + 'GroupsNew|Import a group and related data from another GitLab instance.', + ), + illustration: importGroupIllustration, + details: 'Migrate your existing groups from another instance of GitLab.', + }, + ]; + }, + }, }; </script> <template> <new-namespace-page :jump-to-last-persisted-panel="hasErrors" - :initial-breadcrumb="__('New group')" - :panels="$options.PANELS" + :initial-breadcrumb="initialBreadcrumb" + :panels="panels" :title="s__('GroupsNew|Create new group')" persistence-key="new_group_last_active_tab" /> diff --git a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue index 35193171fb8..be8542628c4 100644 --- a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue +++ b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue @@ -1,6 +1,22 @@ <script> import { GlSprintf, GlLink } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +const DESCRIPTION_DETAILS = { + group: [ + s__( + 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.', + ), + s__('GroupsNew|Groups can also be nested by creating %{linkStart}subgroups%{linkEnd}.'), + ], + subgroup: [ + s__( + 'GroupsNew|%{groupsLinkStart}Groups%{groupsLinkEnd} and %{subgroupsLinkStart}subgroups%{subgroupsLinkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.', + ), + s__('GroupsNew|You can also %{linkStart}import an existing group%{linkEnd}.'), + ], +}; export default { components: { @@ -11,30 +27,46 @@ export default { groupsHelpPath: helpPagePath('user/group/index'), subgroupsHelpPath: helpPagePath('user/group/subgroups/index'), }, + props: { + parentGroupName: { + type: String, + required: false, + default: '', + }, + importExistingGroupPath: { + type: String, + required: false, + default: '', + }, + }, + descriptionDetails: DESCRIPTION_DETAILS, }; </script> <template> <div> <p> - <gl-sprintf - :message=" - s__( - 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.', - ) - " - > + <gl-sprintf v-if="parentGroupName" :message="$options.descriptionDetails.subgroup[0]"> + <template #groupsLink="{ content }"> + <gl-link :href="$options.paths.groupsHelpPath" target="_blank">{{ content }}</gl-link> + </template> + <template #subgroupsLink="{ content }"> + <gl-link :href="$options.paths.subgroupsHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + <gl-sprintf v-else :message="$options.descriptionDetails.group[0]"> <template #link="{ content }"> <gl-link :href="$options.paths.groupsHelpPath" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </p> <p> - <gl-sprintf - :message=" - s__('GroupsNew|Groups can also be nested by creating %{linkStart}subgroups%{linkEnd}.') - " - > + <gl-sprintf v-if="parentGroupName" :message="$options.descriptionDetails.subgroup[1]"> + <template #link="{ content }"> + <gl-link :href="importExistingGroupPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + <gl-sprintf v-else :message="$options.descriptionDetails.group[1]"> <template #link="{ content }"> <gl-link :href="$options.paths.subgroupsHelpPath" target="_blank">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 7c409010510..7dab5258b24 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -16,9 +16,18 @@ BindInOut.initAll(); initFilePickers(); function initNewGroupCreation(el) { - const { hasErrors, verificationRequired, verificationFormUrl, subscriptionsUrl } = el.dataset; + const { + hasErrors, + parentGroupName, + importExistingGroupPath, + verificationRequired, + verificationFormUrl, + subscriptionsUrl, + } = el.dataset; const props = { + parentGroupName, + importExistingGroupPath, hasErrors: parseBoolean(hasErrors), }; diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index 6748a62e777..9cce6723bf7 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -68,7 +68,7 @@ export default { }), tableCell({ key: 'created_at', - label: __('Date'), + label: __('Start date'), }), tableCell({ key: 'status', diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js index 3fae9809e51..c520042c172 100644 --- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js +++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js @@ -2,12 +2,10 @@ import { initAccessTokenTableApp, initExpiresAtField, initNewAccessTokenApp, - initProjectsField, initTokensApp, } from '~/access_tokens'; initAccessTokenTableApp(); initExpiresAtField(); initNewAccessTokenApp(); -initProjectsField(); initTokensApp(); diff --git a/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js b/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js deleted file mode 100644 index 61486606665..00000000000 --- a/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { initCiSecureFiles } from '~/ci_secure_files'; - -initCiSecureFiles(); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index c217bc5a727..65e7f48ed24 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -55,18 +55,30 @@ waitForCSSLoaded(() => { }, attrs: { height: LANGUAGE_CHART_HEIGHT, + responsive: true, }, }); }, }); + const { + graphEndpoint, + graphEndDate, + graphStartDate, + graphRef, + graphCsvPath, + } = codeCoverageContainer.dataset; // eslint-disable-next-line no-new new Vue({ el: codeCoverageContainer, render(h) { return h(CodeCoverage, { props: { - graphEndpoint: codeCoverageContainer.dataset?.graphEndpoint, + graphEndpoint, + graphEndDate, + graphStartDate, + graphRef, + graphCsvPath, }, }); }, @@ -92,6 +104,9 @@ waitForCSSLoaded(() => { yAxisTitle: __('No. of commits'), xAxisType: 'category', }, + attrs: { + responsive: true, + }, }); }, }); @@ -125,6 +140,9 @@ waitForCSSLoaded(() => { yAxisTitle: __('No. of commits'), xAxisType: 'category', }, + attrs: { + responsive: true, + }, }); }, }); @@ -149,6 +167,9 @@ waitForCSSLoaded(() => { yAxisTitle: __('No. of commits'), xAxisType: 'category', }, + attrs: { + responsive: true, + }, }); }, }); diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue index 92ae8128285..d7e68484143 100644 --- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue +++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { get } from 'lodash'; import { formatDate } from '~/lib/utils/datetime_utility'; @@ -11,6 +11,7 @@ export default { components: { GlAlert, GlAreaChart, + GlButton, GlDropdown, GlDropdownItem, GlSprintf, @@ -20,6 +21,22 @@ export default { type: String, required: true, }, + graphEndDate: { + type: String, + required: true, + }, + graphStartDate: { + type: String, + required: true, + }, + graphRef: { + type: String, + required: true, + }, + graphCsvPath: { + type: String, + required: true, + }, }, data() { return { @@ -119,6 +136,28 @@ export default { <template> <div> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-t gl-pt-4 gl-mb-3" + > + <h4 class="gl-m-0" sub-header> + <gl-sprintf + :message="__('Code coverage statistics for %{ref} %{start_date} - %{end_date}')" + > + <template #ref> + <strong> {{ graphRef }} </strong> + </template> + <template #start_date> + <strong> {{ graphStartDate }} </strong> + </template> + <template #end_date> + <strong> {{ graphEndDate }} </strong> + </template> + </gl-sprintf> + </h4> + <gl-button v-if="canShowData" size="small" data-testid="download-button" :href="graphCsvPath"> + {{ __('Download raw data (.csv)') }} + </gl-button> + </div> <div class="gl-mt-3 gl-mb-3"> <gl-alert v-if="hasFetchError" @@ -155,6 +194,7 @@ export default { :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" + responsive > <template v-if="canShowData" #tooltip-title> {{ tooltipTitle }} diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js index 7db34816cfe..f7849e8d588 100644 --- a/app/assets/javascripts/pages/projects/init_blob.js +++ b/app/assets/javascripts/pages/projects/init_blob.js @@ -4,6 +4,7 @@ import BlobForkSuggestion from '~/blob/blob_fork_suggestion'; import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater'; import LineHighlighter from '~/blob/line_highlighter'; import initBlobBundle from '~/blob_edit/blob_bundle'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; export default () => { new LineHighlighter(); // eslint-disable-line no-new @@ -11,10 +12,16 @@ export default () => { // eslint-disable-next-line no-new new BlobLinePermalinkUpdater( document.querySelector('#blob-content-holder'), - '.diff-line-num[data-line-number], .diff-line-num[data-line-number] *', + '.file-line-num[data-line-number], .file-line-num[data-line-number] *', document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'), ); + const eventsToTrack = [ + { selector: '.file-line-blame', property: 'blame' }, + { selector: '.file-line-num', property: 'link' }, + ]; + addBlobLinksTracking('#blob-content-holder', eventsToTrack); + const fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url'); const fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index ca2b1a08be8..c92958cd8c7 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -1,6 +1,6 @@ import { initShow } from '~/issues'; import { store } from '~/notes/stores'; -import initRelatedIssues from '~/related_issues'; +import { initRelatedIssues } from '~/related_issues'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; import initWorkItemLinks from '~/work_items/components/work_item_links'; diff --git a/app/assets/javascripts/pages/projects/pages/new/index.js b/app/assets/javascripts/pages/projects/pages/new/index.js new file mode 100644 index 00000000000..a5157f5b01b --- /dev/null +++ b/app/assets/javascripts/pages/projects/pages/new/index.js @@ -0,0 +1,3 @@ +import initPages from '~/gitlab_pages/new'; + +initPages(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js index cd4bc35e74e..9513f42d9c9 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import PipelineSchedulesTakeOwnershipModal from '~/pipeline_schedules/components/take_ownership_modal.vue'; import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue'; function initPipelineSchedules() { @@ -23,4 +25,43 @@ function initPipelineSchedules() { }); } +function initTakeownershipModal() { + const modalId = 'pipeline-take-ownership-modal'; + const buttonSelector = 'js-take-ownership-button'; + const el = document.getElementById(modalId); + const takeOwnershipButtons = document.querySelectorAll(`.${buttonSelector}`); + + if (!el) { + return; + } + + // eslint-disable-next-line no-new + new Vue({ + el, + data() { + return { + url: '', + }; + }, + mounted() { + takeOwnershipButtons.forEach((button) => { + button.addEventListener('click', () => { + const { url } = button.dataset; + + this.url = url; + this.$root.$emit(BV_SHOW_MODAL, modalId, `.${buttonSelector}`); + }); + }); + }, + render(createElement) { + return createElement(PipelineSchedulesTakeOwnershipModal, { + props: { + ownershipUrl: this.url, + }, + }); + }, + }); +} + initPipelineSchedules(); +initTakeownershipModal(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index f2c30870a68..c7c331c7de5 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -29,6 +29,10 @@ export default { lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'), mergeRequestsLabel: s__('ProjectSettings|Merge requests'), operationsLabel: s__('ProjectSettings|Operations'), + environmentsLabel: s__('ProjectSettings|Environments'), + environmentsHelpText: s__( + 'ProjectSettings|Every project can make deployments to environments either via CI/CD or API calls. Non-project members have read-only access.', + ), packagesHelpText: s__( 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.', ), @@ -209,6 +213,7 @@ export default { requirementsAccessLevel: featureAccessLevel.EVERYONE, securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS, operationsAccessLevel: featureAccessLevel.EVERYONE, + environmentsAccessLevel: featureAccessLevel.EVERYONE, containerRegistryAccessLevel: featureAccessLevel.EVERYONE, warnAboutPotentiallyUnwantedCharacters: true, lfsEnabled: true, @@ -282,6 +287,9 @@ export default { return this.operationsAccessLevel > featureAccessLevel.NOT_ENABLED; }, + environmentsEnabled() { + return this.environmentsAccessLevel > featureAccessLevel.NOT_ENABLED; + }, repositoryEnabled() { return this.repositoryAccessLevel > featureAccessLevel.NOT_ENABLED; }, @@ -318,12 +326,8 @@ export default { packageRegistryAccessLevelEnabled() { return this.glFeatures.packageRegistryAccessLevel; }, - showAdditonalSettings() { - if (this.glFeatures.enforceAuthChecksOnUploads) { - return true; - } - - return this.visibilityLevel !== this.visibilityOptions.PRIVATE; + splitOperationsEnabled() { + return this.glFeatures.splitOperationsVisibilityPermissions; }, }, @@ -381,6 +385,10 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.operationsAccessLevel, ); + this.environmentsAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.environmentsAccessLevel, + ); this.containerRegistryAccessLevel = Math.min( featureAccessLevel.PROJECT_MEMBERS, this.containerRegistryAccessLevel, @@ -422,6 +430,8 @@ export default { this.requirementsAccessLevel = featureAccessLevel.EVERYONE; if (this.operationsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.operationsAccessLevel = featureAccessLevel.EVERYONE; + if (this.environmentsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) + this.environmentsAccessLevel = featureAccessLevel.EVERYONE; if (this.containerRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE; @@ -545,7 +555,7 @@ export default { </template> </gl-sprintf> </span> - <div v-if="showAdditonalSettings" class="gl-mt-4"> + <div class="gl-mt-4"> <strong class="gl-display-block">{{ s__('ProjectSettings|Additional options') }}</strong> <label v-if="visibilityLevel !== visibilityOptions.PRIVATE" @@ -560,9 +570,7 @@ export default { {{ s__('ProjectSettings|Users can request access') }} </label> <label - v-if=" - visibilityLevel !== visibilityOptions.PUBLIC && glFeatures.enforceAuthChecksOnUploads - " + v-if="visibilityLevel !== visibilityOptions.PUBLIC" class="gl-line-height-28 gl-font-weight-normal gl-display-block gl-mb-0" > <input @@ -866,6 +874,20 @@ export default { /> </project-setting-row> </div> + <template v-if="splitOperationsEnabled"> + <project-setting-row + ref="environments-settings" + :label="$options.i18n.environmentsLabel" + :help-text="$options.i18n.environmentsHelpText" + > + <project-feature-setting + v-model="environmentsAccessLevel" + :label="$options.i18n.environmentsLabel" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][environments_access_level]" + /> + </project-setting-row> + </template> </div> <project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3"> <label class="js-emails-disabled"> diff --git a/app/assets/javascripts/pages/projects/tags/releases/index.js b/app/assets/javascripts/pages/projects/tags/releases/index.js deleted file mode 100644 index cafd880b4be..00000000000 --- a/app/assets/javascripts/pages/projects/tags/releases/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import $ from 'jquery'; -import GLForm from '~/gl_form'; -import ZenMode from '~/zen_mode'; - -new ZenMode(); // eslint-disable-line no-new -new GLForm($('.release-form')); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index 94a5c1cb29b..897acf9b02c 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -3,12 +3,17 @@ import { trackNewRegistrations } from '~/google_tag_manager'; import NoEmojiValidator from '~/emoji/no_emoji_validator'; import LengthValidator from '~/pages/sessions/new/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; +import EmailFormatValidator from '~/pages/sessions/new/email_format_validator'; import Tracking from '~/tracking'; new UsernameValidator(); // eslint-disable-line no-new new LengthValidator(); // eslint-disable-line no-new new NoEmojiValidator(); // eslint-disable-line no-new +if (gon.features.trialEmailValidation) { + new EmailFormatValidator(); // eslint-disable-line no-new +} + trackNewRegistrations(); Tracking.enableFormTracking({ diff --git a/app/assets/javascripts/pages/sessions/new/email_format_validator.js b/app/assets/javascripts/pages/sessions/new/email_format_validator.js new file mode 100644 index 00000000000..6dcf3b50dca --- /dev/null +++ b/app/assets/javascripts/pages/sessions/new/email_format_validator.js @@ -0,0 +1,46 @@ +import InputValidator from '~/validators/input_validator'; + +// It checks if email contains at least one character, number or whatever except +// another "@" or whitespace before "@", at least two characters except +// another "@" or whitespace after "@" and one dot in between +const emailRegexPattern = /[^@\s]+@[^@\s]+\.[^@\s]+/; +const hintMessageSelector = '.validation-hint'; +const warningMessageSelector = '.validation-warning'; + +export default class EmailFormatValidator extends InputValidator { + constructor(opts = {}) { + super(); + + const container = opts.container || ''; + + document + .querySelectorAll(`${container} .js-validate-email`) + .forEach((element) => + element.addEventListener('keyup', EmailFormatValidator.eventHandler.bind(this)), + ); + } + + static eventHandler(event) { + const inputDomElement = event.target; + + EmailFormatValidator.setMessageVisibility(inputDomElement, hintMessageSelector); + EmailFormatValidator.setMessageVisibility(inputDomElement, warningMessageSelector); + EmailFormatValidator.validateEmailInput(inputDomElement); + } + + static validateEmailInput(inputDomElement) { + const validEmail = inputDomElement.checkValidity(); + const validPattern = inputDomElement.value.match(emailRegexPattern); + + EmailFormatValidator.setMessageVisibility( + inputDomElement, + warningMessageSelector, + validEmail && !validPattern, + ); + } + + static setMessageVisibility(inputDomElement, messageSelector, isVisible = false) { + const messageElement = inputDomElement.parentElement.querySelector(messageSelector); + messageElement.classList.toggle('hide', !isVisible); + } +} diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 3c22844434d..9d7d9e376cf 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -9,6 +9,7 @@ import { GlFormGroup, GlFormInput, GlFormSelect, + GlSegmentedControl, } from '@gitlab/ui'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import axios from '~/lib/utils/axios_utils'; @@ -81,9 +82,11 @@ export default { newPage: s__('WikiPage|Create page'), }, cancel: s__('WikiPage|Cancel'), - editSourceButtonText: s__('WikiPage|Edit source'), - editRichTextButtonText: s__('WikiPage|Edit rich text'), }, + switchEditingControlOptions: [ + { text: s__('Wiki Page|Source'), value: 'source' }, + { text: s__('Wiki Page|Rich text'), value: 'richText' }, + ], components: { GlAlert, GlIcon, @@ -94,6 +97,7 @@ export default { GlSprintf, GlLink, GlButton, + GlSegmentedControl, MarkdownField, LocalStorageSync, ContentEditor: () => @@ -105,14 +109,15 @@ export default { inject: ['formatOptions', 'pageInfo'], data() { return { + editingMode: 'source', title: this.pageInfo.title?.trim() || '', format: this.pageInfo.format || 'markdown', content: this.pageInfo.content || '', - useContentEditor: false, commitMessage: '', isDirty: false, contentEditorRenderFailed: false, contentEditorEmpty: false, + switchEditingControlDisabled: false, }; }, computed: { @@ -177,6 +182,9 @@ export default { isContentEditorActive() { return this.isMarkdownFormat && this.useContentEditor; }, + useContentEditor() { + return this.editingMode === 'richText'; + }, }, mounted() { this.updateCommitMessage(); @@ -193,16 +201,15 @@ export default { .then(({ data }) => data.body); }, - toggleEditingMode() { - if (this.useContentEditor) { + toggleEditingMode(editingMode) { + this.editingMode = editingMode; + if (!this.useContentEditor && this.contentEditor) { this.content = this.contentEditor.getSerializedContent(); } - - this.useContentEditor = !this.useContentEditor; }, - setUseContentEditor(value) { - this.useContentEditor = value; + setEditingMode(value) { + this.editingMode = value; }, async handleFormSubmit(e) { @@ -294,6 +301,14 @@ export default { }, }); }, + + enableSwitchEditingControl() { + this.switchEditingControlDisabled = false; + }, + + disableSwitchEditingControl() { + this.switchEditingControlDisabled = true; + }, }, }; </script> @@ -372,20 +387,21 @@ export default { <div class="row" data-testid="wiki-form-content-fieldset"> <div class="col-sm-12 row-sm-5"> <gl-form-group> - <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-end gl-mb-3"> - <gl-button + <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-start gl-mb-3"> + <gl-segmented-control data-testid="toggle-editing-mode-button" data-qa-selector="editing_mode_button" - :data-qa-mode="toggleEditingModeButtonText" - variant="link" - @click="toggleEditingMode" - >{{ toggleEditingModeButtonText }}</gl-button - > + class="gl-display-flex" + :checked="editingMode" + :options="$options.switchEditingControlOptions" + :disabled="switchEditingControlDisabled" + @input="toggleEditingMode" + /> </div> <local-storage-sync storage-key="gl-wiki-content-editor-enabled" - :value="useContentEditor" - @input="setUseContentEditor" + :value="editingMode" + @input="setEditingMode" /> <markdown-field v-if="!isContentEditorActive" @@ -422,6 +438,9 @@ export default { :uploads-path="pageInfo.uploadsPath" @initialized="loadInitialContent" @change="handleContentEditorChange" + @loading="disableSwitchEditingControl" + @loadingSuccess="enableSwitchEditingControl" + @loadingError="enableSwitchEditingControl" /> <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" /> </div> diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index 7c424088c8b..9cea89f4990 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -7,11 +7,12 @@ const DEFERRED_LINK_CLASS = 'deferred-link'; export default class PersistentUserCallout { constructor(container, options = container.dataset) { - const { dismissEndpoint, featureId, groupId, deferLinks } = options; + const { dismissEndpoint, featureId, groupId, namespaceId, deferLinks } = options; this.container = container; this.dismissEndpoint = dismissEndpoint; this.featureId = featureId; this.groupId = groupId; + this.namespaceId = namespaceId; this.deferLinks = parseBoolean(deferLinks); this.closeButtons = this.container.querySelectorAll('.js-close'); @@ -56,6 +57,7 @@ export default class PersistentUserCallout { .post(this.dismissEndpoint, { feature_name: this.featureId, group_id: this.groupId, + namespace_id: this.namespaceId, }) .then(() => { this.container.remove(); diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index f836921f5e5..ead512e3574 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -16,6 +16,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-minute-limit-banner', '.js-submit-license-usage-data-banner', '.js-project-usage-limitations-callout', + '.js-namespace-storage-alert', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue index 1f74e89f90c..0b57433e894 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue @@ -28,13 +28,13 @@ export default { GlSprintf, }, mixins: [Tracking.mixin()], - inject: ['runnerHelpPagePath'], methods: { trackHelpPageClick() { const { label, actions } = pipelineEditorTrackingOptions; this.track(actions.helpDrawerLinks.runners, { label }); }, }, + RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html', }; </script> <template> @@ -47,7 +47,7 @@ export default { <p class="gl-mb-0"> <gl-sprintf :message="$options.i18n.note"> <template #link="{ content }"> - <gl-link :href="runnerHelpPagePath" target="_blank" @click="trackHelpPageClick()"> + <gl-link :href="$options.RUNNER_HELP_URL" target="_blank" @click="trackHelpPageClick()"> {{ content }} </gl-link> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue index 65a2a6b56e4..189690ce2c3 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue @@ -43,7 +43,9 @@ export default { </script> <template> - <div class="gl-bg-gray-10 gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1"> + <div + class="gl-bg-gray-10 gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1" + > <gl-button :href="$options.TEMPLATE_REPOSITORY_URL" size="small" diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue deleted file mode 100644 index f1cf5630fbf..00000000000 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue +++ /dev/null @@ -1,53 +0,0 @@ -<script> -import { flatten } from 'lodash'; -import CiLintResults from './ci_lint_results.vue'; - -export default { - components: { - CiLintResults, - }, - inject: { - lintHelpPagePath: { - default: '', - }, - }, - props: { - isValid: { - type: Boolean, - required: true, - }, - ciConfig: { - type: Object, - required: true, - }, - }, - computed: { - stages() { - return this.ciConfig?.stages || []; - }, - jobs() { - const groupedJobs = this.stages.reduce((acc, { groups, name: stageName }) => { - return acc.concat( - groups.map(({ jobs }) => { - return jobs.map((job) => ({ - stage: stageName, - ...job, - })); - }), - ); - }, []); - - return flatten(groupedJobs); - }, - }, -}; -</script> - -<template> - <ci-lint-results - :errors="ciConfig.errors" - :is-valid="isValid" - :jobs="jobs" - :lint-help-page-path="lintHelpPagePath" - /> -</template> diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 99ee244577e..4941f22230b 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -2,7 +2,6 @@ import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { CREATE_TAB, @@ -11,7 +10,6 @@ import { EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_VALID, EDITOR_APP_STATUS_LINT_UNAVAILABLE, - LINT_TAB, MERGED_TAB, TAB_QUERY_PARAM, TABS_INDEX, @@ -22,7 +20,6 @@ import { import getAppStatus from '../graphql/queries/client/app_status.query.graphql'; import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue'; import CiEditorHeader from './editor/ci_editor_header.vue'; -import CiLint from './lint/ci_lint.vue'; import CiValidate from './validate/ci_validate.vue'; import TextEditor from './editor/text_editor.vue'; import EditorTab from './ui/editor_tab.vue'; @@ -56,7 +53,6 @@ export default { }, tabConstants: { CREATE_TAB, - LINT_TAB, MERGED_TAB, VALIDATE_TAB, VISUALIZE_TAB, @@ -64,7 +60,6 @@ export default { components: { CiConfigMergedPreview, CiEditorHeader, - CiLint, CiValidate, EditorTab, GlAlert, @@ -74,7 +69,6 @@ export default { TextEditor, WalkthroughPopover, }, - mixins: [glFeatureFlagsMixin()], props: { ciConfigData: { type: Object, @@ -212,7 +206,6 @@ export default { <pipeline-graph v-else :pipeline-data="ciConfigData" /> </editor-tab> <editor-tab - v-if="glFeatures.simulatePipeline" class="gl-mb-3" data-testid="validate-tab" :badge-title="validateTabBadgeTitle" @@ -222,19 +215,6 @@ export default { <ci-validate :ci-file-content="ciFileContent" /> </editor-tab> <editor-tab - v-else - class="gl-mb-3" - :empty-message="$options.i18n.empty.lint" - :is-empty="isEmpty" - :is-unavailable="isLintUnavailable" - :title="$options.i18n.tabLint" - data-testid="lint-tab" - @click="setCurrentTab($options.tabConstants.LINT_TAB)" - > - <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> - <ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" /> - </editor-tab> - <editor-tab class="gl-mb-3" :empty-message="$options.i18n.empty.merge" :keep-component-mounted="false" diff --git a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue index 47673119db9..83fcab4b343 100644 --- a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue +++ b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue @@ -11,6 +11,8 @@ import { GlSprintf, } from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import Tracking from '~/tracking'; +import { pipelineEditorTrackingOptions } from '../../constants'; import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue'; import CiLintResults from '../lint/ci_lint_results.vue'; import getBlobContent from '../../graphql/queries/blob_content.query.graphql'; @@ -70,6 +72,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [Tracking.mixin()], inject: ['ciConfigPath', 'ciLintPath', 'projectFullPath', 'validateTabIllustrationPath'], props: { ciFileContent: { @@ -110,6 +113,9 @@ export default { }; }, computed: { + canResimulatePipeline() { + return this.hasSimulationResults && this.hasCiContentChanged; + }, isInitialCiContentLoading() { return this.$apollo.queries.initialBlobContent.loading; }, @@ -128,6 +134,10 @@ export default { variant: this.isValid ? 'success' : 'danger', }; }, + trackingAction() { + const { actions } = pipelineEditorTrackingOptions; + return this.canResimulatePipeline ? actions.resimulatePipeline : actions.simulatePipeline; + }, }, watch: { ciFileContent(value) { @@ -139,7 +149,12 @@ export default { cancelSimulation() { this.state = VALIDATE_TAB_INIT; }, + trackSimulation() { + const { label } = pipelineEditorTrackingOptions; + this.track(this.trackingAction, { label }); + }, async validateYaml() { + this.trackSimulation(); this.state = VALIDATE_TAB_LOADING; try { @@ -150,7 +165,7 @@ export default { } = await this.$apollo.mutate({ mutation: lintCiMutation, variables: { - dry_run: true, + dry: true, content: this.yaml, endpoint: this.ciLintPath, }, @@ -198,7 +213,7 @@ export default { :aria-label="$options.i18n.help" /> </div> - <div v-if="hasSimulationResults && hasCiContentChanged"> + <div v-if="canResimulatePipeline"> <span class="gl-text-gray-400" data-testid="content-status"> {{ $options.i18n.contentChange }} </span> @@ -232,6 +247,7 @@ export default { class="gl-mt-3" :disabled="isInitialCiContentLoading" data-testid="simulate-pipeline-button" + data-qa-selector="simulate_pipeline_button" @click="validateYaml" > {{ $options.i18n.cta }} diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 05db0afd15d..dd25c4d433b 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -30,7 +30,6 @@ export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; export const PIPELINE_FAILURE = 'PIPELINE_FAILURE'; export const CREATE_TAB = 'CREATE_TAB'; -export const LINT_TAB = 'LINT_TAB'; export const MERGED_TAB = 'MERGED_TAB'; export const VALIDATE_TAB = 'VALIDATE_TAB'; export const VISUALIZE_TAB = 'VISUALIZE_TAB'; @@ -38,9 +37,8 @@ export const VISUALIZE_TAB = 'VISUALIZE_TAB'; export const TABS_INDEX = { [CREATE_TAB]: '0', [VISUALIZE_TAB]: '1', - [LINT_TAB]: '2', - [VALIDATE_TAB]: '3', - [MERGED_TAB]: '4', + [VALIDATE_TAB]: '2', + [MERGED_TAB]: '3', }; export const TAB_QUERY_PARAM = 'tab'; @@ -77,6 +75,8 @@ export const pipelineEditorTrackingOptions = { [CI_YAML_LINK]: 'visit_help_drawer_link_yaml', }, openHelpDrawer: 'open_help_drawer', + resimulatePipeline: 'resimulate_pipeline', + simulatePipeline: 'simulate_pipeline', }, }; diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql index 5091d63111f..2d42ebb6ac3 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql @@ -13,7 +13,6 @@ mutation lintCI($endpoint: String, $content: String, $dry: Boolean) { only { refs } - afterScript stage tags when diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql index 77a3cdf586c..3495ca51283 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql @@ -19,9 +19,7 @@ mutation commitCIFile( ] } ) { - __typename commit { - __typename id sha } diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 4f5b69107bf..13dad0b2459 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -40,7 +40,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { projectFullPath, projectPath, projectNamespace, - runnerHelpPagePath, simulatePipelineHelpPagePath, totalBranches, validateTabIllustrationPath, @@ -132,7 +131,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { projectFullPath, projectPath, projectNamespace, - runnerHelpPagePath, simulatePipelineHelpPagePath, totalBranches: parseInt(totalBranches, 10), validateTabIllustrationPath, diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index d84fc724d38..9378b67b915 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -3,10 +3,11 @@ import { GlAlert, GlIcon, GlButton, + GlDropdown, + GlDropdownItem, GlForm, GlFormGroup, GlFormInput, - GlFormSelect, GlFormTextarea, GlLink, GlSprintf, @@ -43,10 +44,10 @@ const i18n = { }; export default { - typeOptions: [ - { value: VARIABLE_TYPE, text: __('Variable') }, - { value: FILE_TYPE, text: __('File') }, - ], + typeOptions: { + [VARIABLE_TYPE]: __('Variable'), + [FILE_TYPE]: __('File'), + }, i18n, formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', // this height value is used inline on the textarea to match the input field height @@ -56,10 +57,11 @@ export default { GlAlert, GlIcon, GlButton, + GlDropdown, + GlDropdownItem, GlForm, GlFormGroup, GlFormInput, - GlFormSelect, GlFormTextarea, GlLink, GlSprintf, @@ -202,6 +204,11 @@ export default { }); } }, + setVariableType(key, type) { + const { variables } = this.form[this.refFullName]; + const variable = variables.find((v) => v.key === key); + variable.variable_type = type; + }, setVariableParams(refValue, type, paramsObj) { Object.entries(paramsObj).forEach(([key, value]) => { this.setVariable(refValue, type, key, value); @@ -401,12 +408,19 @@ export default { <div class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" > - <gl-form-select - v-model="variable.variable_type" + <gl-dropdown + :text="$options.typeOptions[variable.variable_type]" :class="$options.formElementClasses" - :options="$options.typeOptions" data-testid="pipeline-form-ci-variable-type" - /> + > + <gl-dropdown-item + v-for="type in Object.keys($options.typeOptions)" + :key="type" + @click="setVariableType(variable.key, type)" + > + {{ $options.typeOptions[type] }} + </gl-dropdown-item> + </gl-dropdown> <gl-form-input v-model="variable.key" :placeholder="s__('CiVariables|Input variable key')" diff --git a/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue b/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue new file mode 100644 index 00000000000..7ded3945a32 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue @@ -0,0 +1,52 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +export default { + components: { + GlModal, + }, + props: { + ownershipUrl: { + type: String, + required: true, + }, + }, + modalId: 'pipeline-take-ownership-modal', + i18n: { + takeOwnership: s__('PipelineSchedules|Take ownership'), + ownershipMessage: s__( + 'PipelineSchedules|Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?', + ), + cancelLabel: __('Cancel'), + }, + computed: { + actionCancel() { + return { text: this.$options.i18n.cancelLabel }; + }, + actionPrimary() { + return { + text: this.$options.i18n.takeOwnership, + attributes: [ + { + variant: 'confirm', + category: 'primary', + href: this.ownershipUrl, + 'data-method': 'post', + }, + ], + }; + }, + }, +}; +</script> +<template> + <gl-modal + :modal-id="$options.modalId" + :action-primary="actionPrimary" + :action-cancel="actionCancel" + :title="$options.i18n.takeOwnership" + > + <p>{{ $options.i18n.ownershipMessage }}</p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue index f2b159acfee..4ba5c237311 100644 --- a/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue +++ b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue @@ -32,6 +32,11 @@ export default { required: false, default: false, }, + id: { + type: String, + required: false, + default: () => uniqueId('checklist_'), + }, }, computed: { checklistItems() { @@ -62,8 +67,8 @@ export default { </script> <template> - <gl-form-group #default="{ ariaDescribedby }" :label="title"> - <gl-form-checkbox-group :aria-describedby="ariaDescribedby" @input="updateValidState"> + <gl-form-group :label="title" :label-for="id"> + <gl-form-checkbox-group :id="id" :label="title" @input="updateValidState"> <gl-form-checkbox v-for="item in checklistItems" :id="item.id" diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue index 939702fd1b5..79b1507ad0e 100644 --- a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue +++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue @@ -49,7 +49,7 @@ export default { <template> <div> <div class="gl-my-8"> - <h2 class="gl-mb-4" data-testid="title">{{ title }}</h2> + <h1 class="gl-mb-4" data-testid="title">{{ title }}</h1> <p class="text-tertiary gl-font-lg gl-max-w-80" data-testid="description"> {{ description }} </p> diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 795ba91a164..8d764fad0c5 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -46,6 +46,9 @@ export default { const { name, status } = this.group; return `${name} - ${status.label}`; }, + jobGroupClasses() { + return [this.cssClassJobName, `job-${this.group.status.group}`]; + }, }, errorCaptured(err, _vm, info) { reportToSentry('job_group_dropdown', `error: ${err}, info: ${info}`); @@ -68,7 +71,7 @@ export default { type="button" data-toggle="dropdown" data-display="static" - :class="cssClassJobName" + :class="jobGroupClasses" class="dropdown-menu-toggle gl-pipeline-job-width! gl-pr-4!" > <div class="gl-display-flex gl-align-items-stretch gl-justify-content-space-between"> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 362571930d6..377f21b299f 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -200,6 +200,9 @@ export default { }, { 'gl-rounded-lg': this.isBridge }, this.cssClassJobName, + { + [`job-${this.status.group}`]: this.isSingleItem, + }, ]; }, }, diff --git a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue index ae6b9186930..fdbf0ca19bc 100644 --- a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue +++ b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue @@ -97,13 +97,16 @@ export default { <gl-loading-icon v-if="$apollo.queries.jobs.loading" size="lg" /> <template v-else> - <gl-alert v-if="showLimitMessage" class="gl-mb-4" :dismissible="false"> - <p>{{ $options.i18n.insightsLimit }}</p> + <gl-alert class="gl-mb-4" :dismissible="false"> + <p v-if="showLimitMessage" data-testid="limit-alert-text"> + {{ $options.i18n.insightsLimit }} + </p> <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/365902" class="gl-mt-5"> {{ $options.i18n.feeback }} </gl-link> </gl-alert> - <div class="gl-display-flex gl-justify-content-space-between gl-mb-7"> + + <div class="gl-display-flex gl-justify-content-space-between gl-mt-2 gl-mb-7"> <gl-card class="gl-w-half gl-mr-7 gl-text-center"> <template #header> <span class="gl-font-weight-bold">{{ $options.i18n.queuedCardHeader }}</span> diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue index e1745969649..df59962569e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue @@ -34,7 +34,13 @@ export default { PipelineGraphWrapper, TestReports, }, - inject: ['defaultTabValue', 'failedJobsCount', 'failedJobsSummary', 'totalJobCount'], + inject: [ + 'defaultTabValue', + 'failedJobsCount', + 'failedJobsSummary', + 'totalJobCount', + 'testsCount', + ], computed: { showFailedJobsTab() { return this.failedJobsCount > 0; @@ -81,11 +87,11 @@ export default { </template> <failed-jobs-app :failed-jobs-summary="failedJobsSummary" /> </gl-tab> - <gl-tab - :title="$options.i18n.tabs.testsTitle" - :active="isActive($options.tabNames.tests)" - data-testid="tests-tab" - > + <gl-tab :active="isActive($options.tabNames.tests)" data-testid="tests-tab" lazy> + <template #title> + <span class="gl-mr-2">{{ $options.i18n.tabs.testsTitle }}</span> + <gl-badge size="sm" data-testid="tests-counter">{{ testsCount }}</gl-badge> + </template> <test-reports /> </gl-tab> <slot></slot> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue index 64d4414eb94..439dc0eb253 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue @@ -32,9 +32,10 @@ export default { .map(({ name, logo, title }) => { return { name: title || name, + description: sprintf(this.$options.i18n.description, { name: title || name }), + isPng: logo.endsWith('png'), logo, link: mergeUrlParams({ template: name }, this.pipelineEditorPath), - description: sprintf(this.$options.i18n.description, { name: title || name }), }; }); @@ -48,6 +49,9 @@ export default { label: template, }); }, + logoStyle(template) { + return template.isPng ? { objectFit: 'contain' } : ''; + }, }, i18n: { description: s__( @@ -66,11 +70,13 @@ export default { > <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> <gl-avatar - :src="template.logo" - :size="48" + :alt="template.name" class="gl-mr-5 gl-bg-white dark-mode-override" + :class="{ 'gl-p-2': template.isPng }" + :style="logoStyle(template)" :shape="$options.AVATAR_SHAPE_OPTION_RECT" - :alt="template.name" + :size="48" + :src="template.logo" data-testid="template-logo" /> <div class="gl-flex-direction-row"> 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 9725e882d5e..05a1ceface3 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 @@ -3,16 +3,16 @@ import { GlAlert, GlDropdown, GlDropdownItem, - GlDropdownSectionHeader, + GlSearchBoxByType, GlLoadingIcon, GlTooltipDirective, } from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; import axios from '~/lib/utils/axios_utils'; import { __, s__ } from '~/locale'; export const i18n = { - artifacts: __('Artifacts'), - artifactSectionHeader: __('Download artifacts'), + downloadArtifacts: __('Download artifacts'), artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), emptyArtifactsMessage: __('No artifacts found'), }; @@ -26,7 +26,7 @@ export default { GlAlert, GlDropdown, GlDropdownItem, - GlDropdownSectionHeader, + GlSearchBoxByType, GlLoadingIcon, }, inject: { @@ -48,8 +48,16 @@ export default { artifacts: [], hasError: false, isLoading: false, + searchQuery: '', }; }, + computed: { + filteredArtifacts() { + return this.searchQuery.length > 0 + ? fuzzaldrinPlus.filter(this.artifacts, this.searchQuery, { key: 'name' }) + : this.artifacts; + }, + }, methods: { fetchArtifacts() { this.isLoading = true; @@ -70,27 +78,27 @@ export default { this.isLoading = false; }); }, + handleDropdownShown() { + this.$refs.searchInput.focusInput(); + }, }, }; </script> <template> <gl-dropdown v-gl-tooltip - :title="$options.i18n.artifacts" - :text="$options.i18n.artifacts" - :aria-label="$options.i18n.artifacts" - icon="ellipsis_v" + :title="$options.i18n.downloadArtifacts" + :text="$options.i18n.downloadArtifacts" + :aria-label="$options.i18n.downloadArtifacts" + :header-text="$options.i18n.downloadArtifacts" + icon="download" data-testid="pipeline-multi-actions-dropdown" right lazy text-sr-only - no-caret @show.once="fetchArtifacts" + @shown="handleDropdownShown" > - <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> @@ -101,8 +109,12 @@ export default { {{ $options.i18n.emptyArtifactsMessage }} </gl-dropdown-item> + <template #header> + <gl-search-box-by-type v-if="artifacts.length" ref="searchInput" v-model.trim="searchQuery" /> + </template> + <gl-dropdown-item - v-for="(artifact, i) in artifacts" + v-for="(artifact, i) in filteredArtifacts" :key="i" :href="artifact.path" rel="nofollow" diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 3fb46a4f128..e5666f7a658 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import createTestReportsStore from '../../stores/test_reports'; import EmptyState from './empty_state.vue'; import TestSuiteTable from './test_suite_table.vue'; @@ -16,6 +17,7 @@ export default { TestSummary, TestSummaryTable, }, + mixins: [glFeatureFlagMixin()], inject: ['blobPath', 'summaryEndpoint', 'suiteEndpoint'], computed: { ...mapState('testReports', ['isLoading', 'selectedSuiteIndex', 'testReports']), @@ -29,14 +31,16 @@ export default { }, }, created() { - this.$store.registerModule( - 'testReports', - createTestReportsStore({ - blobPath: this.blobPath, - summaryEndpoint: this.summaryEndpoint, - suiteEndpoint: this.suiteEndpoint, - }), - ); + if (!this.glFeatures.pipelineTabsVue) { + this.$store.registerModule( + 'testReports', + createTestReportsStore({ + blobPath: this.blobPath, + summaryEndpoint: this.summaryEndpoint, + suiteEndpoint: this.suiteEndpoint, + }), + ); + } this.fetchSummary(); }, @@ -74,7 +78,7 @@ export default { <div v-else-if="!isLoading && showTests" ref="container" - class="position-relative" + class="gl-relative" data-testid="tests-detail" > <transition @@ -82,13 +86,13 @@ export default { @before-enter="beforeEnterTransition" @after-leave="afterLeaveTransition" > - <div v-if="showSuite" key="detail" class="w-100 slide-enter-to-element"> + <div v-if="showSuite" key="detail" class="gl-w-full slide-enter-to-element"> <test-summary :report="getSelectedSuite" show-back @on-back-click="summaryBackClick" /> <test-suite-table /> </div> - <div v-else key="summary" class="w-100 slide-enter-from-element"> + <div v-else key="summary" class="gl-w-full slide-enter-from-element"> <test-summary :report="testReports" /> <test-summary-table @row-click="summaryTableRowClick" /> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 1f438c63fee..7d0f1ba4b5f 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -80,7 +80,10 @@ export default { <h4>{{ heading }}</h4> </div> </div> - <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray"> + <div + role="row" + class="gl-responsive-table-row table-row-header gl-font-weight-bold gl-fill-gray-700" + > <div role="rowheader" class="table-section section-20"> {{ __('Suite') }} </div> @@ -104,7 +107,7 @@ export default { <div v-for="(testCase, index) in getSuiteTests" :key="index" - class="gl-responsive-table-row rounded align-items-md-start" + class="gl-responsive-table-row gl-rounded-base gl-align-items-flex-start" data-testid="test-case-row" > <div class="table-section section-20 section-wrap"> @@ -142,11 +145,8 @@ export default { <div class="table-section section-10 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div> - <div class="table-mobile-content text-center"> - <div - class="ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" - :class="`ci-status-icon-${testCase.status}`" - > + <div class="table-mobile-content gl-md-display-flex gl-justify-content-center"> + <div class="ci-status-icon" :class="`ci-status-icon-${testCase.status}`"> <gl-icon :size="24" :name="testCase.icon" /> </div> </div> @@ -156,7 +156,7 @@ export default { <div role="rowheader" class="table-mobile-header"> {{ __('Duration') }} </div> - <div class="table-mobile-content pr-sm-1"> + <div class="table-mobile-content gl-sm-pr-2"> {{ testCase.formattedTime }} </div> </div> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue index 2f5301715c3..6b723ad5481 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -65,58 +65,53 @@ export default { <template> <div> - <div class="row"> - <div class="col-12 d-flex gl-mt-3 align-items-center"> - <gl-button - v-if="showBack" - size="small" - class="gl-mr-3 js-back-button" - icon="chevron-lg-left" - :aria-label="__('Go back')" - @click="onBackClick" - /> + <div class="gl-w-full gl-display-flex gl-mt-3 gl-align-items-center"> + <gl-button + v-if="showBack" + size="small" + class="gl-mr-3 js-back-button" + icon="chevron-lg-left" + :aria-label="__('Go back')" + @click="onBackClick" + /> - <h4>{{ heading }}</h4> - </div> + <h4>{{ heading }}</h4> </div> - <div class="row mt-2"> - <div class="col-4 col-md"> - <span class="js-total-tests">{{ + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-w-full gl-mt-3" + > + <div class="gl-display-flex gl-justify-content-space-between gl-flex-basis-half"> + <span class="js-total-tests gl-flex-grow-1">{{ sprintf(s__('TestReports|%{count} tests'), { count: report.total_count }) }}</span> - </div> - <div class="col-4 col-md text-center text-md-center"> - <span class="js-failed-tests">{{ + <span class="js-failed-tests gl-flex-grow-1">{{ sprintf(s__('TestReports|%{count} failures'), { count: report.failed_count }) }}</span> - </div> - <div class="col-4 col-md text-right text-md-center"> <span class="js-errored-tests">{{ sprintf(s__('TestReports|%{count} errors'), { count: report.error_count }) }}</span> </div> - - <div class="col-6 mt-3 col-md mt-md-0 text-md-center"> - <span class="js-success-rate">{{ + <div class="gl-display-flex gl-justify-content-space-between gl-flex-grow-1"> + <div class="gl-display-none gl-md-display-block gl-flex-grow-1"></div> + <span class="js-success-rate gl-flex-grow-1">{{ sprintf(s__('TestReports|%{rate}%{sign} success rate'), { rate: successPercentage, sign: '%', }) }}</span> - </div> - <div class="col-6 mt-3 col-md mt-md-0 text-right"> <span class="js-duration">{{ formattedDuration }}</span> </div> </div> - <div class="row mt-3"> - <div class="col-12"> - <gl-progress-bar :value="successPercentage" :variant="progressBarVariant" height="10px" /> - </div> - </div> + <gl-progress-bar + class="gl-mt-5" + :value="successPercentage" + :variant="progressBarVariant" + height="10px" + /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 8389c2a5104..7ab48da1a9d 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -34,33 +34,31 @@ export default { <template> <div> - <div class="row gl-mt-3"> - <div class="col-12"> - <h4>{{ heading }}</h4> - </div> + <div class="gl-mt-5"> + <h4>{{ heading }}</h4> </div> - <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-suites-table"> - <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold"> - <div role="rowheader" class="table-section section-25 pl-3"> + <div v-if="hasSuites" class="js-test-suites-table"> + <div role="row" class="gl-responsive-table-row table-row-header gl-font-weight-bold"> + <div role="rowheader" class="table-section section-25 gl-pl-5"> {{ __('Job') }} </div> <div role="rowheader" class="table-section section-25"> {{ __('Duration') }} </div> - <div role="rowheader" class="table-section section-10 text-center"> + <div role="rowheader" class="table-section section-10 gl-text-center"> {{ __('Failed') }} </div> - <div role="rowheader" class="table-section section-10 text-center"> + <div role="rowheader" class="table-section section-10 gl-text-center"> {{ __('Errors'), }} </div> - <div role="rowheader" class="table-section section-10 text-center"> + <div role="rowheader" class="table-section section-10 gl-text-center"> {{ __('Skipped'), }} </div> - <div role="rowheader" class="table-section section-10 text-center"> + <div role="rowheader" class="table-section section-10 gl-text-center"> {{ __('Passed'), }} </div> - <div role="rowheader" class="table-section section-10 pr-3 text-right"> + <div role="rowheader" class="table-section section-10 gl-pr-5 gl-text-right"> {{ __('Total') }} </div> </div> @@ -69,17 +67,17 @@ export default { v-for="(testSuite, index) in getTestSuites" :key="index" role="row" - class="gl-responsive-table-row test-reports-summary-row rounded js-suite-row" + class="gl-responsive-table-row gl-rounded-base js-suite-row" :class="{ - 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error, + 'gl-responsive-table-row-clickable gl-cursor-pointer': !testSuite.suite_error, }" @click="tableRowClick(index)" > <div class="table-section section-25"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> {{ __('Suite') }} </div> - <div class="table-mobile-content underline cgray pl-3"> + <div class="table-mobile-content underline gl-text-gray-900 gl-pl-5"> {{ testSuite.name }} <gl-icon v-if="testSuite.suite_error" @@ -93,44 +91,44 @@ export default { </div> <div class="table-section section-25"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> {{ __('Duration') }} </div> - <div class="table-mobile-content text-md-left"> + <div class="table-mobile-content gl-text-left"> {{ testSuite.formattedTime }} </div> </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> + <div class="table-section section-10 gl-text-center"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> {{ __('Failed') }} </div> <div class="table-mobile-content">{{ testSuite.failed_count }}</div> </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> + <div class="table-section section-10 gl-text-center"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> {{ __('Errors') }} </div> <div class="table-mobile-content">{{ testSuite.error_count }}</div> </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> + <div class="table-section section-10 gl-text-center"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> {{ __('Skipped') }} </div> <div class="table-mobile-content">{{ testSuite.skipped_count }}</div> </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> + <div class="table-section section-10 gl-text-center"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> {{ __('Passed') }} </div> <div class="table-mobile-content">{{ testSuite.success_count }}</div> </div> - <div class="table-section section-10 text-right pr-md-3"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> + <div class="table-section section-10 gl-text-right pr-md-3"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> {{ __('Total') }} </div> <div class="table-mobile-content">{{ testSuite.total_count }}</div> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 2e825016c91..7b38f870cb6 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -83,7 +83,7 @@ export const PipelineKeyOptions = [ export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.'); -export const BUTTON_TOOLTIP_RETRY = __('Retry failed jobs'); +export const BUTTON_TOOLTIP_RETRY = __('Retry all failed or cancelled jobs'); export const BUTTON_TOOLTIP_CANCEL = __('Cancel'); export const DEFAULT_FIELDS = [ diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js index c4f7665c91d..e8e49cc652e 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js @@ -1,5 +1,6 @@ import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; +import createFlash, { createAlert } from '~/flash'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; @@ -198,18 +199,20 @@ export default { }) .catch((e) => { const unauthorized = e.response.status === httpStatusCodes.UNAUTHORIZED; - const badRequest = e.response.status === httpStatusCodes.BAD_REQUEST; - let errorMessage = __( 'An error occurred while trying to run a new pipeline for this merge request.', ); - if (unauthorized || badRequest) { + if (unauthorized) { errorMessage = __('You do not have permission to run a pipeline on this branch.'); } - createFlash({ + createAlert({ message: errorMessage, + primaryButton: { + text: __('Learn more'), + link: helpPagePath('ci/pipelines/merge_request_pipelines.md'), + }, }); }) .finally(() => this.store.toggleIsRunningPipeline(false)); diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js index c0e769e2485..7051d356089 100644 --- a/app/assets/javascripts/pipelines/pipeline_tabs.js +++ b/app/assets/javascripts/pipelines/pipeline_tabs.js @@ -5,6 +5,7 @@ import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue'; import { removeParams, updateHistory } from '~/lib/utils/url_utility'; import { TAB_QUERY_PARAM } from '~/pipelines/constants'; import { parseBoolean } from '~/lib/utils/common_utils'; +import createTestReportsStore from './stores/test_reports'; import { getPipelineDefaultTab, reportToSentry } from './utils'; Vue.use(VueApollo); @@ -29,6 +30,17 @@ export const createAppOptions = (selector, apolloProvider) => { pipelineIid, pipelineProjectPath, totalJobCount, + licenseManagementApiUrl, + licenseManagementSettingsPath, + licensesApiPath, + canManageLicenses, + summaryEndpoint, + suiteEndpoint, + blobPath, + hasTestReport, + emptyStateImagePath, + artifactsExpiredImagePath, + testsCount, } = dataset; const defaultTabValue = getPipelineDefaultTab(window.location.href); @@ -39,7 +51,15 @@ export const createAppOptions = (selector, apolloProvider) => { PipelineTabs, }, apolloProvider, - store: new Vuex.Store(), + store: new Vuex.Store({ + modules: { + testReports: createTestReportsStore({ + blobPath, + summaryEndpoint, + suiteEndpoint, + }), + }, + }), provide: { canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports), codequalityReportDownloadPath, @@ -54,6 +74,17 @@ export const createAppOptions = (selector, apolloProvider) => { pipelineIid, pipelineProjectPath, totalJobCount, + licenseManagementApiUrl, + licenseManagementSettingsPath, + licensesApiPath, + canManageLicenses: parseBoolean(canManageLicenses), + summaryEndpoint, + suiteEndpoint, + blobPath, + hasTestReport, + emptyStateImagePath, + artifactsExpiredImagePath, + testsCount, }, errorCaptured(err, _vm, info) { reportToSentry('pipeline_tabs', `error: ${err}, info: ${info}`); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index f0556f3d12e..b785fd1753c 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -30,7 +30,6 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => { dispatch('toggleLoading'); - // eslint-disable-next-line camelcase const { build_ids = [] } = state.testReports?.test_suites?.[index] || {}; // Replacing `/:suite_name.json` with the name of the suite. Including the extra characters // to ensure that we replace exactly the template part of the URL string diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue index bda58091b97..4ba7156b026 100644 --- a/app/assets/javascripts/projects/compare/components/app.vue +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -15,7 +15,11 @@ export default { type: String, required: true, }, - refsProjectPath: { + sourceProjectRefsPath: { + type: String, + required: true, + }, + targetProjectRefsPath: { type: String, required: true, }, @@ -37,7 +41,11 @@ export default { type: String, required: true, }, - defaultProject: { + sourceProject: { + type: Object, + required: true, + }, + targetProject: { type: Object, required: true, }, @@ -50,14 +58,14 @@ export default { return { from: { projects: this.projects, - selectedProject: this.defaultProject, + selectedProject: this.targetProject, revision: this.paramsFrom, - refsProjectPath: this.refsProjectPath, + refsProjectPath: this.targetProjectRefsPath, }, to: { - selectedProject: this.defaultProject, + selectedProject: this.sourceProject, revision: this.paramsTo, - refsProjectPath: this.refsProjectPath, + refsProjectPath: this.sourceProjectRefsPath, }, }; }, diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js index e485a086d39..074b8565c3c 100644 --- a/app/assets/javascripts/projects/compare/index.js +++ b/app/assets/javascripts/projects/compare/index.js @@ -5,13 +5,15 @@ export default function init() { const el = document.getElementById('js-compare-selector'); const { - refsProjectPath, + sourceProjectRefsPath, + targetProjectRefsPath, paramsFrom, paramsTo, projectCompareIndexPath, projectMergeRequestPath, createMrPath, - projectTo, + sourceProject, + targetProject, projectsFrom, } = el.dataset; @@ -23,13 +25,15 @@ export default function init() { render(createElement) { return createElement(CompareApp, { props: { - refsProjectPath, + sourceProjectRefsPath, + targetProjectRefsPath, paramsFrom, paramsTo, projectCompareIndexPath, projectMergeRequestPath, createMrPath, - defaultProject: JSON.parse(projectTo), + sourceProject: JSON.parse(sourceProject), + targetProject: JSON.parse(targetProject), projects: JSON.parse(projectsFrom), }, }); diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js index 28b77f6defd..0cfea401be6 100644 --- a/app/assets/javascripts/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -17,6 +17,7 @@ const mountPipelineChartsApp = (el) => { coverageChartPath, defaultBranch, testRunsEmptyStateImagePath, + projectQualitySummaryFeedbackImagePath, } = el.dataset; const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts); @@ -37,6 +38,7 @@ const mountPipelineChartsApp = (el) => { coverageChartPath, defaultBranch, testRunsEmptyStateImagePath, + projectQualitySummaryFeedbackImagePath, }, render: (createElement) => createElement(ProjectPipelinesCharts, {}), }); diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index fe84660422b..424ea3b61c5 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { debounce } from 'lodash'; import DEFAULT_PROJECT_TEMPLATES from 'any_else_ce/projects/default_project_templates'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import Tracking from '~/tracking'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants'; import { ENTER_KEY } from '../lib/utils/keys'; import axios from '../lib/utils/axios_utils'; @@ -109,8 +110,31 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { ); }; + const projectPathValueListener = () => { + // eslint-disable-next-line no-param-reassign + $projectPathInput.oldInputValue = $projectPathInput.value; + }; + + const projectPathTrackListener = () => { + if ($projectPathInput.oldInputValue === $projectPathInput.value) { + // no change made to the input + return; + } + + const trackEvent = 'user_input_path_slug'; + const trackCategory = undefined; // will be default set in event method + + Tracking.event(trackCategory, trackEvent, { + label: 'new_project_form', + }); + }; + $projectPathInput.removeEventListener('keyup', projectPathInputListener); $projectPathInput.addEventListener('keyup', projectPathInputListener); + $projectPathInput.removeEventListener('focus', projectPathValueListener); + $projectPathInput.addEventListener('focus', projectPathValueListener); + $projectPathInput.removeEventListener('blur', projectPathTrackListener); + $projectPathInput.addEventListener('blur', projectPathTrackListener); $projectPathInput.removeEventListener('change', projectPathInputListener); $projectPathInput.addEventListener('change', projectPathInputListener); diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue index 6bbe0ab7d5f..6ba2ef7da99 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue @@ -1,12 +1,24 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSearchBoxByType, + GlSprintf, + GlLink, +} from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { __, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; import branchesQuery from '../queries/branches.query.graphql'; export const i18n = { - fetchBranchesError: __('An error occurred while fetching branches.'), - noMatch: __('No matching results'), + fetchBranchesError: s__('BranchRules|An error occurred while fetching branches.'), + noMatch: s__('BranchRules|No matching results'), + branchHelpText: s__( + 'BranchRules|%{linkStart}Wildcards%{linkEnd} such as *-stable or production/* are supported.', + ), + wildCardSearchHelp: s__('BranchRules|Create wildcard: %{searchTerm}'), }; export default { @@ -17,6 +29,8 @@ export default { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, + GlSprintf, + GlLink, }, apollo: { branchNames: { @@ -39,6 +53,10 @@ export default { }, }, }, + searchInputDelay: 250, + wildcardsHelpPath: helpPagePath('user/project/protected_branches', { + anchor: 'configure-multiple-protected-branches-by-using-a-wildcard', + }), props: { projectPath: { type: String, @@ -58,7 +76,9 @@ export default { }, computed: { createButtonLabel() { - return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm }); + return sprintf(this.$options.i18n.wildCardSearchHelp, { + searchTerm: this.searchTerm, + }); }, shouldRenderCreateButton() { return this.searchTerm && !this.branchNames.includes(this.searchTerm); @@ -81,30 +101,37 @@ export default { }; </script> <template> - <gl-dropdown :text="value || branchNames[0]"> - <gl-search-box-by-type - v-model.trim="searchTerm" - data-testid="branch-search" - debounce="250" - :is-loading="isLoading" - /> - <gl-dropdown-item - v-for="branch in branchNames" - :key="branch" - :is-checked="isSelected(branch)" - is-check-item - @click="selectBranch(branch)" - > - {{ branch }} - </gl-dropdown-item> - <gl-dropdown-item v-if="!branchNames.length && !isLoading" data-testid="no-data">{{ - $options.i18n.noMatch - }}</gl-dropdown-item> - <template v-if="shouldRenderCreateButton"> - <gl-dropdown-divider /> - <gl-dropdown-item data-testid="create-wildcard-button" @click="createWildcard"> - {{ createButtonLabel }} + <div> + <gl-dropdown :text="value || branchNames[0]" class="gl-w-full"> + <gl-search-box-by-type + v-model.trim="searchTerm" + data-testid="branch-search" + :debounce="$options.searchInputDelay" + :is-loading="isLoading" + /> + <gl-dropdown-item + v-for="branch in branchNames" + :key="branch" + :is-checked="isSelected(branch)" + is-check-item + @click="selectBranch(branch)" + > + {{ branch }} </gl-dropdown-item> - </template> - </gl-dropdown> + <gl-dropdown-item v-if="!branchNames.length && !isLoading" data-testid="no-data">{{ + $options.i18n.noMatch + }}</gl-dropdown-item> + <template v-if="shouldRenderCreateButton"> + <gl-dropdown-divider /> + <gl-dropdown-item data-testid="create-wildcard-button" @click="createWildcard"> + {{ createButtonLabel }} + </gl-dropdown-item> + </template> + </gl-dropdown> + <gl-sprintf :message="$options.i18n.branchHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.wildcardsHelpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> </template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue new file mode 100644 index 00000000000..bcc0f64d667 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue @@ -0,0 +1,59 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import PushProtections from './push_protections.vue'; +import MergeProtections from './merge_protections.vue'; + +export const i18n = { + protections: s__('BranchRules|Protections'), + protectionsHelpText: s__( + 'BranchRules|Keep stable branches secure and force developers to use merge requests. %{linkStart}What are protected branches?%{linkEnd}', + ), +}; + +export default { + name: 'BranchProtections', + i18n, + components: { + GlSprintf, + GlLink, + PushProtections, + MergeProtections, + }, + protectedBranchesHelpPath: helpPagePath('user/project/protected_branches'), + props: { + protections: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <h4 class="gl-border-t gl-pt-4">{{ $options.i18n.protections }}</h4> + + <div data-testid="protections-help-text"> + <gl-sprintf :message="$options.i18n.protectionsHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.protectedBranchesHelpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + + <push-protections + class="gl-mt-5" + :members-allowed-to-push="protections.membersAllowedToPush" + :allow-force-push="protections.allowForcePush" + v-on="$listeners" + /> + + <merge-protections + :members-allowed-to-merge="protections.membersAllowedToMerge" + :require-code-owners-approval="protections.requireCodeOwnersApproval" + v-on="$listeners" + /> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue new file mode 100644 index 00000000000..85f168af4a8 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue @@ -0,0 +1,46 @@ +<script> +import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export const i18n = { + allowedToMerge: s__('BranchRules|Allowed to merge'), + requireApprovalTitle: s__('BranchRules|Require approval from code owners.'), + requireApprovalHelpText: s__( + 'BranchRules|Reject code pushes that change files listed in the CODEOWNERS file.', + ), +}; + +export default { + name: 'BranchMergeProtections', + i18n, + components: { + GlFormGroup, + GlFormCheckbox, + }, + props: { + membersAllowedToMerge: { + type: Array, + required: true, + }, + requireCodeOwnersApproval: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <gl-form-group :label="$options.i18n.allowedToMerge"> + <!-- TODO: add multi-select-dropdown (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) --> + + <gl-form-checkbox + class="gl-mt-5" + :checked="requireCodeOwnersApproval" + @change="$emit('change-require-code-owners-approval', $event)" + > + <span>{{ $options.i18n.requireApprovalTitle }}</span> + <template #help>{{ $options.i18n.requireApprovalHelpText }}</template> + </gl-form-checkbox> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue new file mode 100644 index 00000000000..541923bb735 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue @@ -0,0 +1,52 @@ +<script> +import { GlFormGroup, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const i18n = { + allowedToPush: s__('BranchRules|Allowed to push'), + forcePushTitle: s__( + 'BranchRules|Allow all users with push access to %{linkStart}force push%{linkEnd}.', + ), +}; + +export default { + name: 'BranchPushProtections', + i18n, + components: { + GlFormGroup, + GlSprintf, + GlLink, + GlFormCheckbox, + }, + forcePushHelpPath: helpPagePath('topics/git/git_rebase', { anchor: 'force-push' }), + props: { + membersAllowedToPush: { + type: Array, + required: true, + }, + allowForcePush: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <gl-form-group :label="$options.i18n.allowedToPush"> + <!-- TODO: add multi-select-dropdown (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) --> + + <gl-form-checkbox + class="gl-mt-5" + :checked="allowForcePush" + @change="$emit('change-allow-force-push', $event)" + > + <gl-sprintf :message="$options.i18n.forcePushTitle"> + <template #link="{ content }"> + <gl-link :href="$options.forcePushHelpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-form-checkbox> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue index c2e7f4e9b1b..ad3eb7d2899 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue @@ -1,15 +1,18 @@ <script> import { GlFormGroup } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import { getParameterByName } from '~/lib/utils/url_utility'; import BranchDropdown from './branch_dropdown.vue'; +import Protections from './protections/index.vue'; export default { name: 'RuleEdit', - i18n: { - branch: __('Branch'), + i18n: { branch: s__('BranchRules|Branch') }, + components: { + BranchDropdown, + GlFormGroup, + Protections, }, - components: { BranchDropdown, GlFormGroup }, props: { projectPath: { type: String, @@ -19,20 +22,35 @@ export default { data() { return { branch: getParameterByName('branch'), + protections: { + membersAllowedToPush: [], + allowForcePush: false, + membersAllowedToMerge: [], + requireCodeOwnersApproval: false, + }, }; }, }; </script> <template> - <gl-form-group :label="$options.i18n.branch"> - <branch-dropdown - id="branches" - v-model="branch" - class="gl-w-half" - :project-path="projectPath" - @createWildcard="branch = $event" + <div> + <gl-form-group :label="$options.i18n.branch"> + <branch-dropdown + id="branches" + v-model="branch" + class="gl-w-half" + :project-path="projectPath" + @createWildcard="branch = $event" + /> + </gl-form-group> + + <protections + :protections="protections" + @change-allowed-to-push-members="protections.membersAllowedToPush = $event" + @change-allow-force-push="protections.allowForcePush = $event" + @change-allowed-to-merge-members="protections.membersAllowedToMerge = $event" + @change-require-code-owners-approval="protections.requireCodeOwnersApproval = $event" /> - </gl-form-group> - <!-- TODO - Add branch protections (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) --> + </div> </template> diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index fcf81c9d1f7..2209172c06d 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -262,8 +262,8 @@ export default { const selectedUsers = this.preselectedItems .filter(({ type }) => type === LEVEL_TYPES.USER) - .map(({ user_id, name, username, avatar_url, type }) => ({ - id: user_id, + .map(({ user_id: id, name, username, avatar_url, type }) => ({ + id, name, username, avatar_url, diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue index fe968e74c6d..c13753da00b 100644 --- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue +++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue @@ -1,7 +1,13 @@ <script> import { GlFormGroup } from '@gitlab/ui'; -import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import produce from 'immer'; import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import searchNamespacesWhereUserCanTransferProjects from '../graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql'; + +const GROUPS_PER_PAGE = 25; export default { name: 'TransferProjectForm', @@ -11,14 +17,6 @@ export default { ConfirmDanger, }, props: { - groupNamespaces: { - type: Array, - required: true, - }, - userNamespaces: { - type: Array, - required: true, - }, confirmationPhrase: { type: String, required: true, @@ -28,19 +26,88 @@ export default { required: true, }, }, + apollo: { + currentUser: { + query: searchNamespacesWhereUserCanTransferProjects, + debounce: DEBOUNCE_DELAY, + variables() { + return { + search: this.searchTerm, + after: null, + first: GROUPS_PER_PAGE, + }; + }, + result() { + this.isLoadingMoreGroups = false; + this.isSearchLoading = false; + }, + }, + }, data() { - return { selectedNamespace: null }; + return { + currentUser: {}, + selectedNamespace: null, + isLoadingMoreGroups: false, + isSearchLoading: false, + searchTerm: '', + }; }, computed: { hasSelectedNamespace() { return Boolean(this.selectedNamespace?.id); }, + groupNamespaces() { + return this.currentUser.groups?.nodes?.map(this.formatNamespace) || []; + }, + userNamespaces() { + const { namespace } = this.currentUser; + + return namespace ? [this.formatNamespace(namespace)] : []; + }, + hasNextPageOfGroups() { + return this.currentUser.groups?.pageInfo?.hasNextPage || false; + }, }, methods: { handleSelect(selectedNamespace) { this.selectedNamespace = selectedNamespace; this.$emit('selectNamespace', selectedNamespace.id); }, + handleLoadMoreGroups() { + this.isLoadingMoreGroups = true; + + this.$apollo.queries.currentUser.fetchMore({ + variables: { + after: this.currentUser.groups.pageInfo.endCursor, + first: GROUPS_PER_PAGE, + }, + updateQuery( + previousResult, + { + fetchMoreResult: { + currentUser: { groups: newGroups }, + }, + }, + ) { + const previousGroups = previousResult.currentUser.groups; + + return produce(previousResult, (draftData) => { + draftData.currentUser.groups.nodes = [...previousGroups.nodes, ...newGroups.nodes]; + draftData.currentUser.groups.pageInfo = newGroups.pageInfo; + }); + }, + }); + }, + handleSearch(searchTerm) { + this.isSearchLoading = true; + this.searchTerm = searchTerm; + }, + formatNamespace({ id, fullName }) { + return { + id: getIdFromGraphQLId(id), + humanName: fullName, + }; + }, }, }; </script> @@ -53,11 +120,16 @@ export default { :group-namespaces="groupNamespaces" :user-namespaces="userNamespaces" :selected-namespace="selectedNamespace" + :has-next-page-of-groups="hasNextPageOfGroups" + :is-loading-more-groups="isLoadingMoreGroups" + :is-search-loading="isSearchLoading" + :should-filter-namespaces="false" @select="handleSelect" + @load-more-groups="handleLoadMoreGroups" + @search="handleSearch" /> </gl-form-group> <confirm-danger - button-class="qa-transfer-button" :disabled="!hasSelectedNamespace" :phrase="confirmationPhrase" :button-text="confirmButtonText" diff --git a/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql b/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql new file mode 100644 index 00000000000..d4bcb8c869c --- /dev/null +++ b/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql @@ -0,0 +1,24 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query searchNamespacesWhereUserCanTransferProjects( + $search: String = "" + $after: String = "" + $first: Int = null +) { + currentUser { + id + groups(permissionScope: TRANSFER_PROJECTS, search: $search, after: $after, first: $first) { + nodes { + id + fullName + } + pageInfo { + ...PageInfo + } + } + namespace { + id + fullName + } + } +} diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js index a5f720bffaa..bc1aff640d2 100644 --- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js +++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js @@ -1,36 +1,29 @@ import Vue from 'vue'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import TransferProjectForm from './components/transfer_project_form.vue'; -const prepareNamespaces = (rawNamespaces = '') => { - if (!rawNamespaces) { - return { groupNamespaces: [], userNamespaces: [] }; - } - - const data = JSON.parse(rawNamespaces); - return { - groupNamespaces: data?.group?.map(convertObjectPropsToCamelCase) || [], - userNamespaces: data?.user?.map(convertObjectPropsToCamelCase) || [], - }; -}; - export default () => { const el = document.querySelector('.js-transfer-project-form'); if (!el) { return false; } + Vue.use(VueApollo); + const { targetFormId = null, targetHiddenInputId = null, buttonText: confirmButtonText = '', phrase: confirmationPhrase = '', confirmDangerMessage = '', - namespaces = '', } = el.dataset; return new Vue({ el, + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), provide: { confirmDangerMessage, }, @@ -39,7 +32,6 @@ export default () => { props: { confirmButtonText, confirmationPhrase, - ...prepareNamespaces(namespaces), }, on: { selectNamespace: (id) => { diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index d02526160fd..1343ad8246c 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -256,6 +256,7 @@ export default { :error-message="i18n.branchesErrorMessage" :show-header="showSectionHeaders" data-testid="branches-section" + data-qa-selector="branches_section" @selected="selectRef($event)" /> 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 d765033d00b..102f1228355 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -208,7 +208,7 @@ export default { <p v-if="hasError" class="gl-field-error"> {{ addRelatedErrorMessage }} </p> - <div class="gl-mt-5 gl-clearfix"> + <div class="gl-mt-5"> <gl-button ref="addButton" category="primary" @@ -216,12 +216,13 @@ export default { :disabled="isSubmitButtonDisabled" :loading="isSubmitting" type="submit" - class="gl-float-left" + size="small" + class="gl-mr-2" data-qa-selector="add_issue_button" > {{ __('Add') }} </gl-button> - <gl-button class="gl-float-right" @click="onFormCancel"> + <gl-button size="small" @click="onFormCancel"> {{ __('Cancel') }} </gl-button> </div> 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 eeb4c254a1b..5b4a6d1fe0d 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -1,5 +1,6 @@ <script> import { GlLink, GlIcon, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; import { issuableIconMap, issuableQaClassMap, @@ -96,6 +97,11 @@ export default { default: true, }, }, + data() { + return { + isOpen: true, + }; + }, computed: { hasRelatedIssues() { return this.relatedIssues.length > 0; @@ -139,6 +145,21 @@ export default { qaClass() { return issuableQaClassMap[this.issuableType]; }, + toggleIcon() { + return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; + }, + toggleLabel() { + return this.isOpen ? __('Collapse') : __('Expand'); + }, + }, + methods: { + handleToggle() { + this.isOpen = !this.isOpen; + }, + addButtonClick(event) { + this.isOpen = true; + this.$emit('toggleAddRelatedIssuesForm', event); + }, }, linkedIssueTypesTextMap, }; @@ -148,12 +169,10 @@ export default { <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 }" - class="card-header gl-display-flex gl-justify-content-space-between" + :class="{ 'panel-empty-heading border-bottom-0': !hasBody, 'gl-border-b-0': !isOpen }" + class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-py-3 gl-px-5 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" > - <h3 - class="card-title h5 position-relative gl-my-0 gl-display-flex gl-align-items-center gl-h-7" - > + <h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1"> <gl-link id="user-content-related-issues" class="anchor position-absolute gl-text-decoration-none" @@ -172,30 +191,45 @@ export default { <gl-icon name="question" :size="12" /> </gl-link> - <div class="gl-display-inline-flex"> - <div class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-5"> - <span class="gl-display-inline-flex gl-align-items-center"> - <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" /> - {{ badgeLabel }} - </span> - </div> - <gl-button - v-if="canAdmin" - data-qa-selector="related_issues_plus_button" - icon="plus" - :aria-label="addIssuableButtonText" - :class="qaClass" - @click="$emit('toggleAddRelatedIssuesForm', $event)" - /> + <div class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-3"> + <span class="gl-display-inline-flex gl-align-items-center"> + <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" /> + {{ badgeLabel }} + </span> </div> </h3> <slot name="header-actions"></slot> + <gl-button + v-if="canAdmin" + size="small" + data-qa-selector="related_issues_plus_button" + data-testid="related-issues-plus-button" + :aria-label="addIssuableButtonText" + :class="qaClass" + class="gl-ml-3" + @click="addButtonClick" + > + <slot name="add-button-text">{{ __('Add') }}</slot> + </gl-button> + <div class="gl-pl-3 gl-ml-3 gl-border-l-1 gl-border-l-solid gl-border-l-gray-100"> + <gl-button + category="tertiary" + size="small" + :icon="toggleIcon" + :aria-label="toggleLabel" + :disabled="!hasRelatedIssues" + data-testid="toggle-links" + @click="handleToggle" + /> + </div> </div> <div - class="linked-issues-card-body bg-gray-light" + v-if="isOpen" + class="linked-issues-card-body gl-bg-gray-10" :class="{ 'gl-p-5': isFormVisible || shouldShowTokenBody, }" + data-testid="related-issues-body" > <div v-if="isFormVisible" 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 9ed895e90fb..11de734f5d4 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -5,7 +5,6 @@ import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue import { defaultSortableOptions } from '~/sortable/constants'; export default { - name: 'RelatedIssuesList', components: { GlLoadingIcon, RelatedIssuableItem, @@ -141,6 +140,7 @@ export default { :path-id-separator="pathIdSeparator" :is-locked="issue.lockIssueRemoval" :locked-message="issue.lockedMessage" + :work-item-type="issue.type" event-namespace="relatedIssue" data-qa-selector="related_issuable_content" @relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)" diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue index da049d68467..cad5037d7e4 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -24,6 +24,7 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways: */ import createFlash from '~/flash'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { relatedIssuesRemoveErrorMap, @@ -123,6 +124,14 @@ export default { return this.state.relatedIssues.find((issue) => issue.id === id); }, onRelatedIssueRemoveRequest(idToRemove) { + if (isGid(idToRemove)) { + const deletedId = getIdFromGraphQLId(idToRemove); + this.state.relatedIssues = this.state.relatedIssues.filter( + (issue) => issue.id !== deletedId, + ); + return; + } + const issueToRemove = this.findRelatedIssueById(idToRemove); if (issueToRemove) { diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js index 3516836952f..23ea93cd258 100644 --- a/app/assets/javascripts/related_issues/constants.js +++ b/app/assets/javascripts/related_issues/constants.js @@ -114,8 +114,8 @@ export const PathIdSeparator = { }; export const issuablesBlockHeaderTextMap = { - [issuableTypesMap.ISSUE]: __('Linked issues'), - [issuableTypesMap.INCIDENT]: __('Related incidents or issues'), + [issuableTypesMap.ISSUE]: __('Linked items'), + [issuableTypesMap.INCIDENT]: __('Linked incidents or issues'), [issuableTypesMap.EPIC]: __('Linked epics'), }; @@ -136,7 +136,7 @@ export const issuablesFormCategoryHeaderTextMap = { }; export const issuablesFormInputTextMap = { - [issuableTypesMap.ISSUE]: __('the following issue(s)'), - [issuableTypesMap.INCIDENT]: __('the following incident(s) or issue(s)'), - [issuableTypesMap.EPIC]: __('the following epic(s)'), + [issuableTypesMap.ISSUE]: __('the following issues'), + [issuableTypesMap.INCIDENT]: __('the following incidents or issues'), + [issuableTypesMap.EPIC]: __('the following epics'), }; diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js index 655ec57bc3d..eb2f5d119b8 100644 --- a/app/assets/javascripts/related_issues/index.js +++ b/app/assets/javascripts/related_issues/index.js @@ -1,30 +1,33 @@ import Vue from 'vue'; +import apolloProvider from '~/issues/show/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import RelatedIssuesRoot from './components/related_issues_root.vue'; -export default function initRelatedIssues(issueType = 'issue') { - const relatedIssuesRootElement = document.querySelector('.js-related-issues-root'); - if (relatedIssuesRootElement) { - // eslint-disable-next-line no-new - new Vue({ - el: relatedIssuesRootElement, - name: 'RelatedIssuesRoot', - components: { - relatedIssuesRoot: RelatedIssuesRoot, - }, - render: (createElement) => - createElement('related-issues-root', { - props: { - endpoint: relatedIssuesRootElement.dataset.endpoint, - canAdmin: parseBoolean(relatedIssuesRootElement.dataset.canAddRelatedIssues), - helpPath: relatedIssuesRootElement.dataset.helpPath, - showCategorizedIssues: parseBoolean( - relatedIssuesRootElement.dataset.showCategorizedIssues, - ), - issuableType: issueType, - autoCompleteEpics: false, - }, - }), - }); +export function initRelatedIssues(issueType = 'issue') { + const el = document.querySelector('.js-related-issues-root'); + + if (!el) { + return null; } + + return new Vue({ + el, + name: 'RelatedIssuesRoot', + apolloProvider, + provide: { + fullPath: el.dataset.fullPath, + hasIssueWeightsFeature: parseBoolean(el.dataset.hasIssueWeightsFeature), + }, + render: (createElement) => + createElement(RelatedIssuesRoot, { + props: { + endpoint: el.dataset.endpoint, + canAdmin: parseBoolean(el.dataset.canAddRelatedIssues), + helpPath: el.dataset.helpPath, + showCategorizedIssues: parseBoolean(el.dataset.showCategorizedIssues), + issuableType: issueType, + autoCompleteEpics: false, + }, + }), + }); } diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 022c3224bb4..dd3f4ed636f 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -128,8 +128,13 @@ export default { async mounted() { await this.initializeRelease(); - // Focus the first non-disabled input or button element - this.$el.querySelector('input:enabled, button:enabled').focus(); + if (this.release?.tagName) { + // Focus the release title input if a tag was preselected + this.$refs.releaseTitleInput.$el.focus(); + } else { + // Focus the first non-disabled input or button element otherwise + this.$el.querySelector('input:enabled, button:enabled').focus(); + } }, methods: { ...mapActions('editNew', [ diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index b81da399a7b..7c6d44456d9 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -209,7 +209,7 @@ export default { :id="`asset-type-${index}`" ref="typeSelect" :value="link.linkType || $options.defaultTypeOptionValue" - class="form-control pr-4" + class="pr-4" name="asset-type" :options="$options.typeOptions" @change="updateAssetLinkType({ linkIdToUpdate: link.id, newType: $event })" diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue index def38780545..070865cf84b 100644 --- a/app/assets/javascripts/releases/components/release_block_header.vue +++ b/app/assets/javascripts/releases/components/release_block_header.vue @@ -7,6 +7,10 @@ import { BACK_URL_PARAM } from '~/releases/constants'; export default { i18n: { editButton: __('Edit this release'), + historical: __('Historical release'), + historicalTooltip: __( + 'This release was created with a date in the past. Evidence collection at the moment of the release is unavailable.', + ), }, name: 'ReleaseBlockHeader', components: { @@ -65,6 +69,14 @@ export default { <gl-badge v-if="release.upcomingRelease" variant="warning" class="align-middle">{{ __('Upcoming Release') }}</gl-badge> + <gl-badge + v-else-if="release.historicalRelease" + v-gl-tooltip + :title="$options.i18n.historicalTooltip" + class="gl-vertical-align-middle" + > + {{ $options.i18n.historical }} + </gl-badge> </h2> <gl-button v-if="editLink" diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql index e0de6d12b13..e22726f27a7 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql @@ -1,5 +1,4 @@ fragment Release on Release { - __typename id name tagName @@ -8,21 +7,17 @@ fragment Release on Release { releasedAt createdAt upcomingRelease + historicalRelease assets { - __typename count sources { - __typename nodes { - __typename format url } } links { - __typename nodes { - __typename id name url @@ -33,9 +28,7 @@ fragment Release on Release { } } evidences { - __typename nodes { - __typename id filepath collectedAt @@ -43,7 +36,6 @@ fragment Release on Release { } } links { - __typename editUrl selfUrl openedIssuesUrl @@ -53,29 +45,24 @@ fragment Release on Release { closedMergeRequestsUrl } commit { - __typename id sha webUrl title } author { - __typename id webUrl avatarUrl username } milestones { - __typename nodes { - __typename id title description webPath stats { - __typename totalIssuesCount closedIssuesCount } diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql index 61a06f268bd..1e3d31c86bf 100644 --- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql @@ -1,3 +1,5 @@ +#import "../fragments/release.fragment.graphql" + query allReleases( $fullPath: ID! $first: Int @@ -7,96 +9,12 @@ query allReleases( $sort: ReleaseSort ) { project(fullPath: $fullPath) { - __typename id releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) { - __typename nodes { - __typename - id - name - tagName - tagPath - descriptionHtml - releasedAt - createdAt - upcomingRelease - assets { - __typename - count - sources { - __typename - nodes { - __typename - format - url - } - } - links { - __typename - nodes { - __typename - id - name - url - directAssetUrl - linkType - external - } - } - } - evidences { - __typename - nodes { - __typename - id - filepath - collectedAt - sha - } - } - links { - __typename - editUrl - selfUrl - openedIssuesUrl - closedIssuesUrl - openedMergeRequestsUrl - mergedMergeRequestsUrl - closedMergeRequestsUrl - } - commit { - __typename - id - sha - webUrl - title - } - author { - __typename - id - webUrl - avatarUrl - username - } - milestones { - __typename - nodes { - __typename - id - title - description - webPath - stats { - __typename - totalIssuesCount - closedIssuesCount - } - } - } + ...Release } pageInfo { - __typename startCursor hasPreviousPage hasNextPage diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js index f1f5f4bca4c..a1027ef08d7 100644 --- a/app/assets/javascripts/releases/util.js +++ b/app/assets/javascripts/releases/util.js @@ -12,6 +12,7 @@ const convertScalarProperties = (graphQLRelease) => 'description', 'descriptionHtml', 'upcomingRelease', + 'historicalRelease', ]); const convertDateProperties = ({ releasedAt }) => ({ diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 0714d88b392..6061be465ca 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -206,7 +206,6 @@ export default { <gl-button v-if="isCollapsible" - class="js-collapse-btn" data-testid="report-section-expand-button" data-qa-selector="expand_report_button" @click="toggleCollapsed" diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index bf4f19504f0..7999b916e0f 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -8,7 +8,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import { redirectTo } from '~/lib/utils/url_utility'; +import { redirectTo, getLocationHash } from '~/lib/utils/url_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; import CodeIntelligence from '~/code_navigation/components/app.vue'; @@ -63,6 +63,28 @@ export default { }; }, result() { + const urlHash = getLocationHash(); + const plain = this.$route?.query?.plain; + + // When the 'plain' URL param is present, its value determines which viewer to render: + // - when 0 and the rich viewer is available we render with it + // - otherwise we render the simple viewer + if (plain !== undefined) { + if (plain === '0' && this.hasRichViewer) { + this.switchViewer(RICH_BLOB_VIEWER); + } else { + this.switchViewer(SIMPLE_BLOB_VIEWER); + } + return; + } + + // If there is a code line hash in the URL we render with the simple viewer + if (urlHash && urlHash.startsWith('L')) { + this.switchViewer(SIMPLE_BLOB_VIEWER); + return; + } + + // By default, if present, use the rich viewer to render this.switchViewer(this.hasRichViewer ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER); }, error() { @@ -173,6 +195,21 @@ export default { return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE; }, }, + watch: { + // Watch the URL 'plain' query value to know if the viewer needs changing. + // This is the case when the user switches the viewer and then goes back + // through the hystory. + '$route.query.plain': { + handler(plainValue) { + this.switchViewer( + this.hasRichViewer && (plainValue === undefined || plainValue === '0') + ? RICH_BLOB_VIEWER + : SIMPLE_BLOB_VIEWER, + plainValue !== undefined, + ); + }, + }, + }, methods: { onError() { this.useFallback = true; @@ -189,15 +226,10 @@ export default { axios .get(`${this.blobInfo.webPath}?format=json&viewer=${type}`) .then(async ({ data: { html, binary } }) => { - if (type === SIMPLE_BLOB_VIEWER) { - this.isRenderingLegacyTextViewer = true; + this.isRenderingLegacyTextViewer = true; + if (type === SIMPLE_BLOB_VIEWER) { this.legacySimpleViewer = html; - - window.requestIdleCallback(() => { - this.isRenderingLegacyTextViewer = false; - new LineHighlighter(); // eslint-disable-line no-new - }); } else { this.legacyRichViewer = html; } @@ -205,6 +237,14 @@ export default { this.isBinary = binary; this.isLoadingLegacyViewer = false; + window.requestIdleCallback(() => { + this.isRenderingLegacyTextViewer = false; + + if (type === SIMPLE_BLOB_VIEWER) { + new LineHighlighter(); // eslint-disable-line no-new + } + }); + await this.$nextTick(); handleLocationHash(); // Ensures that we scroll to the hash when async content is loaded }) @@ -220,6 +260,22 @@ export default { this.loadLegacyViewer(); } }, + updateRouteQuery() { + const plain = this.activeViewerType === SIMPLE_BLOB_VIEWER ? '1' : '0'; + + if (this.$route?.query?.plain === plain) { + return; + } + + this.$router.push({ + path: this.$route.path, + query: { ...this.$route.query, plain }, + }); + }, + handleViewerChanged(newViewer) { + this.switchViewer(newViewer); + this.updateRouteQuery(); + }, editBlob(target) { if (this.showForkSuggestion) { this.setForkTarget(target); @@ -251,7 +307,7 @@ export default { :has-render-error="hasRenderError" :show-path="false" :override-copy="glFeatures.highlightJs" - @viewer-changed="switchViewer" + @viewer-changed="handleViewerChanged" @copy="onCopy" > <template #actions> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 9f2cf8505d3..7f408485326 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -196,12 +196,9 @@ export default { </gl-link> </div> <gl-button-group class="gl-ml-4 js-commit-sha-group"> - <gl-button - label - class="gl-font-monospace" - data-testid="last-commit-id-label" - v-text="showCommitId" - /> + <gl-button label class="gl-font-monospace" data-testid="last-commit-id-label">{{ + showCommitId + }}</gl-button> <clipboard-button :text="commit.sha" :title="__('Copy commit SHA')" diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 0e80f306638..77d3a517d28 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -94,7 +94,6 @@ export const LFS_STORAGE = 'lfs'; */ export const LEGACY_FILE_TYPES = [ 'gemfile', - 'gemspec', 'composer_json', 'podfile', 'podspec', diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 8baee80e5d6..45a7793e559 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -27,6 +27,7 @@ query getBlobInfo( fileType language path + blamePath editBlobPath gitpodBlobUrl ideEditPath diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 9de67015094..3256e13f4da 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -3,9 +3,7 @@ import $ from 'jquery'; import { setCookie } from '~/lib/utils/common_utils'; import { hide, fixTitle } from '~/tooltips'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { sprintf, s__, __ } from './locale'; +import { __ } from './locale'; const updateSidebarClasses = (layoutPage, rightSidebar) => { if (window.innerWidth >= 992) { @@ -20,7 +18,6 @@ const updateSidebarClasses = (layoutPage, rightSidebar) => { }; function Sidebar() { - this.toggleTodo = this.toggleTodo.bind(this); this.sidebar = $('aside'); this.removeListeners(); @@ -54,7 +51,6 @@ Sidebar.prototype.addEventListeners = function () { this.sidebar.on('hiddenGlDropdown', this, this.onSidebarDropdownHidden); $document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked); - $(document).off('click', '.js-issuable-todo').on('click', '.js-issuable-todo', this.toggleTodo); if (window.gon?.features?.movedMrSidebar) { const layoutPage = document.querySelector('.layout-page'); @@ -105,32 +101,6 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { } }; -Sidebar.prototype.toggleTodo = function (e) { - const $this = $(e.currentTarget); - const ajaxType = $this.data('deletePath') ? 'delete' : 'post'; - const url = String($this.data('deletePath') || $this.data('createPath')); - - hide($this); - - $('.js-issuable-todo').disable().addClass('is-loading'); - - axios[ajaxType](url, { - issuable_id: $this.data('issuableId'), - issuable_type: $this.data('issuableType'), - }) - .then(({ data }) => { - this.todoUpdateDone(data); - }) - .catch(() => - createFlash({ - message: sprintf(__('There was an error %{message} to-do item.'), { - message: - ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'), - }), - }), - ); -}; - Sidebar.prototype.sidebarCollapseClicked = function (e) { if ($(e.currentTarget).hasClass('js-dont-change-state')) { return; 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 f6b7a8b46d7..777a332333d 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -12,10 +12,12 @@ import { isSearchFiltered, } from 'ee_else_ce/runner/runner_search_utils'; import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql'; +import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerBulkDelete from '../components/runner_bulk_delete.vue'; +import RunnerBulkDeleteCheckbox from '../components/runner_bulk_delete_checkbox.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerListEmptyState from '../components/runner_list_empty_state.vue'; import RunnerName from '../components/runner_name.vue'; @@ -37,6 +39,7 @@ export default { RegistrationDropdown, RunnerFilteredSearchBar, RunnerBulkDelete, + RunnerBulkDeleteCheckbox, RunnerList, RunnerListEmptyState, RunnerName, @@ -138,11 +141,15 @@ export default { onToggledPaused() { // When a runner becomes Paused, the tab count can // become stale, refetch outdated counts. - this.$refs['runner-type-tabs'].refetch(); + this.refetchCounts(); }, onDeleted({ message }) { + this.refetchCounts(); this.$root.$toast?.show(message); }, + refetchCounts() { + this.$apollo.getClient().refetchQueries({ include: [allRunnersCountQuery] }); + }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -152,6 +159,9 @@ export default { isChecked, }); }, + onPaginationInput(value) { + this.search.pagination = value; + }, }, filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, @@ -163,7 +173,6 @@ export default { class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > <runner-type-tabs - ref="runner-type-tabs" v-model="search" :count-scope="$options.INSTANCE_TYPE" :count-variables="countVariables" @@ -196,13 +205,20 @@ export default { :filtered-svg-path="emptyStateFilteredSvgPath" /> <template v-else> - <runner-bulk-delete v-if="isBulkDeleteEnabled" /> + <runner-bulk-delete + v-if="isBulkDeleteEnabled" + :runners="runners.items" + @deleted="onDeleted" + /> <runner-list :runners="runners.items" :loading="runnersLoading" :checkable="isBulkDeleteEnabled" @checked="onChecked" > + <template v-if="isBulkDeleteEnabled" #head-checkbox> + <runner-bulk-delete-checkbox :runners="runners.items" /> + </template> <template #runner-name="{ runner }"> <gl-link :href="runner.adminUrl"> <runner-name :runner="runner" /> @@ -217,11 +233,13 @@ export default { /> </template> </runner-list> - <runner-pagination - v-model="search.pagination" - class="gl-mt-3" - :page-info="runners.pageInfo" - /> </template> + + <runner-pagination + class="gl-mt-3" + :disabled="runnersLoading" + :page-info="runners.pageInfo" + @input="onPaginationInput" + /> </div> </template> diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue index 1eb383a1904..1cd098d6713 100644 --- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue @@ -59,7 +59,11 @@ export default { <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="description"> {{ description }} </tooltip-on-truncate> - <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress"> + <tooltip-on-truncate + v-if="ipAddress" + class="gl-display-block gl-text-truncate" + :title="ipAddress" + > <span class="gl-md-display-none gl-lg-display-inline">{{ __('IP Address') }}</span> <strong>{{ ipAddress }}</strong> </tooltip-on-truncate> diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue index 38bdfecb7df..2fa87bdd776 100644 --- a/app/assets/javascripts/runner/components/runner_assigned_item.vue +++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue @@ -1,10 +1,11 @@ <script> -import { GlAvatar, GlLink } from '@gitlab/ui'; +import { GlAvatar, GlBadge, GlLink } from '@gitlab/ui'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; export default { components: { GlAvatar, + GlBadge, GlLink, }, props: { @@ -25,6 +26,16 @@ export default { required: false, default: null, }, + description: { + type: String, + required: false, + default: null, + }, + isOwner: { + type: Boolean, + required: false, + default: false, + }, }, AVATAR_SHAPE_OPTION_RECT, }; @@ -41,7 +52,12 @@ export default { :size="48" /> </gl-link> - - <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link> + <div> + <div class="gl-mb-1"> + <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link> + <gl-badge v-if="isOwner" variant="info">{{ s__('Runner|Owner') }}</gl-badge> + </div> + <div v-if="description">{{ description }}</div> + </div> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/runner/components/runner_bulk_delete.vue index 50791de0bda..703da01d9c8 100644 --- a/app/assets/javascripts/runner/components/runner_bulk_delete.vue +++ b/app/assets/javascripts/runner/components/runner_bulk_delete.vue @@ -1,21 +1,31 @@ <script> -import { GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui'; -import { n__, sprintf } from '~/locale'; -import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; -import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { GlButton, GlModalDirective, GlModal, GlSprintf } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { __, s__, n__, sprintf } from '~/locale'; import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; +import BulkRunnerDelete from '../graphql/list/bulk_runner_delete.mutation.graphql'; +import { RUNNER_TYPENAME } from '../constants'; export default { components: { GlButton, + GlModal, GlSprintf, }, directives: { GlModal: GlModalDirective, }, inject: ['localMutations'], + props: { + runners: { + type: Array, + default: () => [], + required: false, + }, + }, data() { return { + isDeleting: false, checkedRunnerIds: [], }; }, @@ -25,8 +35,13 @@ export default { }, }, computed: { + currentCheckedRunnerIds() { + return this.runners + .map(({ id }) => id) + .filter((id) => this.checkedRunnerIds.indexOf(id) >= 0); + }, checkedCount() { - return this.checkedRunnerIds.length || 0; + return this.currentCheckedRunnerIds.length || 0; }, bannerMessage() { return sprintf( @@ -43,48 +58,103 @@ export default { modalTitle() { return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount); }, - modalHtmlMessage() { + modalActionPrimary() { + return { + text: n__( + 'Runners|Permanently delete %d runner', + 'Runners|Permanently delete %d runners', + this.checkedCount, + ), + attributes: { + loading: this.isDeleting, + variant: 'danger', + }, + }; + }, + modalActionCancel() { + return { + text: __('Cancel'), + attributes: { + loading: this.isDeleting, + }, + }; + }, + modalMessage() { return sprintf( n__( 'Runners|%{strongStart}%{count}%{strongEnd} runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', 'Runners|%{strongStart}%{count}%{strongEnd} runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', this.checkedCount, ), - { - strongStart: '<strong>', - strongEnd: '</strong>', - count: this.checkedCount, - }, - false, + { count: this.checkedCount }, ); }, - primaryBtnText() { + }, + methods: { + toastConfirmationMessage(deletedCount) { return n__( - 'Runners|Permanently delete %d runner', - 'Runners|Permanently delete %d runners', - this.checkedCount, + 'Runners|%d selected runner deleted', + 'Runners|%d selected runners deleted', + deletedCount, ); }, - }, - methods: { onClearChecked() { this.localMutations.clearChecked(); }, - onClickDelete: ignoreWhilePending(async function onClickDelete() { - const confirmed = await confirmAction(null, { - title: this.modalTitle, - modalHtmlMessage: this.modalHtmlMessage, - primaryBtnVariant: 'danger', - primaryBtnText: this.primaryBtnText, - }); + async onConfirmDelete(e) { + this.isDeleting = true; + e.preventDefault(); // don't close modal until deletion is complete + + try { + await this.$apollo.mutate({ + mutation: BulkRunnerDelete, + variables: { + input: { + ids: this.currentCheckedRunnerIds, + }, + }, + update: (cache, { data }) => { + const { errors, deletedIds } = data.bulkRunnerDelete; + + if (errors?.length) { + this.onError(new Error(errors.join(' '))); + this.$refs.modal.hide(); + return; + } + + this.$emit('deleted', { + message: this.toastConfirmationMessage(deletedIds.length), + }); - if (confirmed) { - // TODO Call $apollo.mutate with list of runner - // ids in `this.checkedRunnerIds`. - // See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/ + // Clean up + + // Remove deleted runners from the cache + deletedIds.forEach((id) => { + const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id }); + cache.evict({ id: cacheId }); + }); + cache.gc(); + + this.$refs.modal.hide(); + }, + }); + } catch (error) { + this.onError(error); + } finally { + this.isDeleting = false; } - }), + }, + onError(error) { + createAlert({ + message: s__( + 'Runners|Something went wrong while deleting. Please refresh the page to try again.', + ), + captureError: true, + error, + }); + }, }, + BULK_DELETE_MODAL_ID: 'bulk-delete-modal', }; </script> @@ -99,13 +169,28 @@ export default { </gl-sprintf> </div> <div class="gl-ml-auto"> - <gl-button data-testid="clear-btn" variant="default" @click="onClearChecked">{{ + <gl-button variant="default" @click="onClearChecked">{{ s__('Runners|Clear selection') }}</gl-button> - <gl-button data-testid="delete-btn" variant="danger" @click="onClickDelete">{{ + <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{ s__('Runners|Delete selected') }}</gl-button> </div> </div> + <gl-modal + ref="modal" + size="sm" + :modal-id="$options.BULK_DELETE_MODAL_ID" + :title="modalTitle" + :action-primary="modalActionPrimary" + :action-cancel="modalActionCancel" + @primary="onConfirmDelete" + > + <gl-sprintf :message="modalMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue new file mode 100644 index 00000000000..dde5a5a4a05 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue @@ -0,0 +1,59 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; + +export default { + components: { + GlFormCheckbox, + }, + inject: ['localMutations'], + props: { + runners: { + type: Array, + default: () => [], + required: false, + }, + }, + data() { + return { + checkedRunnerIds: [], + }; + }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + }, + }, + computed: { + disabled() { + return !this.runners.length; + }, + checked() { + return Boolean(this.runners.length) && this.runners.every(this.isChecked); + }, + indeterminate() { + return !this.checked && this.runners.some(this.isChecked); + }, + }, + methods: { + isChecked({ id }) { + return this.checkedRunnerIds.indexOf(id) >= 0; + }, + onChange($event) { + this.localMutations.setRunnersChecked({ + runners: this.runners, + isChecked: $event, + }); + }, + }, +}; +</script> + +<template> + <gl-form-checkbox + :indeterminate="indeterminate" + :checked="checked" + :disabled="disabled" + @change="onChange" + /> +</template> diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue index db67acef3db..584f77b7648 100644 --- a/app/assets/javascripts/runner/components/runner_detail.vue +++ b/app/assets/javascripts/runner/components/runner_detail.vue @@ -38,11 +38,10 @@ export default { </script> <template> - <div class="gl-display-flex gl-pb-4"> - <dt class="gl-mr-2">{{ label }}</dt> - <dd class="gl-mb-0"> - <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> - <template v-if="value || $slots.value"> + <div class="gl-display-contents"> + <dt class="gl-mb-5 gl-mr-6 gl-max-w-26">{{ label }}</dt> + <dd class="gl-mb-5"> + <template v-if="value || $scopedSlots.value"> <slot name="value">{{ value }}</slot> </template> <span v-else class="gl-text-gray-500">{{ emptyValue }}</span> diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue index 60469d26dd5..d5222f39b81 100644 --- a/app/assets/javascripts/runner/components/runner_details.vue +++ b/app/assets/javascripts/runner/components/runner_details.vue @@ -51,6 +51,9 @@ export default { } return null; }, + tagList() { + return this.runner.tagList || []; + }, isGroupRunner() { return this.runner?.runnerType === GROUP_TYPE; }, @@ -66,14 +69,17 @@ export default { <div> <runner-upgrade-status-alert class="gl-my-4" :runner="runner" /> <div class="gl-pt-4"> - <dl class="gl-mb-0" data-testid="runner-details-list"> + <dl + class="gl-mb-0 gl-display-grid runner-details-grid-template" + data-testid="runner-details-list" + > <runner-detail :label="s__('Runners|Description')" :value="runner.description" /> <runner-detail :label="s__('Runners|Last contact')" :empty-value="s__('Runners|Never contacted')" > - <template #value> - <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> + <template v-if="runner.contactedAt" #value> + <time-ago :time="runner.contactedAt" /> </template> </runner-detail> <runner-detail :label="s__('Runners|Version')"> @@ -87,8 +93,8 @@ export default { <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" /> <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" /> <runner-detail :label="s__('Runners|Configuration')"> - <template #value> - <gl-intersperse v-if="configTextProtected || configTextUntagged"> + <template v-if="configTextProtected || configTextUntagged" #value> + <gl-intersperse> <span v-if="configTextProtected">{{ configTextProtected }}</span> <span v-if="configTextUntagged">{{ configTextUntagged }}</span> </gl-intersperse> @@ -96,13 +102,8 @@ export default { </runner-detail> <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" /> <runner-detail :label="s__('Runners|Tags')"> - <template #value> - <runner-tags - v-if="runner.tagList && runner.tagList.length" - class="gl-vertical-align-middle" - :tag-list="runner.tagList" - size="sm" - /> + <template v-if="tagList.length" #value> + <runner-tags class="gl-vertical-align-middle" :tag-list="tagList" size="sm" /> </template> </runner-detail> diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue index bff5ec9b238..5a9ab21a457 100644 --- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -64,19 +64,19 @@ export default { }, methods: { onFilter(filters) { - // Apply new filters, from page 1 + // Apply new filters, resetting pagination this.$emit('input', { ...this.value, filters, - pagination: { page: 1 }, + pagination: {}, }); }, onSort(sort) { - // Apply new sort, from page 1 + // Apply new sort, resetting pagination this.$emit('input', { ...this.value, sort, - pagination: { page: 1 }, + pagination: {}, }); }, }, diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue index 57afdc4b9be..9003eba3636 100644 --- a/app/assets/javascripts/runner/components/runner_jobs.vue +++ b/app/assets/javascripts/runner/components/runner_jobs.vue @@ -27,9 +27,7 @@ export default { items: [], pageInfo: {}, }, - pagination: { - page: 1, - }, + pagination: {}, }; }, apollo: { @@ -62,6 +60,11 @@ export default { return this.$apollo.queries.jobs.loading; }, }, + methods: { + onPaginationInput(value) { + this.pagination = value; + }, + }, I18N_NO_JOBS_FOUND, }; </script> @@ -74,6 +77,6 @@ export default { <runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" /> <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p> - <runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" /> + <runner-pagination :disabled="loading" :page-info="jobs.pageInfo" @input="onPaginationInput" /> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index f1f99c728c5..2e406f71792 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -1,5 +1,5 @@ <script> -import { GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; +import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; @@ -23,6 +23,7 @@ const defaultFields = [ export default { components: { + GlFormCheckbox, GlTableLite, GlSkeletonLoader, TooltipOnTruncate, @@ -123,19 +124,11 @@ export default { fixed > <template #head(checkbox)> - <!-- - Checkbox to select all to be added here - See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/ - --> - <span></span> + <slot name="head-checkbox"></slot> </template> <template #cell(checkbox)="{ item }"> - <input - type="checkbox" - :checked="isChecked(item)" - @change="onCheckboxChange(item, $event.target.checked)" - /> + <gl-form-checkbox :checked="isChecked(item)" @change="onCheckboxChange(item, $event)" /> </template> <template #head(status)="{ label }"> diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue index cfc21d1407b..a5bf3074dd1 100644 --- a/app/assets/javascripts/runner/components/runner_pagination.vue +++ b/app/assets/javascripts/runner/components/runner_pagination.vue @@ -1,18 +1,12 @@ <script> -import { GlPagination } from '@gitlab/ui'; +import { GlKeysetPagination } from '@gitlab/ui'; export default { components: { - GlPagination, + GlKeysetPagination, }, + inheritAttrs: false, props: { - value: { - required: false, - type: Object, - default: () => ({ - page: 1, - }), - }, pageInfo: { required: false, type: Object, @@ -20,46 +14,37 @@ export default { }, }, computed: { - prevPage() { - return this.pageInfo?.hasPreviousPage ? this.value.page - 1 : null; + paginationProps() { + return { ...this.pageInfo, ...this.$attrs }; }, - nextPage() { - return this.pageInfo?.hasNextPage ? this.value.page + 1 : null; + isShown() { + const { hasPreviousPage, hasNextPage } = this.pageInfo; + return hasPreviousPage || hasNextPage; }, }, methods: { - handlePageChange(page) { - if (page === 1) { - // Small optimization for first page - // If we have loaded using "first", - // page is already cached. - this.$emit('input', { - page, - }); - } else if (page > this.value.page) { - this.$emit('input', { - page, - after: this.pageInfo.endCursor, - }); - } else { - this.$emit('input', { - page, - before: this.pageInfo.startCursor, - }); - } + prevPage() { + this.$emit('input', { + before: this.pageInfo.startCursor, + }); + }, + nextPage() { + this.$emit('input', { + after: this.pageInfo.endCursor, + }); }, }, }; </script> <template> - <gl-pagination - v-bind="$attrs" - :value="value.page" - :prev-page="prevPage" - :next-page="nextPage" - align="center" - class="gl-pagination" - @input="handlePageChange" - /> + <div v-if="isShown" class="gl-text-center"> + <gl-keyset-pagination + v-bind="paginationProps" + :prev-text="s__('Pagination|Prev')" + :next-text="s__('Pagination|Next')" + @prev="prevPage" + @next="nextPage" + /> + </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue index c0c0c14e91e..2c1d2fc2b10 100644 --- a/app/assets/javascripts/runner/components/runner_projects.vue +++ b/app/assets/javascripts/runner/components/runner_projects.vue @@ -30,13 +30,12 @@ export default { data() { return { projects: { + ownerProjectId: null, items: [], pageInfo: {}, count: 0, }, - pagination: { - page: 1, - }, + pagination: {}, }; }, apollo: { @@ -48,6 +47,7 @@ export default { update(data) { const { runner } = data; return { + ownerProjectId: runner?.ownerProject?.id, count: runner?.projectCount || 0, items: runner?.projects?.nodes || [], pageInfo: runner?.projects?.pageInfo || {}, @@ -76,6 +76,14 @@ export default { }); }, }, + methods: { + isOwner(projectId) { + return projectId === this.projects.ownerProjectId; + }, + onPaginationInput(value) { + this.pagination = value; + }, + }, I18N_NONE, }; </script> @@ -98,10 +106,16 @@ export default { :name="project.name" :full-name="project.nameWithNamespace" :avatar-url="project.avatarUrl" + :description="project.description" + :is-owner="isOwner(project.id)" /> </template> <span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span> - <runner-pagination v-model="pagination" :disabled="loading" :page-info="projects.pageInfo" /> + <runner-pagination + :disabled="loading" + :page-info="projects.pageInfo" + @input="onPaginationInput" + /> </div> </template> diff --git a/app/assets/javascripts/runner/components/stat/runner_count.vue b/app/assets/javascripts/runner/components/stat/runner_count.vue index af18b203f90..37c6f922f9a 100644 --- a/app/assets/javascripts/runner/components/stat/runner_count.vue +++ b/app/assets/javascripts/runner/components/stat/runner_count.vue @@ -1,8 +1,9 @@ <script> import { fetchPolicies } from '~/lib/graphql'; +import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql'; +import groupRunnersCountQuery from 'ee_else_ce/runner/graphql/list/group_runners_count.query.graphql'; + import { captureException } from '../../sentry_utils'; -import allRunnersCountQuery from '../../graphql/list/all_runners_count.query.graphql'; -import groupRunnersCountQuery from '../../graphql/list/group_runners_count.query.graphql'; import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants'; /** @@ -38,7 +39,7 @@ export default { variables: { type: Object, required: false, - default: () => {}, + default: () => ({}), }, skip: { type: Boolean, diff --git a/app/assets/javascripts/runner/components/stat/runner_single_stat.vue b/app/assets/javascripts/runner/components/stat/runner_single_stat.vue new file mode 100644 index 00000000000..ae732b052ac --- /dev/null +++ b/app/assets/javascripts/runner/components/stat/runner_single_stat.vue @@ -0,0 +1,41 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { formatNumber } from '~/locale'; +import RunnerCount from './runner_count.vue'; + +export default { + components: { + GlSingleStat, + RunnerCount, + }, + props: { + scope: { + type: String, + required: true, + }, + variables: { + type: Object, + required: false, + default: () => ({}), + }, + skip: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + formattedValue(value) { + if (typeof value === 'number') { + return formatNumber(value); + } + return '-'; + }, + }, +}; +</script> +<template> + <runner-count #default="{ count }" :scope="scope" :variables="variables" :skip="skip"> + <gl-single-stat v-bind="$attrs" :value="formattedValue(count)" /> + </runner-count> +</template> diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue index 9e1ca9ba4ee..93e54ebe7f4 100644 --- a/app/assets/javascripts/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue @@ -1,12 +1,13 @@ <script> +import { s__ } from '~/locale'; +import RunnerSingleStat from '~/runner/components/stat/runner_single_stat.vue'; import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants'; -import RunnerCount from './runner_count.vue'; -import RunnerStatusStat from './runner_status_stat.vue'; export default { components: { - RunnerCount, - RunnerStatusStat, + RunnerSingleStat, + RunnerUpgradeStatusStats: () => + import('ee_component/runner/components/stat/runner_upgrade_status_stats.vue'), }, props: { scope: { @@ -16,32 +17,67 @@ export default { variables: { type: Object, required: false, - default: () => {}, + default: () => ({}), }, }, - methods: { - countVariables(vars) { - return { ...this.variables, ...vars }; + computed: { + stats() { + return [ + { + key: STATUS_ONLINE, + props: { + skip: this.statusCountSkip(STATUS_ONLINE), + variables: { ...this.variables, status: STATUS_ONLINE }, + variant: 'success', + title: s__('Runners|Online runners'), + metaText: s__('Runners|online'), + }, + }, + { + key: STATUS_OFFLINE, + props: { + skip: this.statusCountSkip(STATUS_OFFLINE), + variables: { ...this.variables, status: STATUS_OFFLINE }, + variant: 'muted', + title: s__('Runners|Offline runners'), + metaText: s__('Runners|offline'), + }, + }, + { + key: STATUS_STALE, + props: { + skip: this.statusCountSkip(STATUS_STALE), + variables: { ...this.variables, status: STATUS_STALE }, + variant: 'warning', + title: s__('Runners|Stale runners'), + metaText: s__('Runners|stale'), + }, + }, + ]; }, + }, + methods: { statusCountSkip(status) { // Show an empty result when we already filter by another status return this.variables.status && this.variables.status !== status; }, }, - STATUS_LIST: [STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE], }; </script> <template> - <div class="gl-display-flex gl-py-6"> - <runner-count - v-for="status in $options.STATUS_LIST" - #default="{ count }" - :key="status" + <div class="gl-display-flex gl-flex-wrap gl-py-6"> + <runner-single-stat + v-for="stat in stats" + :key="stat.key" + :scope="scope" + v-bind="stat.props" + class="gl-px-5" + /> + + <runner-upgrade-status-stats + class="gl-display-contents" :scope="scope" - :variables="countVariables({ status })" - :skip="statusCountSkip(status)" - > - <runner-status-stat class="gl-px-5" :status="status" :value="count" /> - </runner-count> + :variables="variables" + /> </div> </template> diff --git a/app/assets/javascripts/runner/components/stat/runner_status_stat.vue b/app/assets/javascripts/runner/components/stat/runner_status_stat.vue deleted file mode 100644 index b77bbe15541..00000000000 --- a/app/assets/javascripts/runner/components/stat/runner_status_stat.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -import { GlSingleStat } from '@gitlab/ui/dist/charts'; -import { s__, formatNumber } from '~/locale'; -import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants'; - -export default { - components: { - GlSingleStat, - }, - props: { - value: { - type: Number, - required: false, - default: null, - }, - status: { - type: String, - required: true, - }, - }, - computed: { - formattedValue() { - if (typeof this.value === 'number') { - return formatNumber(this.value); - } - return '-'; - }, - stat() { - switch (this.status) { - case STATUS_ONLINE: - return { - variant: 'success', - title: s__('Runners|Online runners'), - metaText: s__('Runners|online'), - }; - case STATUS_OFFLINE: - return { - variant: 'muted', - title: s__('Runners|Offline runners'), - metaText: s__('Runners|offline'), - }; - case STATUS_STALE: - return { - variant: 'warning', - title: s__('Runners|Stale runners'), - metaText: s__('Runners|stale'), - }; - default: - return { - title: s__('Runners|Runners'), - }; - } - }, - }, -}; -</script> -<template> - <gl-single-stat - v-if="stat" - :value="formattedValue" - :variant="stat.variant" - :title="stat.title" - :meta-text="stat.metaText" - /> -</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 64541729701..ed1afcbf691 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -1,5 +1,7 @@ import { __, s__ } from '~/locale'; +export const RUNNER_TYPENAME = 'CiRunner'; // __typename + export const RUNNER_PAGE_SIZE = 20; export const RUNNER_JOB_COUNT_LIMIT = 1000; @@ -102,7 +104,6 @@ export const PARAM_KEY_TAG = 'tag'; export const PARAM_KEY_SEARCH = 'search'; export const PARAM_KEY_SORT = 'sort'; -export const PARAM_KEY_PAGE = 'page'; export const PARAM_KEY_AFTER = 'after'; export const PARAM_KEY_BEFORE = 'before'; diff --git a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql index f900a0450e5..29abddf84f5 100644 --- a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql @@ -1,5 +1,4 @@ fragment RunnerFieldsShared on CiRunner { - __typename id shortSha runnerType diff --git a/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql index 6bb896dda16..1160596aff3 100644 --- a/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql @@ -1,5 +1,4 @@ -#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" -#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/runner/graphql/list/all_runners_connection.fragment.graphql" query getAllRunners( $before: String @@ -25,14 +24,6 @@ query getAllRunners( search: $search sort: $sort ) { - nodes { - ...ListItem - adminUrl - editAdminUrl - } - pageInfo { - __typename - ...PageInfo - } + ...AllRunnersConnection } } diff --git a/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql b/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql new file mode 100644 index 00000000000..4440b8e98da --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql @@ -0,0 +1,13 @@ +#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +fragment AllRunnersConnection on CiRunnerConnection { + nodes { + ...ListItem + adminUrl + editAdminUrl + } + pageInfo { + ...PageInfo + } +} diff --git a/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql new file mode 100644 index 00000000000..b73c016b1de --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql @@ -0,0 +1,6 @@ +mutation bulkRunnerDelete($input: BulkRunnerDeleteInput!) { + bulkRunnerDelete(input: $input) { + deletedIds + errors + } +} diff --git a/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql b/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql new file mode 100644 index 00000000000..baef16a4b41 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql @@ -0,0 +1,16 @@ +#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +fragment GroupRunnerConnection on CiRunnerConnection { + edges { + webUrl + editUrl + node { + ...ListItem + projectCount # Used to determine why some project runners can't be deleted + } + } + pageInfo { + ...PageInfo + } +} diff --git a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql index 8755636a7ad..4c519b9b867 100644 --- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql @@ -1,5 +1,4 @@ -#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" -#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/runner/graphql/list/group_runner_connection.fragment.graphql" query getGroupRunners( $groupFullPath: ID! @@ -27,18 +26,7 @@ query getGroupRunners( search: $search sort: $sort ) { - edges { - webUrl - editUrl - node { - ...ListItem - projectCount # Used to determine why some project runners can't be deleted - } - } - pageInfo { - __typename - ...PageInfo - } + ...GroupRunnerConnection } } } diff --git a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql index cf925359ffb..ce23bddb898 100644 --- a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql @@ -1,11 +1,9 @@ fragment ListItemShared on CiRunner { - __typename id description runnerType shortSha version - revision ipAddress active locked diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js index e87bc72c86a..154af261bba 100644 --- a/app/assets/javascripts/runner/graphql/list/local_state.js +++ b/app/assets/javascripts/runner/graphql/list/local_state.js @@ -1,4 +1,5 @@ import { makeVar } from '@apollo/client/core'; +import { RUNNER_TYPENAME } from '../../constants'; import typeDefs from './typedefs.graphql'; /** @@ -33,10 +34,16 @@ export const createLocalState = () => { typePolicies: { Query: { fields: { - checkedRunnerIds() { + checkedRunnerIds(_, { canRead, toReference }) { return Object.entries(checkedRunnerIdsVar()) + .filter(([id]) => { + // Some runners may be deleted by the user separately. + // Skip dangling references, those not in the cache. + // See: https://www.apollographql.com/docs/react/caching/garbage-collection/#dangling-references + return canRead(toReference({ __typename: RUNNER_TYPENAME, id })); + }) .filter(([, isChecked]) => isChecked) - .map(([key]) => key); + .map(([id]) => id); }, }, }, @@ -50,6 +57,13 @@ export const createLocalState = () => { [runner.id]: isChecked, }); }, + setRunnersChecked({ runners, isChecked }) { + const newVal = runners.reduce( + (acc, { id }) => ({ ...acc, [id]: isChecked }), + checkedRunnerIdsVar(), + ); + checkedRunnerIdsVar(newVal); + }, clearChecked() { checkedRunnerIdsVar({}); }, diff --git a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql index b79ad4d9280..499c0156770 100644 --- a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql @@ -1,5 +1,4 @@ fragment RunnerDetailsShared on CiRunner { - __typename id shortSha runnerType diff --git a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql index cb27de7c200..acc4a641565 100644 --- a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql +++ b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql @@ -9,11 +9,15 @@ query getRunnerProjects( ) { runner(id: $id) { id + ownerProject { + id + } projectCount projects(first: $first, last: $last, before: $before, after: $after) { nodes { id avatarUrl + description name nameWithNamespace webUrl 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 e8446dbe345..a82411a2120 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -3,6 +3,14 @@ import { GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { updateHistory } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; +import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config'; +import { + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, + isSearchFiltered, +} from 'ee_else_ce/runner/runner_search_utils'; +import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; @@ -22,13 +30,6 @@ import { PROJECT_TYPE, I18N_FETCH_ERROR, } from '../constants'; -import groupRunnersQuery from '../graphql/list/group_runners.query.graphql'; -import { - fromUrlQueryToSearch, - fromSearchToUrl, - fromSearchToVariables, - isSearchFiltered, -} from '../runner_search_utils'; import { captureException } from '../sentry_utils'; export default { @@ -123,7 +124,7 @@ export default { return !this.runnersLoading && !this.runners.items.length; }, searchTokens() { - return [pausedTokenConfig, statusTokenConfig]; + return [pausedTokenConfig, statusTokenConfig, upgradeStatusTokenConfig]; }, filteredSearchNamespace() { return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`; @@ -166,6 +167,9 @@ export default { reportToSentry(error) { captureException({ error, component: this.$options.name }); }, + onPaginationInput(value) { + this.search.pagination = value; + }, }, TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE], GROUP_TYPE, @@ -225,11 +229,13 @@ export default { /> </template> </runner-list> - <runner-pagination - v-model="search.pagination" - class="gl-mt-3" - :page-info="runners.pageInfo" - /> </template> + + <runner-pagination + class="gl-mt-3" + :disabled="runnersLoading" + :page-info="runners.pageInfo" + @input="onPaginationInput" + /> </div> </template> diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index e01878f355a..dc582ccbac1 100644 --- a/app/assets/javascripts/runner/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -1,3 +1,4 @@ +import { isEmpty } from 'lodash'; import { queryToObject, setUrlParams } from '~/lib/utils/url_utility'; import { filterToQueryObject, @@ -13,7 +14,6 @@ import { PARAM_KEY_TAG, PARAM_KEY_SEARCH, PARAM_KEY_SORT, - PARAM_KEY_PAGE, PARAM_KEY_AFTER, PARAM_KEY_BEFORE, DEFAULT_SORT, @@ -41,7 +41,7 @@ import { getPaginationVariables } from './utils'; * sort: 'CREATED_DESC', * * // Pagination information - * pagination: { page: 1 }, + * pagination: { "after": "..." }, * }; * ``` * @@ -66,25 +66,16 @@ export const searchValidator = ({ runnerType, filters, sort }) => { }; const getPaginationFromParams = (params) => { - const page = parseInt(params[PARAM_KEY_PAGE], 10); - const after = params[PARAM_KEY_AFTER]; - const before = params[PARAM_KEY_BEFORE]; - - if (page && (before || after)) { - return { - page, - before, - after, - }; - } return { - page: 1, + after: params[PARAM_KEY_AFTER], + before: params[PARAM_KEY_BEFORE], }; }; // Outdated URL parameters const STATUS_ACTIVE = 'ACTIVE'; const STATUS_PAUSED = 'PAUSED'; +const PARAM_KEY_PAGE = 'page'; /** * Replaces params into a URL @@ -97,6 +88,21 @@ const updateUrlParams = (url, params = {}) => { return setUrlParams(params, url, false, true, true); }; +const outdatedStatusParams = (status) => { + if (status === STATUS_ACTIVE) { + return { + [PARAM_KEY_PAUSED]: ['false'], + [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! + }; + } else if (status === STATUS_PAUSED) { + return { + [PARAM_KEY_PAUSED]: ['true'], + [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! + }; + } + return {}; +}; + /** * Returns an updated URL for old (or deprecated) admin runner URLs. * @@ -108,25 +114,22 @@ const updateUrlParams = (url, params = {}) => { export const updateOutdatedUrl = (url = window.location.href) => { const urlObj = new URL(url); const query = urlObj.search; - const params = queryToObject(query, { gatherArrays: true }); - const status = params[PARAM_KEY_STATUS]?.[0] || null; - - switch (status) { - case STATUS_ACTIVE: - return updateUrlParams(url, { - [PARAM_KEY_PAUSED]: ['false'], - [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! - }); - case STATUS_PAUSED: - return updateUrlParams(url, { - [PARAM_KEY_PAUSED]: ['true'], - [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! - }); - default: - return null; + // Remove `page` completely, not needed for keyset pagination + const pageParams = PARAM_KEY_PAGE in params ? { [PARAM_KEY_PAGE]: null } : {}; + + const status = params[PARAM_KEY_STATUS]?.[0]; + const redirectParams = { + // Replace paused status (active, paused) with a paused flag + ...outdatedStatusParams(status), + ...pageParams, + }; + + if (!isEmpty(redirectParams)) { + return updateUrlParams(url, redirectParams); } + return null; }; /** @@ -182,13 +185,11 @@ export const fromSearchToUrl = ( } const isDefaultSort = sort !== DEFAULT_SORT; - const isFirstPage = pagination?.page === 1; const otherParams = { // Sorting & Pagination [PARAM_KEY_SORT]: isDefaultSort ? sort : null, - [PARAM_KEY_PAGE]: isFirstPage ? null : pagination.page, - [PARAM_KEY_BEFORE]: isFirstPage ? null : pagination.before, - [PARAM_KEY_AFTER]: isFirstPage ? null : pagination.after, + [PARAM_KEY_BEFORE]: pagination?.before || null, + [PARAM_KEY_AFTER]: pagination?.after || null, }; return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true); @@ -247,6 +248,6 @@ export const fromSearchToVariables = ({ */ export const isSearchFiltered = ({ runnerType = null, filters = [], pagination = {} } = {}) => { return Boolean( - runnerType !== null || filters?.length !== 0 || (pagination && pagination?.page !== 1), + runnerType !== null || filters?.length !== 0 || pagination?.before || pagination?.after, ); }; diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 6efaf08a178..5a9ef832e05 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -157,6 +157,7 @@ export const SCANNER_NAMES_MAP = { COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, SECRET_DETECTION: SECRET_DETECTION_NAME, DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, + GENERIC: s__('ciReport|Manually Added'), }; export const securityFeatures = [ diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 579316f481c..2cdec8fc481 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -91,7 +91,7 @@ export default { modalId: 'set-user-status-modal', noEmoji: true, availability: isUserBusy(this.currentAvailability), - clearStatusAfter: statusTimeRanges[0].label, + clearStatusAfter: statusTimeRanges[0], clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), { date: this.currentClearStatusAfter, }), @@ -178,9 +178,7 @@ export default { message, availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET, clearStatusAfter: - clearStatusAfter === statusTimeRanges[0].label - ? null - : clearStatusAfter.replace(' ', '_'), + clearStatusAfter.label === statusTimeRanges[0].label ? null : clearStatusAfter.shortcut, }) .then(this.onUpdateSuccess) .catch(this.onUpdateFail); @@ -279,12 +277,12 @@ export default { <div class="form-group"> <div class="gl-display-flex gl-align-items-baseline"> <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span> - <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown"> + <gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown"> <gl-dropdown-item v-for="after in $options.statusTimeRanges" :key="after.name" :data-testid="after.name" - @click="setClearStatusAfter(after.label)" + @click="setClearStatusAfter(after)" >{{ after.label }}</gl-dropdown-item > </gl-dropdown> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 3602b5ec4f6..29ea390a81d 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -39,9 +39,6 @@ export default { assignSelf() { this.$emit('assign-self'); }, - toggleAttentionRequested(data) { - this.$emit('toggle-attention-requested', data); - }, }, }; </script> @@ -66,12 +63,7 @@ export default { </template> </span> - <uncollapsed-assignee-list - v-else - :users="sortedAssigness" - :issuable-type="issuableType" - @toggle-attention-requested="toggleAttentionRequested" - /> + <uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index 59a4eb54bbe..a94dd128a1a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -32,11 +32,6 @@ export default { return this.users.length === 0; }, }, - methods: { - toggleAttentionRequested(data) { - this.$emit('toggle-attention-requested', data); - }, - }, }; </script> @@ -66,7 +61,6 @@ export default { :users="users" :issuable-type="issuableType" class="gl-text-gray-800 hide-collapsed" - @toggle-attention-requested="toggleAttentionRequested" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index e596d6292bf..18b26c7d8bd 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -125,9 +125,6 @@ export default { availability: this.assigneeAvailabilityStatus[username] || '', })); }, - toggleAttentionRequested(data) { - this.mediator.toggleAttentionRequested('assignee', data); - }, }, }; </script> @@ -155,7 +152,6 @@ export default { :editable="store.editable" :issuable-type="issuableType" @assign-self="assignSelf" - @toggle-attention-requested="toggleAttentionRequested" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 14f6c9d3a15..5c432ca0e03 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -149,6 +149,9 @@ export default { signedIn() { return this.currentUser.username !== undefined; }, + issuableAuthor() { + return this.issuable?.author; + }, }, watch: { iid(_, oldIid) { @@ -266,6 +269,7 @@ export default { :current-user="currentUser" :issuable-type="issuableType" :is-editing="edit" + :issuable-author="issuableAuthor" class="gl-w-full dropdown-menu-user gl-mt-n3" @toggle="collapseWidget" @error="showError" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index e9c68008143..0ed40f56bea 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -1,5 +1,5 @@ <script> -import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui'; +import { GlAvatarLabeled, GlIcon } from '@gitlab/ui'; import { IssuableType } from '~/issues/constants'; import { s__, sprintf } from '~/locale'; @@ -11,7 +11,6 @@ const AVAILABILITY_STATUS = { export default { components: { GlAvatarLabeled, - GlAvatarLink, GlIcon, }, props: { @@ -47,23 +46,21 @@ export default { </script> <template> - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="userLabel" - :sub-label="`@${user.username}`" - :src="user.avatarUrl || user.avatar || user.avatar_url" - class="gl-align-items-center gl-relative" - > - <template #meta> - <gl-icon - v-if="hasCannotMergeIcon" - name="warning-solid" - aria-hidden="true" - class="merge-icon" - :size="12" - /> - </template> - </gl-avatar-labeled> - </gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="userLabel" + :sub-label="`@${user.username}`" + :src="user.avatarUrl || user.avatar || user.avatar_url" + class="gl-align-items-center gl-relative sidebar-participant" + > + <template #meta> + <gl-icon + v-if="hasCannotMergeIcon" + name="warning-solid" + aria-hidden="true" + class="merge-icon gl-left-6 gl-bottom-0" + :size="12" + /> + </template> + </gl-avatar-labeled> </template> 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 b6260418837..0e4d4c74160 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -2,7 +2,6 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; -import AttentionRequestedToggle from '../attention_requested_toggle.vue'; import AssigneeAvatarLink from './assignee_avatar_link.vue'; import UserNameWithStatus from './user_name_with_status.vue'; @@ -10,7 +9,6 @@ const DEFAULT_RENDER_COUNT = 5; export default { components: { - AttentionRequestedToggle, AssigneeAvatarLink, UserNameWithStatus, }, @@ -46,10 +44,6 @@ export default { return this.users.length - DEFAULT_RENDER_COUNT; }, uncollapsedUsers() { - if (this.showVerticalList) { - return this.users; - } - const uncollapsedLength = this.showLess ? Math.min(this.users.length, DEFAULT_RENDER_COUNT) : this.users.length; @@ -58,9 +52,6 @@ export default { username() { return `@${this.firstUser.username}`; }, - showVerticalList() { - return this.glFeatures.mrAttentionRequests && this.isMergeRequest; - }, isMergeRequest() { return this.issuableType === IssuableType.MergeRequest; }, @@ -75,9 +66,6 @@ export default { } return u?.status?.availability || ''; }, - toggleAttentionRequested(data) { - this.$emit('toggle-attention-requested', data); - }, }, }; </script> @@ -96,7 +84,7 @@ export default { <assignee-avatar-link :user="user" :issuable-type="issuableType" - :tooltip-has-name="!showVerticalList" + :tooltip-has-name="!isMergeRequest" class="gl-word-break-word" data-css-area="user" > @@ -107,14 +95,6 @@ export default { <user-name-with-status :name="user.name" :availability="userAvailability(user)" /> </div> </assignee-avatar-link> - <attention-requested-toggle - v-if="showVerticalList" - :user="user" - type="assignee" - class="gl-mr-2" - data-css-area="attention" - @toggle-attention-requested="toggleAttentionRequested" - /> </div> </div> <div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800"> diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue deleted file mode 100644 index 974ad189f32..00000000000 --- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue +++ /dev/null @@ -1,105 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; - -export default { - i18n: { - addAttentionRequest: __('Add attention request'), - removeAttentionRequest: __('Remove attention request'), - attentionRequestedNoPermission: __('Attention requested'), - noAttentionRequestedNoPermission: __('No attention request'), - }, - components: { - GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - type: { - type: String, - required: true, - }, - user: { - type: Object, - required: true, - }, - }, - data() { - return { - loading: false, - }; - }, - computed: { - tooltipTitle() { - if (this.user.attention_requested) { - if (this.user.can_update_merge_request) { - return this.$options.i18n.removeAttentionRequest; - } - - return this.$options.i18n.attentionRequestedNoPermission; - } - - if (this.user.can_update_merge_request) { - return this.$options.i18n.addAttentionRequest; - } - - return this.$options.i18n.noAttentionRequestedNoPermission; - }, - request() { - const state = { - selected: false, - icon: 'attention', - direction: 'add', - }; - - if (this.user.attention_requested) { - Object.assign(state, { - selected: true, - icon: 'attention-solid', - direction: 'remove', - }); - } - - return state; - }, - }, - methods: { - toggleAttentionRequired() { - if (this.loading || !this.user.can_update_merge_request) return; - - this.$root.$emit(BV_HIDE_TOOLTIP); - this.loading = true; - this.$emit('toggle-attention-requested', { - user: this.user, - callback: this.toggleAttentionRequiredComplete, - direction: this.request.direction, - }); - }, - toggleAttentionRequiredComplete() { - this.loading = false; - }, - }, -}; -</script> - -<template> - <div> - <span - v-gl-tooltip.left.viewport="tooltipTitle" - class="gl-display-inline-block js-attention-request-toggle" - > - <gl-button - :loading="loading" - :selected="request.selected" - :icon="request.icon" - :aria-label="tooltipTitle" - :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }" - size="small" - category="tertiary" - @click="toggleAttentionRequired" - /> - </span> - </div> -</template> diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue index c44ce8b0057..336c291d4f1 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -88,7 +88,10 @@ export default { .then( ({ data: { - issuableSetConfidential: { errors }, + issuableSetConfidential: { + issuable: { confidential }, + errors, + }, }, }) => { if (errors.length) { @@ -96,7 +99,7 @@ export default { message: errors[0], }); } else { - this.$emit('closeForm'); + this.$emit('closeForm', { confidential }); } }, ) diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue index f234c5ea3c9..eec083f23f3 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -95,10 +95,10 @@ export default { confidentialWidget.setConfidentiality = null; }, methods: { - closeForm() { + closeForm({ confidential } = {}) { this.$refs.editable.collapse(); this.$el.dispatchEvent(hideDropdownEvent); - this.$emit('closeForm'); + this.$emit('closeForm', { confidential }); }, // synchronizing the quick action with the sidebar widget // this is a temporary solution until we have confidentiality real-time updates diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue index 9502b2e78b3..6f82178b6fd 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -33,7 +33,7 @@ export default { return this.users.length > 2; }, allReviewersCanMerge() { - return this.users.every((user) => user.can_merge); + return this.users.every((user) => user.mergeRequestInteraction?.canMerge); }, sidebarAvatarCounter() { if (this.users.length > DEFAULT_MAX_COUNTER) { @@ -48,7 +48,7 @@ export default { return this.users.slice(0, collapsedLength); }, tooltipTitleMergeStatus() { - const mergeLength = this.users.filter((u) => u.can_merge).length; + const mergeLength = this.users.filter((u) => u.mergeRequestInteraction?.canMerge).length; if (mergeLength === this.users.length) { return ''; diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue index 7961b7cd679..a7db3b3d09f 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue @@ -23,10 +23,10 @@ export default { return sprintf(__("%{userName}'s avatar"), { userName: this.user.name }); }, avatarUrl() { - return this.user.avatar || this.user.avatar_url || gon.default_avatar_url; + return this.user.avatarUrl || this.user.avatar_url || gon.default_avatar_url; }, hasMergeIcon() { - return !this.user.can_merge; + return !this.user.mergeRequestInteraction?.canMerge; }, }, }; 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 c9b0a4ae2b3..f69c027e201 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -40,7 +40,7 @@ export default { }, computed: { cannotMerge() { - return this.issuableType === 'merge_request' && !this.user.can_merge; + return this.issuableType === 'merge_request' && !this.user.mergeRequestInteraction?.canMerge; }, tooltipTitle() { if (this.cannotMerge && this.tooltipHasName) { @@ -59,7 +59,7 @@ export default { }; }, reviewerUrl() { - return this.user.web_url; + return this.user.webUrl; }, }, }; diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index b07fd944ff9..5e1172ad835 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -36,8 +36,8 @@ export default { return !this.users.length; }, sortedReviewers() { - const canMergeUsers = this.users.filter((user) => user.can_merge); - const canNotMergeUsers = this.users.filter((user) => !user.can_merge); + const canMergeUsers = this.users.filter((user) => user.mergeRequestInteraction?.canMerge); + const canNotMergeUsers = this.users.filter((user) => !user.mergeRequestInteraction?.canMerge); return [...canMergeUsers, ...canNotMergeUsers]; }, @@ -49,9 +49,6 @@ export default { requestReview(data) { this.$emit('request-review', data); }, - toggleAttentionRequested(data) { - this.$emit('toggle-attention-requested', data); - }, }, }; </script> @@ -73,7 +70,6 @@ export default { :root-path="rootPath" :issuable-type="issuableType" @request-review="requestReview" - @toggle-attention-requested="toggleAttentionRequested" /> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index 2ea63219e92..b0d820ddd15 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -1,15 +1,23 @@ <script> // NOTE! For the first iteration, we are simply copying the implementation of Assignees // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import Vue from 'vue'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import createFlash from '~/flash'; import { __ } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import getMergeRequestReviewersQuery from '~/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql'; import ReviewerTitle from './reviewer_title.vue'; import Reviewers from './reviewers.vue'; +export const state = Vue.observable({ + issuable: {}, + loading: false, + initialLoading: true, +}); + export default { name: 'SidebarReviewers', components: { @@ -40,18 +48,49 @@ export default { required: true, }, }, + apollo: { + issuable: { + query: getMergeRequestReviewersQuery, + variables() { + return { + iid: this.issuableIid, + fullPath: this.projectPath, + }; + }, + update(data) { + return data.workspace?.issuable; + }, + result() { + this.initialLoading = false; + }, + error() { + createFlash({ message: __('An error occurred while fetching reviewers.') }); + }, + }, + }, data() { - return { - store: new Store(), - loading: false, - }; + return state; }, computed: { relativeUrlRoot() { return gon.relative_url_root ?? ''; }, + reviewers() { + return this.issuable.reviewers?.nodes || []; + }, + graphqlFetching() { + return this.$apollo.queries.issuable.loading; + }, + isLoading() { + return this.loading || this.$apollo.queries.issuable.loading; + }, + canUpdate() { + return this.issuable.userPermissions?.updateMergeRequest || false; + }, }, created() { + this.store = new Store(); + this.removeReviewer = this.store.removeReviewer.bind(this.store); this.addReviewer = this.store.addReviewer.bind(this.store); this.removeAllReviewers = this.store.removeAllReviewers.bind(this.store); @@ -77,6 +116,7 @@ export default { .then(() => { this.loading = false; refreshUserMergeRequestCounts(); + this.$apollo.queries.issuable.refetch(); }) .catch(() => { this.loading = false; @@ -88,9 +128,6 @@ export default { requestReview(data) { this.mediator.requestReview(data); }, - toggleAttentionRequested(data) { - this.mediator.toggleAttentionRequested('reviewer', data); - }, }, }; </script> @@ -98,18 +135,17 @@ export default { <template> <div> <reviewer-title - :number-of-reviewers="store.reviewers.length" - :loading="loading || store.isFetching.reviewers" - :editable="store.editable" + :number-of-reviewers="reviewers.length" + :loading="isLoading" + :editable="canUpdate" /> <reviewers - v-if="!store.isFetching.reviewers" + v-if="!initialLoading" :root-path="relativeUrlRoot" - :users="store.reviewers" - :editable="store.editable" + :users="reviewers" + :editable="canUpdate" :issuable-type="issuableType" @request-review="requestReview" - @toggle-attention-requested="toggleAttentionRequested" /> </div> </template> 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 2f58e11c00f..217ca2e2548 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -1,8 +1,6 @@ <script> import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __, sprintf, s__ } from '~/locale'; -import AttentionRequestedToggle from '../attention_requested_toggle.vue'; import ReviewerAvatarLink from './reviewer_avatar_link.vue'; const LOADING_STATE = 'loading'; @@ -16,12 +14,10 @@ export default { GlButton, GlIcon, ReviewerAvatarLink, - AttentionRequestedToggle, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], props: { users: { type: Array, @@ -80,9 +76,6 @@ export default { this.loadingStates[userId] = null; } }, - toggleAttentionRequested(data) { - this.$emit('toggle-attention-requested', data); - }, }, LOADING_STATE, SUCCESS_STATE, @@ -96,7 +89,6 @@ export default { :key="user.id" :class="{ 'gl-mb-3': index !== users.length - 1, - 'attention-requests': glFeatures.mrAttentionRequests, }" class="gl-display-grid gl-align-items-center reviewer-grid gl-mr-2" data-testid="reviewer" @@ -112,16 +104,8 @@ export default { {{ user.name }} </div> </reviewer-avatar-link> - <attention-requested-toggle - v-if="glFeatures.mrAttentionRequests" - :user="user" - type="reviewer" - class="gl-mr-2" - data-css-area="attention" - @toggle-attention-requested="toggleAttentionRequested" - /> <gl-icon - v-if="user.approved" + v-if="user.mergeRequestInteraction.approved" v-gl-tooltip.left :size="16" :title="approvedByTooltipTitle(user)" @@ -137,9 +121,7 @@ export default { data-testid="re-request-success" /> <gl-button - v-else-if=" - user.can_update_merge_request && user.reviewed && !glFeatures.mrAttentionRequests - " + v-else-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed" v-gl-tooltip.left :title="$options.i18n.reRequestReview" :aria-label="$options.i18n.reRequestReview" diff --git a/app/assets/javascripts/sidebar/components/severity/severity.vue b/app/assets/javascripts/sidebar/components/severity/severity.vue index 0db856543d0..776dab98f01 100644 --- a/app/assets/javascripts/sidebar/components/severity/severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/severity.vue @@ -37,10 +37,10 @@ export default { <gl-icon :size="iconSize" :name="`severity-${severity.icon}`" - :class="[`icon-${severity.icon}`, { 'gl-mr-3': !iconOnly }]" + :class="[`icon-${severity.icon}`, { 'gl-mr-3 gl-flex-shrink-0': !iconOnly }]" /> - <tooltip-on-truncate v-if="!iconOnly" :title="severity.label" class="gl-text-truncate">{{ - severity.label - }}</tooltip-on-truncate> + <tooltip-on-truncate v-if="!iconOnly" :title="severity.label" class="gl-text-truncate"> + {{ severity.label }} + </tooltip-on-truncate> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index 86e46016534..bf4ba715f85 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -149,23 +149,25 @@ export default { </div> <div class="hide-collapsed"> - <p - class="gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-display-flex gl-justify-content-space-between" + <div + class="gl-display-flex gl-align-items-center gl-line-height-20 gl-text-gray-900 gl-font-weight-bold" > {{ $options.i18n.SEVERITY }} <gl-button v-if="canUpdate" category="tertiary" size="small" + class="gl-ml-auto hide-collapsed gl-mr-n2" data-testid="editButton" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > {{ $options.i18n.EDIT }} </gl-button> - </p> + </div> <gl-dropdown + class="gl-mt-3" :class="dropdownClass" block :header-text="__('Assign severity')" diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 3f82fe5ce87..fec4d0e346d 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -27,8 +27,6 @@ import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import eventHub from '~/sidebar/event_hub'; -import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import Translate from '../vue_shared/translate'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; @@ -41,6 +39,7 @@ import SidebarTimeTracking from './components/time_tracking/sidebar_time_trackin import { IssuableAttributeType } from './constants'; import SidebarMoveIssue from './lib/sidebar_move_issue'; import CrmContacts from './components/crm_contacts/crm_contacts.vue'; +import SidebarEventHub from './event_hub'; Vue.use(Translate); Vue.use(VueApollo); @@ -361,6 +360,13 @@ function mountConfidentialComponent() { ? IssuableType.Issue : IssuableType.MergeRequest, }, + on: { + closeForm({ confidential }) { + if (confidential !== undefined) { + SidebarEventHub.$emit('confidentialityUpdated', confidential); + } + }, + }, }), }); } @@ -652,13 +658,6 @@ export function mountSidebar(mediator, store) { mountSeverityComponent(); mountEscalationStatusComponent(); - - if (window.gon?.features?.mrAttentionRequests) { - eventHub.$on('removeCurrentUserAttentionRequested', () => { - mediator.removeCurrentUserAttentionRequested(); - refreshUserMergeRequestCounts(); - }); - } } export { getSidebarOptions }; diff --git a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql index 4998b2af666..a9d7e9878c6 100644 --- a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql @@ -1,9 +1,7 @@ query epicConfidential($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { - __typename id issuable: epic(iid: $iid) { - __typename id confidential } diff --git a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql index 00529042e92..45c15a86961 100644 --- a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql @@ -1,9 +1,7 @@ query epicDueDate($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { - __typename id issuable: epic(iid: $iid) { - __typename id dueDate dueDateIsFixed diff --git a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql index dada7ffc034..d665ca1e084 100644 --- a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql @@ -3,10 +3,8 @@ query epicParticipants($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { - __typename id issuable: epic(iid: $iid) { - __typename id participants { nodes { diff --git a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql index f35ca896ef8..76d570a0f16 100644 --- a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql @@ -1,9 +1,7 @@ query epicReference($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { - __typename id issuable: epic(iid: $iid) { - __typename id reference(full: true) } diff --git a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql index 85fc7de8d02..c85ede07fde 100644 --- a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql @@ -1,9 +1,7 @@ query epicStartDate($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { - __typename id issuable: epic(iid: $iid) { - __typename id startDate startDateIsFixed diff --git a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql index a8fe6b8ddc3..b1973075d48 100644 --- a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql @@ -1,10 +1,8 @@ query epicSubscribed($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { - __typename id emailsDisabled issuable: epic(iid: $iid) { - __typename id subscribed } diff --git a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql index b0ba724e727..3c035bcc6db 100644 --- a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql @@ -1,9 +1,7 @@ query epicTodos($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { - __typename id issuable: epic(iid: $iid) { - __typename id currentUserTodos(state: pending) { nodes { diff --git a/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql index dceab61ed26..6b15fcda2e8 100644 --- a/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql +++ b/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql @@ -2,7 +2,6 @@ query groupMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) { workspace: group(fullPath: $fullPath) { - __typename id attributes: milestones( searchTitle: $title diff --git a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql index e578cf3bda5..fcdc84c5a06 100644 --- a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql @@ -1,9 +1,7 @@ query issueConfidential($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { - __typename id issuable: issue(iid: $iid) { - __typename id confidential } diff --git a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql index 48cbff252b3..4369104704a 100644 --- a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql @@ -1,9 +1,7 @@ query issueDueDate($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { - __typename id issuable: issue(iid: $iid) { - __typename id dueDate } diff --git a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql index c3128d6d961..2c69cc04429 100644 --- a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql @@ -1,9 +1,7 @@ query issueReference($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { id - __typename issuable: issue(iid: $iid) { - __typename id reference(full: true) } diff --git a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql index e2722fc86a4..419036ee15d 100644 --- a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql @@ -1,9 +1,7 @@ query issueSubscribed($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { - __typename id issuable: issue(iid: $iid) { - __typename id subscribed emailsDisabled diff --git a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql index 059361dd370..f4d0e9b5deb 100644 --- a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql @@ -1,9 +1,7 @@ query issueTimeTracking($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { - __typename id issuable: issue(iid: $iid) { - __typename id humanTimeEstimate humanTotalTimeSpent diff --git a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql index 5cd5d81c439..3211ded66ae 100644 --- a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql @@ -1,9 +1,7 @@ query issueTodos($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { - __typename id issuable: issue(iid: $iid) { - __typename id currentUserTodos(state: pending) { nodes { diff --git a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql index b0a16677cf2..26bf901babf 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql @@ -2,10 +2,8 @@ query mergeRequestMilestone($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { - __typename id issuable: mergeRequest(iid: $iid) { - __typename id attribute: milestone { ...MilestoneFragment diff --git a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql index 7c78f812b67..e42e50ba861 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql @@ -1,9 +1,7 @@ query mergeRequestReference($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { - __typename id issuable: mergeRequest(iid: $iid) { - __typename id reference(full: true) } diff --git a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql index d5e27ca7b69..d29f4d512c5 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql @@ -1,9 +1,7 @@ query mergeRequestSubscribed($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { - __typename id issuable: mergeRequest(iid: $iid) { - __typename id subscribed } diff --git a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql index d480ff3d5ba..5d05cb2f34c 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql @@ -1,9 +1,7 @@ query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { - __typename id issuable: mergeRequest(iid: $iid) { - __typename id humanTimeEstimate humanTotalTimeSpent diff --git a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql index 65b9ef45260..906bfcdf9cd 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql @@ -1,9 +1,7 @@ query mergeRequestTodos($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { - __typename id issuable: mergeRequest(iid: $iid) { - __typename id currentUserTodos(state: pending) { nodes { diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql index 721a71bef63..507221946fa 100644 --- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql @@ -2,10 +2,8 @@ mutation projectIssueMilestoneMutation($fullPath: ID!, $iid: String!, $attribute issuableSetAttribute: updateIssue( input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId } ) { - __typename errors issuable: issue { - __typename id attribute: milestone { title diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql index c7f3adc9aca..bcb055d4f0f 100644 --- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql +++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql @@ -2,10 +2,8 @@ query projectIssueMilestone($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { - __typename id issuable: issue(iid: $iid) { - __typename id attribute: milestone { ...MilestoneFragment diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql index d9eab18628d..b75c2138525 100644 --- a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql +++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql @@ -2,7 +2,6 @@ query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) { workspace: project(fullPath: $fullPath) { - __typename id attributes: milestones( searchTitle: $title diff --git a/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql b/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql deleted file mode 100644 index d9b9c04fd63..00000000000 --- a/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation mergeRequestRemoveAttentionRequest($projectPath: ID!, $iid: String!, $userId: UserID!) { - mergeRequestRemoveAttentionRequest( - input: { projectPath: $projectPath, iid: $iid, userId: $userId } - ) { - errors - } -} diff --git a/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql b/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql deleted file mode 100644 index 99a86e4fe5c..00000000000 --- a/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation mergeRequestRequestAttention($projectPath: ID!, $iid: String!, $userId: UserID!) { - mergeRequestRequestAttention(input: { projectPath: $projectPath, iid: $iid, userId: $userId }) { - errors - } -} diff --git a/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql b/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql index 4675db9153e..51d461989e4 100644 --- a/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql @@ -1,6 +1,5 @@ mutation issuableTodoCreate($input: TodoCreateInput!) { todoMutation: todoCreate(input: $input) { - __typename todo { id } diff --git a/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql b/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql index 8253e5e82bc..4a91147c246 100644 --- a/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql @@ -1,6 +1,5 @@ mutation issuableTodoMarkDone($input: TodoMarkDoneInput!) { todoMutation: todoMarkDone(input: $input) { - __typename todo { id } diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql index 938953ccfb2..2714d815bcd 100644 --- a/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql @@ -2,10 +2,8 @@ mutation mergeRequestSetMilestone($fullPath: ID!, $iid: String!, $attributeId: M issuableSetAttribute: mergeRequestSetMilestone( input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId } ) { - __typename errors issuable: mergeRequest { - __typename id attribute: milestone { title diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 05268a5c89c..beacdeb559c 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -5,8 +5,6 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql'; import sidebarDetailsMRQuery from '../queries/sidebar_details_mr.query.graphql'; -import requestAttentionMutation from '../queries/request_attention.mutation.graphql'; -import removeAttentionRequestMutation from '../queries/remove_attention_request.mutation.graphql'; const queries = { merge_request: sidebarDetailsMRQuery, @@ -93,25 +91,4 @@ export default class SidebarService { }, }); } - - requestAttention(userId) { - return gqClient.mutate({ - mutation: requestAttentionMutation, - variables: { - userId: convertToGraphQLId(TYPE_USER, `${userId}`), - projectPath: this.fullPath, - iid: this.iid.toString(), - }, - }); - } - removeAttentionRequest(userId) { - return gqClient.mutate({ - mutation: removeAttentionRequestMutation, - variables: { - userId: convertToGraphQLId(TYPE_USER, `${userId}`), - projectPath: this.fullPath, - iid: this.iid.toString(), - }, - }); - } } diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 74ab65e4e04..1be670f7590 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -3,17 +3,7 @@ import Mediator from './sidebar_mediator'; export default (store) => { const mediator = new Mediator(getSidebarOptions()); - mediator - .fetch() - .then(() => { - if (window.gon?.features?.mrAttentionRequests) { - return import('~/attention_requests'); - } - - return null; - }) - .then((module) => module?.initSideNavPopover()) - .catch(() => {}); + mediator.fetch(); mountSidebar(mediator, store); }; diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 4df00903ab6..f7c93b6903c 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,8 +1,7 @@ import Store from '~/sidebar/stores/sidebar_store'; import createFlash from '~/flash'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; -import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { visitUrl } from '../lib/utils/url_utility'; import Service from './services/sidebar_service'; @@ -42,7 +41,6 @@ export default class SidebarMediator { const data = { assignee_ids: assignees }; try { - const { currentUserHasAttention } = this.store; const res = await this.service.update(field, data); this.store.overwrite('assignees', res.data.assignees); @@ -51,10 +49,6 @@ export default class SidebarMediator { this.store.overwrite('reviewers', res.data.reviewers); } - if (currentUserHasAttention && this.store.isAddingAssignee) { - toast(__('Assigned user(s). Your attention request was removed.')); - } - return Promise.resolve(res); } catch (e) { return Promise.reject(e); @@ -70,16 +64,11 @@ export default class SidebarMediator { const data = { reviewer_ids: reviewers }; try { - const { currentUserHasAttention } = this.store; const res = await this.service.update(field, data); this.store.overwrite('reviewers', res.data.reviewers); this.store.overwrite('assignees', res.data.assignees); - if (currentUserHasAttention && this.store.isAddingAssignee) { - toast(__('Requested review. Your attention request was removed.')); - } - return Promise.resolve(res); } catch (e) { return Promise.reject(); @@ -97,80 +86,6 @@ export default class SidebarMediator { .catch(() => callback(userId, false)); } - removeCurrentUserAttentionRequested() { - const currentUserId = gon.current_user_id; - - const currentUserReviewer = this.store.findReviewer({ id: currentUserId }); - const currentUserAssignee = this.store.findAssignee({ id: currentUserId }); - - if (currentUserReviewer?.attention_requested || currentUserAssignee?.attention_requested) { - // Update current users attention_requested state - this.store.updateReviewer(currentUserId, 'attention_requested'); - this.store.updateAssignee(currentUserId, 'attention_requested'); - } - } - - async toggleAttentionRequested(type, { user, callback, direction }) { - const mutations = { - add: (id) => this.service.requestAttention(id), - remove: (id) => this.service.removeAttentionRequest(id), - }; - - try { - const isReviewer = type === 'reviewer'; - const reviewerOrAssignee = isReviewer - ? this.store.findReviewer(user) - : this.store.findAssignee(user); - - await mutations[direction]?.(user.id); - - if (reviewerOrAssignee.attention_requested) { - toast( - sprintf(__('Removed attention request from @%{username}'), { - username: user.username, - }), - ); - } else { - const currentUserId = gon.current_user_id; - const { currentUserHasAttention } = this.store; - - if (currentUserId !== user.id) { - this.removeCurrentUserAttentionRequested(); - } - - toast( - currentUserHasAttention && currentUserId !== user.id - ? sprintf( - __( - 'Requested attention from @%{username}. Your own attention request was removed.', - ), - { username: user.username }, - ) - : sprintf(__('Requested attention from @%{username}'), { username: user.username }), - ); - } - - this.store.updateReviewer(user.id, 'attention_requested'); - this.store.updateAssignee(user.id, 'attention_requested'); - - refreshUserMergeRequestCounts(); - callback(); - } catch (error) { - callback(); - createFlash({ - message: sprintf(__('Updating the attention request for %{username} failed.'), { - username: user.username, - }), - error, - captureError: true, - actionConfig: { - title: __('Try again'), - clickHandler: () => this.toggleAttentionRequired(type, { user, callback, direction }), - }, - }); - } - } - setMoveToProjectId(projectId) { this.store.setMoveToProjectId(projectId); } diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 971e2a15c68..e2581a8f30e 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -19,9 +19,7 @@ export default class SidebarStore { this.humanTimeSpent = ''; this.timeTrackingLimitToHours = timeTrackingLimitToHours; this.assignees = []; - this.addingAssignees = []; this.reviewers = []; - this.addingReviewers = []; this.isFetching = { assignees: true, reviewers: true, @@ -77,20 +75,12 @@ export default class SidebarStore { if (!this.findAssignee(assignee)) { this.changing = true; this.assignees.push(assignee); - - if (assignee.id !== this.currentUser.id) { - this.addingAssignees.push(assignee.id); - } } } addReviewer(reviewer) { if (!this.findReviewer(reviewer)) { this.reviewers.push(reviewer); - - if (reviewer.id !== this.currentUser.id) { - this.addingReviewers.push(reviewer.id); - } } } @@ -126,14 +116,12 @@ export default class SidebarStore { if (assignee) { this.changing = true; this.assignees = this.assignees.filter(({ id }) => id !== assignee.id); - this.addingAssignees = this.addingAssignees.filter(({ id }) => id !== assignee.id); } } removeReviewer(reviewer) { if (reviewer) { this.reviewers = this.reviewers.filter(({ id }) => id !== reviewer.id); - this.addingReviewers = this.addingReviewers.filter(({ id }) => id !== reviewer.id); } } @@ -161,26 +149,4 @@ export default class SidebarStore { setMoveToProjectId(moveToProjectId) { this.moveToProjectId = moveToProjectId; } - - get currentUserHasAttention() { - if (!window.gon?.features?.mrAttentionRequests || !this.isMergeRequest) return false; - - const currentUserId = this.currentUser.id; - const currentUserReviewer = this.findReviewer({ id: currentUserId }); - const currentUserAssignee = this.findAssignee({ id: currentUserId }); - - return currentUserReviewer?.attention_requested || currentUserAssignee?.attention_requested; - } - - get isAddingAssignee() { - return this.addingAssignees.length > 0; - } - - get isAddingReviewer() { - return this.addingReviewers.length > 0; - } - - get isMergeRequest() { - return this.issuableType === 'merge_request'; - } } diff --git a/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql deleted file mode 100644 index d75b4011d1c..00000000000 --- a/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql +++ /dev/null @@ -1,35 +0,0 @@ -#import '~/graphql_shared/fragments/blobviewer.fragment.graphql' - -fragment SnippetBase on Snippet { - id - title - description - descriptionHtml - createdAt - updatedAt - visibilityLevel - webUrl - httpUrlToRepo - sshUrlToRepo - blobs { - nodes { - binary - name - path - rawPath - size - externalStorage - renderedAsText - simpleViewer { - ...BlobViewer - } - richViewer { - ...BlobViewer - } - } - } - userPermissions { - adminSnippet - updateSnippet - } -} diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.js b/app/assets/javascripts/surveys/merge_request_experience/app.js index ea5d8aef3c5..50b1c2c3f39 100644 --- a/app/assets/javascripts/surveys/merge_request_experience/app.js +++ b/app/assets/javascripts/surveys/merge_request_experience/app.js @@ -8,12 +8,17 @@ Vue.use(Translate); Vue.use(VueApollo); export const startMrSurveyApp = () => { + const mountEl = document.querySelector('#js-mr-experience-survey'); + if (!mountEl) return; + let channel = null; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); + const { accountAge } = mountEl.dataset; + const app = new Vue({ apolloProvider, data() { @@ -24,6 +29,9 @@ export const startMrSurveyApp = () => { render(h) { if (this.hidden) return null; return h(MergeRequestExperienceSurveyApp, { + props: { + accountAge: Number(accountAge), + }, on: { close: () => { channel?.postMessage('close'); @@ -37,7 +45,7 @@ export const startMrSurveyApp = () => { }, }); - app.$mount('#js-mr-experience-survey'); + app.$mount(mountEl); if (window.BroadcastChannel) { channel = new BroadcastChannel('mr_survey'); diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue index 85eed6ae82a..4e4ef49b1c6 100644 --- a/app/assets/javascripts/surveys/merge_request_experience/app.vue +++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue @@ -32,6 +32,12 @@ export default { tooltip: GlTooltipDirective, }, mixins: [Tracking.mixin()], + props: { + accountAge: { + type: Number, + required: true, + }, + }, i18n: { survey: s__('MrSurvey|Merge request experience survey'), close: __('Close'), @@ -68,6 +74,9 @@ export default { this.track('survey:mr_experience', { label: this.step.label, value: event, + extra: { + accountAge: this.accountAge, + }, }); this.stepIndex += 1; if (!this.step) { diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index 79a30340856..6e72d95c8e6 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -62,13 +62,21 @@ export default class TaskList { .prop('disabled', true); } + updateInapplicableTaskListItems(e) { + this.getTaskListTarget(e) + .find('.task-list-item-checkbox[data-inapplicable]') + .prop('disabled', true); + } + disableTaskListItems(e) { this.getTaskListTarget(e).taskList('disable'); + this.updateInapplicableTaskListItems(); } enableTaskListItems(e) { this.getTaskListTarget(e).taskList('enable'); this.disableNonMarkdownTaskListItems(e); + this.updateInapplicableTaskListItems(e); } enable() { diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js index 2727485fb95..369cf9714e8 100644 --- a/app/assets/javascripts/test_utils/index.js +++ b/app/assets/javascripts/test_utils/index.js @@ -1,8 +1,10 @@ +import { editor } from 'monaco-editor'; import { Sortable } from 'sortablejs'; import simulateDrag from './simulate_drag'; import simulateInput from './simulate_input'; // Export to global space for rspec to use +window.localMonaco = editor; window.simulateDrag = simulateDrag; window.simulateInput = simulateInput; window.Sortable = Sortable; diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 3356cada58a..c2892fb8dac 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -131,7 +131,9 @@ const lazyLaunchPopover = debounce((mountPopover, event) => { let hasAddedLazyPopovers = false; export default function addPopovers(mountPopover = (instance) => instance.$mount()) { - if (!hasAddedLazyPopovers) { + // The web request fails for anonymous users so we don't want to show the popover when the user is not signed in. + // https://gitlab.com/gitlab-org/gitlab/-/issues/351395#note_1039341458 + if (window.gon?.current_user_id && !hasAddedLazyPopovers) { document.addEventListener('mouseover', (event) => lazyLaunchPopover(mountPopover, event)); hasAddedLazyPopovers = true; } diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js new file mode 100644 index 00000000000..65f0eceae55 --- /dev/null +++ b/app/assets/javascripts/visibility_level/constants.js @@ -0,0 +1,10 @@ +export const VISIBILITY_LEVEL_PRIVATE = 'private'; +export const VISIBILITY_LEVEL_INTERNAL = 'internal'; +export const VISIBILITY_LEVEL_PUBLIC = 'public'; + +// Matches `lib/gitlab/visibility_level.rb` +export const VISIBILITY_LEVELS_ENUM = { + [VISIBILITY_LEVEL_PRIVATE]: 0, + [VISIBILITY_LEVEL_INTERNAL]: 10, + [VISIBILITY_LEVEL_PUBLIC]: 20, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue index b76d5d90ead..38f40e8a3c8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue @@ -14,7 +14,8 @@ export default { props: { widget: { type: String, - required: true, + required: false, + default: '', }, tertiaryButtons: { type: Array, @@ -30,6 +31,8 @@ export default { }, computed: { dropdownLabel() { + if (!this.widget) return undefined; + return sprintf(__('%{widget} options'), { widget: this.widget }); }, }, @@ -85,6 +88,7 @@ export default { :href="btn.href" :target="btn.target" :data-clipboard-text="btn.dataClipboardText" + :data-method="btn.dataMethod" @click="onClickAction(btn)" > {{ btn.text }} @@ -99,11 +103,15 @@ export default { :title="setTooltip(btn)" :href="btn.href" :target="btn.target" - :class="{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }" + :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]" :data-clipboard-text="btn.dataClipboardText" + :data-qa-selector="btn.dataQaSelector" + :data-method="btn.dataMethod" :icon="btn.icon" :data-testid="btn.testId || 'extension-actions-button'" :variant="btn.variant || 'confirm'" + :loading="btn.loading" + :disabled="btn.loading" category="tertiary" size="small" class="gl-display-none gl-md-display-block gl-float-left" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue index 437d035fbf5..254b280bf14 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue @@ -2,9 +2,9 @@ import { GlSprintf } from '@gitlab/ui'; import { escape } from 'lodash'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { n__, s__ } from '~/locale'; +import { n__, s__, sprintf } from '~/locale'; -const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit'); +const mergeCommitCount = s__('mrWidgetCommitsAdded|%{strongStart}1%{strongEnd} merge commit'); export default { components: { @@ -49,40 +49,45 @@ export default { return escape(this.targetBranch); }, commitsCountMessage() { - return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount); + const count = this.isSquashEnabled ? 1 : this.commitsCount; + + return sprintf( + n__( + '%{strongStart}%{count}%{strongEnd} commit', + '%{strongStart}%{count}%{strongEnd} commits', + count, + ), + { count }, + ); }, message() { - if (this.glFeatures.restructuredMrWidget) { - if (this.state === 'closed') { - return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.'); - } else if (this.isMerged) { - return s__( - 'mrWidgetCommitsAdded|Changes merged into %{targetBranch} with %{mergeCommitSha}%{squashedCommits}.', - ); - } - - return this.isFastForwardEnabled - ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.') - : s__( - 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}%{squashedCommits}.', - ); + if (this.state === 'closed') { + return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.'); + } else if (this.isMerged) { + return s__( + 'mrWidgetCommitsAdded|Changes merged into %{targetBranch} with %{mergeCommitSha}%{squashedCommits}.', + ); } return this.isFastForwardEnabled - ? s__('mrWidgetCommitsAdded|Adds %{commitCount} to %{targetBranch}.') + ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.') : s__( - 'mrWidgetCommitsAdded|Adds %{commitCount} and %{mergeCommitCount} to %{targetBranch}%{squashedCommits}.', + 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}%{squashedCommits}.', ); }, - textDecorativeComponent() { - return this.glFeatures.restructuredMrWidget ? 'span' : 'strong'; - }, squashCommitMessage() { if (this.isMerged) { - return s__('mergedCommitsAdded|(commits were squashed)'); + return s__('mergedCommitsAdded| (commits were squashed)'); } - return n__('(squashes %d commit)', '(squashes %d commits)', this.commitsCount); + return sprintf( + n__( + ' (squashes %{strongStart}%{count}%{strongEnd} commit)', + ' (squashes %{strongStart}%{count}%{strongEnd} commits)', + this.commitsCount, + ), + { count: this.commitsCount }, + ); }, }, mergeCommitCount, @@ -93,25 +98,33 @@ export default { <span> <gl-sprintf :message="message"> <template #commitCount> - <component :is="textDecorativeComponent" class="commits-count-message">{{ - commitsCountMessage - }}</component> + <gl-sprintf :message="commitsCountMessage"> + <template #strong="{ content }"> + <span class="gl-font-weight-bold">{{ content }}</span> + </template> + </gl-sprintf> </template> <template #mergeCommitCount> - <component :is="textDecorativeComponent">{{ $options.mergeCommitCount }}</component> + <gl-sprintf :message="$options.mergeCommitCount"> + <template #strong="{ content }"> + <span class="gl-font-weight-bold">{{ content }}</span> + </template> + </gl-sprintf> </template> <template #targetBranch> - <span class="label-branch">{{ targetBranchEscaped }}</span> + <span class="label-branch gl-font-weight-bold">{{ targetBranchEscaped }}</span> </template> <template #squashedCommits> - <template v-if="glFeatures.restructuredMrWidget && isSquashEnabled"> - {{ squashCommitMessage }}</template - ></template - > + <template v-if="isSquashEnabled"> + <gl-sprintf :message="squashCommitMessage"> + <template #strong="{ content }"> + <span class="gl-font-weight-bold">{{ content }}</span> + </template> + </gl-sprintf> + </template> + </template> <template #mergeCommitSha> - <template v-if="glFeatures.restructuredMrWidget" - ><span class="label-branch">{{ mergeCommitSha }}</span></template - > + <span class="label-branch">{{ mergeCommitSha }}</span> </template> </gl-sprintf> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 4163d195e0f..f782c28ea19 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -4,9 +4,6 @@ import createFlash from '~/flash'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__, __ } from '~/locale'; -import sidebarEventHub from '~/sidebar/event_hub'; -import showToast from '~/vue_shared/plugins/global_toast'; -import SidebarMediator from '~/sidebar/sidebar_mediator'; import eventHub from '../../event_hub'; import approvalsMixin from '../../mixins/approvals'; import MrWidgetContainer from '../mr_widget_container.vue'; @@ -192,16 +189,8 @@ export default { .then((data) => { this.mr.setApprovals(data); - if ( - this.glFeatures.mrAttentionRequests && - SidebarMediator.singleton?.store.currentUserHasAttention - ) { - showToast(__('Approved. Your attention request was removed.')); - } - eventHub.$emit('MRWidgetUpdateRequested'); eventHub.$emit('ApprovalUpdated'); - sidebarEventHub.$emit('removeCurrentUserAttentionRequested'); this.$emit('updated'); }) .catch(errFn) diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue index bb1837399ed..1256b3a8e52 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue @@ -80,22 +80,26 @@ export default { }, computeGraphData(metrics, deploymentTime) { this.loadingMetrics = false; - const { memory_before, memory_after, memory_values } = metrics; + const { + memory_before: memoryBefore, + memory_after: memoryAfter, + memory_values: memoryValues, + } = metrics; // Both `memory_before` and `memory_after` objects // have peculiar structure where accessing only a specific // index yeilds correct value that we can use to show memory delta. - if (memory_before.length > 0) { - this.memoryFrom = this.getMegabytes(memory_before[0].value[1]); + if (memoryBefore.length > 0) { + this.memoryFrom = this.getMegabytes(memoryBefore[0].value[1]); } - if (memory_after.length > 0) { - this.memoryTo = this.getMegabytes(memory_after[0].value[1]); + if (memoryAfter.length > 0) { + this.memoryTo = this.getMegabytes(memoryAfter[0].value[1]); } - if (memory_values.length > 0) { + if (memoryValues.length > 0) { this.hasMetrics = true; - this.memoryMetrics = memory_values[0].values; + this.memoryMetrics = memoryValues[0].values; this.deploymentTime = deploymentTime; } }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md b/app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md new file mode 100644 index 00000000000..45ebafec8bf --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md @@ -0,0 +1 @@ +Please see [the Widget Extensions documentation](development/merge_request_concepts/widget_extensions.md) for necessary information regarding development of new MR Widgets. 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 410331004e4..414c5bf9691 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 @@ -12,8 +12,8 @@ import { sprintf, s__, __ } from '~/locale'; import Poll from '~/lib/utils/poll'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants'; +import Actions from '../action_buttons.vue'; import StatusIcon from './status_icon.vue'; -import Actions from './actions.vue'; import ChildContent from './child_content.vue'; import { createTelemetryHub } from './telemetry'; import { generateText } from './utils'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue index 38f83a61b30..1eccc7de660 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue @@ -1,7 +1,7 @@ <script> import { GlBadge, GlLink, GlSafeHtmlDirective, GlModalDirective } from '@gitlab/ui'; +import Actions from '../action_buttons.vue'; import StatusIcon from './status_icon.vue'; -import Actions from './actions.vue'; import { generateText } from './utils'; export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js index b551cd2fd60..bc84459e298 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js @@ -34,6 +34,36 @@ const nonStandardEvents = { }, counter: {}, }, + metrics: { + uniqueUser: { + expand: ['i_testing_metrics_report_widget_total'], + }, + counter: {}, + }, + browserPerformance: { + uniqueUser: { + expand: ['i_testing_web_performance_widget_total'], + }, + counter: {}, + }, + licenseCompliance: { + uniqueUser: { + expand: ['i_testing_license_compliance_widget_total'], + }, + counter: {}, + }, + loadPerformance: { + uniqueUser: { + expand: ['i_testing_load_performance_widget_total'], + }, + counter: {}, + }, + statusChecks: { + uniqueUser: { + expand: ['i_testing_status_checks_widget'], + }, + counter: {}, + }, }; function combineDeepArray(path, ...objects) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index 913aa0e1e34..94a1b805b99 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,7 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlSafeHtmlDirective as SafeHtml, GlLink } from '@gitlab/ui'; import { s__, n__ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'MRWidgetRelatedLinks', @@ -10,9 +9,7 @@ export default { }, components: { GlLink, - GlSprintf, }, - mixins: [glFeatureFlagMixin()], props: { relatedLinks: { type: Object, @@ -67,42 +64,21 @@ export default { </script> <template> <section> - <p - v-if="relatedLinks.closing" - :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }" - > + <p v-if="relatedLinks.closing" class="gl-display-inline gl-m-0"> {{ closesText }} <span v-safe-html="relatedLinks.closing"></span> </p> - <p - v-if="relatedLinks.mentioned" - :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }" - > - <span v-if="relatedLinks.closing && glFeatures.restructuredMrWidget">·</span> + <p v-if="relatedLinks.mentioned" class="gl-display-inline gl-m-0"> + <span v-if="relatedLinks.closing">·</span> {{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }} <span v-safe-html="relatedLinks.mentioned"></span> </p> - <p - v-if="shouldShowAssignToMeLink" - :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }" - > + <p v-if="shouldShowAssignToMeLink" class="gl-display-inline gl-m-0"> <span> <gl-link rel="nofollow" data-method="post" :href="relatedLinks.assignToMe">{{ assignIssueText }}</gl-link> </span> </p> - <div - v-if="divergedCommitsCount > 0 && !glFeatures.restructuredMrWidget" - class="diverged-commits-count" - > - <gl-sprintf :message="s__('mrWidget|The source branch is %{link} the target branch')"> - <template #link> - <gl-link :href="targetBranchPath">{{ - n__('%d commit behind', '%d commits behind', divergedCommitsCount) - }}</gl-link> - </template> - </gl-sprintf> - </div> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 7ff1eb6e73a..5b8acb4ebf8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,25 +1,17 @@ <script> -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { GlLoadingIcon } from '@gitlab/ui'; import ciIcon from '~/vue_shared/components/ci_icon.vue'; export default { components: { ciIcon, - GlButton, GlLoadingIcon, }, - mixins: [glFeatureFlagMixin()], props: { status: { type: String, required: true, }, - showDisabledButton: { - type: Boolean, - required: false, - default: false, - }, }, computed: { isLoading() { @@ -42,15 +34,5 @@ export default { </div> <ci-icon v-else :status="statusObj" :size="24" /> </div> - - <gl-button - v-if="!glFeatures.restructuredMrWidget && showDisabledButton" - category="primary" - variant="confirm" - data-testid="disabled-merge-button" - :disabled="true" - > - {{ s__('mrWidget|Merge') }} - </gl-button> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue new file mode 100644 index 00000000000..4a5a03fb598 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue @@ -0,0 +1,55 @@ +<script> +import StatusIcon from './mr_widget_status_icon.vue'; +import Actions from './action_buttons.vue'; + +export default { + components: { + StatusIcon, + Actions, + }, + props: { + isLoading: { + type: Boolean, + required: false, + default: false, + }, + status: { + type: String, + required: false, + default: '', + }, + actions: { + type: Array, + required: false, + default: () => [], + }, + }, +}; +</script> + +<template> + <div class="mr-widget-body media"> + <div v-if="isLoading" class="gl-w-full mr-conflict-loader"> + <slot name="loading"></slot> + </div> + <template v-else> + <slot name="icon"> + <status-icon :status="status" /> + </slot> + <div + :class="{ 'gl-display-flex': actions.length, 'gl-md-display-flex': !actions.length }" + class="media-body" + > + <slot></slot> + <div + :class="{ 'gl-flex-direction-column-reverse': !actions.length }" + class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto gl-mt-1" + > + <slot name="actions"> + <actions v-if="actions.length" :tertiary-buttons="actions" /> + </slot> + </div> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue index 18761d04c2e..515a7cf51a1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -1,8 +1,5 @@ <script> -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - export default { - mixins: [glFeatureFlagMixin()], props: { value: { type: String, @@ -23,10 +20,7 @@ export default { <template> <li> <div class="commit-message-editor"> - <div - :class="{ 'gl-mb-3': glFeatures.restructuredMrWidget }" - class="d-flex flex-wrap align-items-center justify-content-between" - > + <div class="d-flex flex-wrap align-items-center justify-content-between gl-mb-3"> <label class="col-form-label" :for="inputId"> <strong>{{ label }}</strong> </label> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index 071920856a8..f74826f95d3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -1,5 +1,4 @@ <script> -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import statusIcon from '../mr_widget_status_icon.vue'; export default { @@ -7,7 +6,6 @@ export default { components: { statusIcon, }, - mixins: [glFeatureFlagMixin()], }; </script> <template> @@ -16,7 +14,7 @@ export default { <status-icon status="warning" show-disabled-button /> </div> <div class="media-body"> - <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold"> + <span class="gl-ml-0! gl-text-body! bold"> {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }} </span> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index aabbeac564a..690acc9a6dc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -1,15 +1,15 @@ <script> -import { GlSkeletonLoader, GlIcon, GlButton, GlSprintf } from '@gitlab/ui'; +import { GlSkeletonLoader, GlIcon, GlSprintf } from '@gitlab/ui'; import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge'; import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql'; import createFlash from '~/flash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { AUTO_MERGE_STRATEGIES } from '../../constants'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import MrWidgetAuthor from '../mr_widget_author.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetAutoMergeEnabled', @@ -29,8 +29,8 @@ export default { MrWidgetAuthor, GlSkeletonLoader, GlIcon, - GlButton, GlSprintf, + StateContainer, }, mixins: [autoMergeMixin, glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], props: { @@ -78,18 +78,25 @@ export default { autoMergeStrategy() { return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).autoMergeStrategy; }, - canRemoveSourceBranch() { - const { currentUserId } = this.mr; - const mergeUserId = this.glFeatures.mergeRequestWidgetGraphql - ? getIdFromGraphQLId(this.state.mergeUser?.id) - : this.mr.mergeUserId; - const canRemoveSourceBranch = this.glFeatures.mergeRequestWidgetGraphql - ? this.state.userPermissions.removeSourceBranch - : this.mr.canRemoveSourceBranch; + actions() { + const actions = []; - return ( - !this.shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId - ); + if (this.loading) { + return actions; + } + + if (this.mr.canCancelAutomaticMerge) { + actions.push({ + text: this.cancelButtonText, + loading: this.isCancellingAutoMerge, + dataQaSelector: 'cancel_auto_merge_button', + class: 'js-cancel-auto-merge', + testId: 'cancelAutomaticMergeButton', + onClick: () => this.cancelAutomaticMerge(), + }); + } + + return actions; }, }, methods: { @@ -144,56 +151,25 @@ export default { }; </script> <template> - <div class="mr-widget-body media"> - <div v-if="loading" class="gl-w-full mr-conflict-loader"> + <state-container status="scheduled" :is-loading="loading" :actions="actions"> + <template #loading> <gl-skeleton-loader :width="334" :height="30"> <rect x="0" y="3" width="24" height="24" rx="4" /> <rect x="32" y="7" width="150" height="16" rx="4" /> <rect x="190" y="7" width="144" height="16" rx="4" /> </gl-skeleton-loader> - </div> - <template v-else> + </template> + <template v-if="!loading"> + <h4 class="gl-mr-3" data-testid="statusText"> + <gl-sprintf :message="statusText" data-testid="statusText"> + <template #merge_author> + <mr-widget-author :author="mergeUser" /> + </template> + </gl-sprintf> + </h4> + </template> + <template v-if="!loading" #icon> <gl-icon name="status_scheduled" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" /> - <div class="media-body"> - <h4 class="gl-display-flex"> - <span class="gl-mr-3"> - <gl-sprintf :message="statusText" data-testid="statusText"> - <template #merge_author> - <mr-widget-author :author="mergeUser" /> - </template> - </gl-sprintf> - </span> - <gl-button - v-if="mr.canCancelAutomaticMerge" - :loading="isCancellingAutoMerge" - size="small" - class="js-cancel-auto-merge" - data-qa-selector="cancel_auto_merge_button" - data-testid="cancelAutomaticMergeButton" - @click="cancelAutomaticMerge" - > - {{ cancelButtonText }} - </gl-button> - </h4> - <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list"> - <p v-if="shouldRemoveSourceBranch"> - {{ s__('mrWidget|Deletes the source branch') }} - </p> - <p v-else class="gl-display-flex"> - <span class="gl-mr-3">{{ s__('mrWidget|Does not delete the source branch') }}</span> - <gl-button - v-if="canRemoveSourceBranch" - :loading="isRemovingSourceBranch" - size="small" - class="js-remove-source-branch" - data-testid="removeSourceBranchButton" - @click="removeSourceBranch" - > - {{ s__('mrWidget|Delete source branch') }} - </gl-button> - </p> - </section> - </div> </template> - </div> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 1a764d3d091..b0cda85f361 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -1,17 +1,15 @@ <script> -import { GlLoadingIcon, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import autoMergeFailedQuery from '../../queries/states/auto_merge_failed.query.graphql'; -import statusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetAutoMergeFailed', components: { - statusIcon, - GlLoadingIcon, - GlButton, + StateContainer, }, mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], apollo: { @@ -38,6 +36,17 @@ export default { isRefreshing: false, }; }, + computed: { + actions() { + return [ + { + text: s__('mrWidget|Refresh'), + loading: this.isRefreshing, + onClick: () => this.refreshWidget(), + }, + ]; + }, + }, methods: { refreshWidget() { this.isRefreshing = true; @@ -49,23 +58,10 @@ export default { }; </script> <template> - <div class="mr-widget-body media"> - <status-icon status="warning" /> - <div class="media-body space-children gl-display-flex gl-flex-wrap gl-align-items-center"> - <span class="bold"> - <template v-if="mergeError">{{ mergeError }}</template> - {{ s__('mrWidget|This merge request failed to be merged automatically') }} - </span> - <gl-button - :disabled="isRefreshing" - category="secondary" - variant="default" - size="small" - @click="refreshWidget" - > - <gl-loading-icon v-if="isRefreshing" size="sm" :inline="true" /> - {{ s__('mrWidget|Refresh') }} - </gl-button> - </div> - </div> + <state-container status="warning" :actions="actions"> + <span class="bold gl-ml-0!"> + <template v-if="mergeError">{{ mergeError }}</template> + {{ s__('mrWidget|This merge request failed to be merged automatically') }} + </span> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index fd42fa0421f..e2d87d8d536 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -1,5 +1,4 @@ <script> -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import statusIcon from '../mr_widget_status_icon.vue'; export default { @@ -7,14 +6,13 @@ export default { components: { statusIcon, }, - mixins: [glFeatureFlagMixin()], }; </script> <template> <div class="mr-widget-body media"> <status-icon :show-disabled-button="true" status="loading" /> <div class="media-body space-children"> - <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold"> + <span class="gl-ml-0! gl-text-body! bold"> {{ s__('mrWidget|Checking if merge request can be merged…') }} </span> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index d50e52f5ac1..61f7d26f51e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -1,5 +1,4 @@ <script> -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -9,7 +8,6 @@ export default { MrWidgetAuthorTime, statusIcon, }, - mixins: [glFeatureFlagMixin()], props: { /* TODO: This is providing all store and service down when it only needs metrics and targetBranch */ @@ -30,13 +28,6 @@ export default { :date-title="mr.metrics.closedAt" :date-readable="mr.metrics.readableClosedAt" /> - - <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list"> - <p> - {{ s__('mrWidget|The changes were not merged into') }} - <a :href="mr.targetBranchPath" class="label-branch"> {{ mr.targetBranch }} </a> - </p> - </section> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index def30dacf8a..8abd915b93e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -4,14 +4,14 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import userPermissionsQuery from '../../queries/permissions.query.graphql'; import conflictsStateQuery from '../../queries/states/conflicts.query.graphql'; -import StatusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetConflicts', components: { GlSkeletonLoader, - StatusIcon, GlButton, + StateContainer, }, mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], apollo: { @@ -86,29 +86,23 @@ export default { }; </script> <template> - <div class="mr-widget-body media"> - <status-icon :show-disabled-button="true" status="warning" /> - - <div v-if="isLoading" class="gl-ml-4 gl-w-full mr-conflict-loader"> + <state-container status="warning" :is-loading="isLoading"> + <template #loading> <gl-skeleton-loader :width="334" :height="30"> <rect x="0" y="7" width="150" height="16" rx="4" /> <rect x="158" y="7" width="84" height="16" rx="4" /> <rect x="250" y="7" width="84" height="16" rx="4" /> </gl-skeleton-loader> - </div> - <div v-else class="media-body space-children gl-display-flex gl-align-items-center"> - <span - v-if="shouldBeRebased" - :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" - class="bold" - > + </template> + <template v-if="!isLoading"> + <span v-if="shouldBeRebased" class="bold gl-ml-0! gl-text-body!"> {{ s__(`mrWidget|Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.`) }} </span> <template v-else> - <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold"> + <span class="bold gl-ml-0! gl-text-body! gl-flex-grow-1 gl-w-full gl-md-w-auto gl-mr-2"> {{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }} <span v-if="!canMerge"> {{ @@ -118,23 +112,30 @@ export default { }} </span> </span> - <gl-button - v-if="showResolveButton" - :href="mr.conflictResolutionPath" - :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'" - data-testid="resolve-conflicts-button" - > - {{ s__('mrWidget|Resolve conflicts') }} - </gl-button> - <gl-button - v-if="canMerge" - :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'" - data-testid="merge-locally-button" - class="js-check-out-modal-trigger" - > - {{ s__('mrWidget|Resolve locally') }} - </gl-button> </template> - </div> - </div> + </template> + <template v-if="!isLoading && !shouldBeRebased" #actions> + <gl-button + v-if="canMerge" + size="small" + variant="confirm" + category="secondary" + data-testid="merge-locally-button" + class="js-check-out-modal-trigger gl-align-self-start" + :class="{ 'gl-mr-2': showResolveButton }" + > + {{ s__('mrWidget|Resolve locally') }} + </gl-button> + <gl-button + v-if="showResolveButton" + :href="mr.conflictResolutionPath" + size="small" + variant="confirm" + class="gl-mb-2 gl-md-mb-0 gl-align-self-start" + data-testid="resolve-conflicts-button" + > + {{ s__('mrWidget|Resolve conflicts') }} + </gl-button> + </template> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index 42e9261b82c..18103ac4a0e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -30,7 +30,7 @@ export default { computed: { mergeError() { - const mergeError = this.mr.mergeError ? stripHtml(this.mr.mergeError, ' ').trim() : ''; + const mergeError = this.prepareMergeError(this.mr.mergeError); return sprintf( s__('mrWidget|%{mergeError}.'), @@ -76,6 +76,13 @@ export default { this.refresh(); } }, + prepareMergeError(mergeError) { + return mergeError + ? stripHtml(mergeError, ' ') + .replace(/(\.$|\s+)/g, ' ') + .trim() + : ''; + }, }, }; </script> @@ -89,7 +96,9 @@ export default { <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> <span class="bold"> - <span v-if="mr.mergeError" class="has-error-message"> {{ mergeError }} </span> + <span v-if="mr.mergeError" class="has-error-message" data-testid="merge-error"> + {{ mergeError }} + </span> <span v-else> {{ s__('mrWidget|Merge failed.') }} </span> <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index bf036f562ed..4416123cd51 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -1,15 +1,13 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlLoadingIcon, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import api from '~/api'; import createFlash from '~/flash'; import { s__, __ } from '~/locale'; import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants'; import modalEventHub from '~/projects/commit/event_hub'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import eventHub from '../../event_hub'; import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetMerged', @@ -19,11 +17,8 @@ export default { components: { MrWidgetAuthorTime, GlIcon, - ClipboardButton, - GlLoadingIcon, - GlButton, + StateContainer, }, - mixins: [glFeatureFlagMixin()], props: { mr: { type: Object, @@ -78,6 +73,53 @@ export default { cherryPickLabel() { return s__('mrWidget|Cherry-pick'); }, + actions() { + const actions = []; + + if (this.mr.canRevertInCurrentMR) { + actions.push({ + text: this.revertLabel, + tooltipText: this.revertTitle, + dataQaSelector: 'revert_button', + onClick: () => this.openRevertModal(), + }); + } else if (this.mr.revertInForkPath) { + actions.push({ + text: this.revertLabel, + tooltipText: this.revertTitle, + href: this.mr.revertInForkPath, + dataQaSelector: 'revert_button', + dataMethod: 'post', + }); + } + + if (this.mr.canCherryPickInCurrentMR) { + actions.push({ + text: this.cherryPickLabel, + tooltipText: this.cherryPickTitle, + dataQaSelector: 'cherry_pick_button', + onClick: () => this.openCherryPickModal(), + }); + } else if (this.mr.cherryPickInForkPath) { + actions.push({ + text: this.cherryPickLabel, + tooltipText: this.cherryPickTitle, + href: this.mr.cherryPickInForkPath, + dataQaSelector: 'cherry_pick_button', + dataMethod: 'post', + }); + } + + if (this.shouldShowRemoveSourceBranch) { + actions.push({ + text: s__('mrWidget|Delete source branch'), + class: 'js-remove-branch-button', + onClick: () => this.removeSourceBranch(), + }); + } + + return actions; + }, }, mounted() { document.dispatchEvent(new CustomEvent('merged:UpdateActions')); @@ -121,103 +163,15 @@ export default { }; </script> <template> - <div class="mr-widget-body media"> - <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" /> - <div class="media-body"> - <div class="space-children"> - <mr-widget-author-time - :action-text="s__('mrWidget|Merged by')" - :author="mr.metrics.mergedBy" - :date-title="mr.metrics.mergedAt" - :date-readable="mr.metrics.readableMergedAt" - /> - <gl-button - v-if="mr.canRevertInCurrentMR" - v-gl-tooltip.hover - :title="revertTitle" - size="small" - category="secondary" - data-qa-selector="revert_button" - @click="openRevertModal" - > - {{ revertLabel }} - </gl-button> - <gl-button - v-else-if="mr.revertInForkPath" - v-gl-tooltip.hover - :href="mr.revertInForkPath" - :title="revertTitle" - size="small" - category="secondary" - data-method="post" - > - {{ revertLabel }} - </gl-button> - <gl-button - v-if="mr.canCherryPickInCurrentMR" - v-gl-tooltip.hover - :title="cherryPickTitle" - size="small" - data-qa-selector="cherry_pick_button" - @click="openCherryPickModal" - > - {{ cherryPickLabel }} - </gl-button> - <gl-button - v-else-if="mr.cherryPickInForkPath" - v-gl-tooltip.hover - :href="mr.cherryPickInForkPath" - :title="cherryPickTitle" - size="small" - data-method="post" - > - {{ cherryPickLabel }} - </gl-button> - <gl-button - v-if="shouldShowRemoveSourceBranch" - :disabled="isMakingRequest" - size="small" - class="js-remove-branch-button" - @click="removeSourceBranch" - > - {{ s__('mrWidget|Delete source branch') }} - </gl-button> - </div> - <section - v-if="!glFeatures.restructuredMrWidget" - class="mr-info-list" - data-qa-selector="merged_status_content" - > - <p> - {{ s__('mrWidget|The changes were merged into') }} - <span class="label-branch"> - <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a> - </span> - <template v-if="mr.mergeCommitSha"> - with - <a - :href="mr.mergeCommitPath" - class="commit-sha js-mr-merged-commit-sha" - v-text="mr.shortMergeCommitSha" - > - </a> - <clipboard-button - :title="__('Copy commit SHA')" - :text="mr.mergeCommitSha" - css-class="js-mr-merged-copy-sha" - category="tertiary" - size="small" - /> - </template> - </p> - <p v-if="mr.sourceBranchRemoved"> - {{ s__('mrWidget|The source branch has been deleted') }} - </p> - <p v-if="shouldShowSourceBranchRemoving"> - <gl-loading-icon size="sm" :inline="true" /> - <span> {{ s__('mrWidget|The source branch is being deleted') }} </span> - </p> - </section> - </div> - </div> + <state-container :actions="actions"> + <template #icon> + <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" /> + </template> + <mr-widget-author-time + :action-text="s__('mrWidget|Merged by')" + :author="mr.metrics.mergedBy" + :date-title="mr.metrics.mergedAt" + :date-readable="mr.metrics.readableMergedAt" + /> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index b86ab69af3f..c7574a41bb8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -1,6 +1,5 @@ <script> import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import simplePoll from '~/lib/utils/simple_poll'; import MergeRequest from '~/merge_request'; import eventHub from '../../event_hub'; @@ -15,7 +14,6 @@ export default { components: { statusIcon, }, - mixins: [glFeatureFlagMixin()], props: { mr: { type: Object, @@ -90,14 +88,6 @@ export default { {{ mergeStatus.message }} <gl-emoji :data-name="mergeStatus.emoji" /> </h4> - <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list"> - <p> - {{ s__('mrWidget|Merges changes into') }} - <span class="label-branch"> - <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a> - </span> - </p> - </section> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index cadbd9c28a9..659d12d1160 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -74,13 +74,7 @@ export default { <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> - <span - :class="{ - 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget, - }" - class="bold js-branch-text" - data-testid="widget-content" - > + <span class="gl-ml-0! gl-text-body! bold js-branch-text" data-testid="widget-content"> <gl-sprintf :message="warning"> <template #code="{ content }"> <code>{{ content }}</code> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue index 34c5a2ff2c8..e99ee59b877 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue @@ -14,7 +14,7 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> - <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold"> + <span class="gl-ml-0! gl-text-body! bold"> {{ s__( `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 59767eb2e6e..6c5fc916799 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -8,7 +8,7 @@ import simplePoll from '~/lib/utils/simple_poll'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import rebaseQuery from '../../queries/states/rebase.query.graphql'; -import statusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetRebase', @@ -25,9 +25,9 @@ export default { }, }, components: { - statusIcon, GlSkeletonLoader, GlButton, + StateContainer, }, mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], props: { @@ -51,9 +51,6 @@ export default { isLoading() { return this.glFeatures.mergeRequestWidgetGraphql && this.$apollo.queries.state.loading; }, - showRebaseWithoutCi() { - return this.glFeatures?.rebaseWithoutCiUi; - }, rebaseInProgress() { if (this.glFeatures.mergeRequestWidgetGraphql) { return this.state.rebaseInProgress; @@ -76,6 +73,10 @@ export default { return this.mr.targetBranch; }, status() { + if (this.isLoading) { + return undefined; + } + if (this.rebaseInProgress || this.isMakingRequest) { return 'loading'; } @@ -148,92 +149,70 @@ export default { }; </script> <template> - <div class="mr-widget-body media"> - <div v-if="isLoading" class="gl-w-full mr-conflict-loader"> + <state-container :status="status" :is-loading="isLoading"> + <template #loading> <gl-skeleton-loader :width="334" :height="30"> <rect x="0" y="3" width="24" height="24" rx="4" /> <rect x="32" y="5" width="302" height="20" rx="4" /> </gl-skeleton-loader> - </div> - <template v-else> - <status-icon :status="status" :show-disabled-button="showDisabledButton" /> - - <div class="rebase-state-find-class-convention media media-body space-children"> + </template> + <template v-if="!isLoading"> + <span + v-if="rebaseInProgress || isMakingRequest" + class="gl-ml-0! gl-text-body! gl-font-weight-bold" + data-testid="rebase-message" + >{{ __('Rebase in progress') }}</span + > + <span + v-if="!rebaseInProgress && !canPushToSourceBranch" + class="gl-text-body! gl-font-weight-bold gl-ml-0!" + data-testid="rebase-message" + >{{ fastForwardMergeText }}</span + > + <div + v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest" + class="accept-merge-holder clearfix js-toggle-container media gl-md-display-flex gl-flex-wrap gl-flex-grow-1" + > <span - v-if="rebaseInProgress || isMakingRequest" - :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" - class="gl-font-weight-bold" + v-if="!rebasingError" + class="gl-font-weight-bold gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3" data-testid="rebase-message" - >{{ __('Rebase in progress') }}</span + data-qa-selector="no_fast_forward_message_content" + >{{ + __('Merge blocked: the source branch must be rebased onto the target branch.') + }}</span > <span - v-if="!rebaseInProgress && !canPushToSourceBranch" - :class="{ 'gl-text-body!': glFeatures.restructuredMrWidget }" - class="gl-font-weight-bold gl-ml-0!" + v-else + class="gl-font-weight-bold danger gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-md-mr-3" data-testid="rebase-message" - >{{ fastForwardMergeText }}</span - > - <div - v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest" - class="accept-merge-holder clearfix js-toggle-container accept-action media space-children gl-align-items-center" + >{{ rebasingError }}</span > - <gl-button - v-if="!glFeatures.restructuredMrWidget" - :loading="isMakingRequest" - variant="confirm" - data-qa-selector="mr_rebase_button" - data-testid="standard-rebase-button" - @click="rebase" - > - {{ __('Rebase') }} - </gl-button> - <gl-button - v-if="!glFeatures.restructuredMrWidget && showRebaseWithoutCi" - :loading="isMakingRequest" - variant="confirm" - category="secondary" - data-testid="rebase-without-ci-button" - @click="rebaseWithoutCi" - > - {{ __('Rebase without pipeline') }} - </gl-button> - <span - v-if="!rebasingError" - :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" - class="gl-font-weight-bold" - data-testid="rebase-message" - data-qa-selector="no_fast_forward_message_content" - >{{ - __('Merge blocked: the source branch must be rebased onto the target branch.') - }}</span - > - <span v-else class="gl-font-weight-bold danger" data-testid="rebase-message">{{ - rebasingError - }}</span> - <gl-button - v-if="glFeatures.restructuredMrWidget" - :loading="isMakingRequest" - variant="confirm" - size="small" - data-qa-selector="mr_rebase_button" - class="gl-ml-3!" - @click="rebase" - > - {{ __('Rebase') }} - </gl-button> - <gl-button - v-if="glFeatures.restructuredMrWidget && showRebaseWithoutCi" - :loading="isMakingRequest" - variant="confirm" - size="small" - category="secondary" - data-testid="rebase-without-ci-button" - @click="rebaseWithoutCi" - > - {{ __('Rebase without pipeline') }} - </gl-button> - </div> </div> </template> - </div> + <template v-if="!isLoading" #actions> + <gl-button + :loading="isMakingRequest" + variant="confirm" + size="small" + category="secondary" + data-testid="rebase-without-ci-button" + class="gl-align-self-start gl-mr-2" + @click="rebaseWithoutCi" + > + {{ __('Rebase without pipeline') }} + </gl-button> + <gl-button + :loading="isMakingRequest" + variant="confirm" + size="small" + data-qa-selector="mr_rebase_button" + data-testid="standard-rebase-button" + class="gl-mb-2 gl-md-mb-0 gl-align-self-start" + @click="rebase" + > + {{ __('Rebase') }} + </gl-button> + </template> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue index d204befef58..d507e5f232b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue @@ -1,7 +1,6 @@ <script> import { GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__ } from '~/locale'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -12,7 +11,6 @@ export default { GlSprintf, statusIcon, }, - mixins: [glFeatureFlagMixin()], computed: { troubleshootingDocsPath() { return helpPagePath('ci/troubleshooting', { anchor: 'merge-request-status-messages' }); @@ -30,7 +28,7 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> - <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold"> + <span class="gl-ml-0! gl-text-body! bold"> <gl-sprintf :message="$options.i18n.failedMessage"> <template #link="{ content }"> <gl-link :href="troubleshootingDocsPath" target="_blank"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index cf482410bef..d2c85b14999 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 @@ -31,12 +31,10 @@ import { import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import MergeRequestStore from '../../stores/mr_widget_store'; -import statusIcon from '../mr_widget_status_icon.vue'; import AddedCommitMessage from '../added_commit_message.vue'; import RelatedLinks from '../mr_widget_related_links.vue'; import CommitEdit from './commit_edit.vue'; import CommitMessageDropdown from './commit_message_dropdown.vue'; -import CommitsHeader from './commits_header.vue'; import SquashBeforeMerge from './squash_before_merge.vue'; import MergeFailedPipelineConfirmationDialog from './merge_failed_pipeline_confirmation_dialog.vue'; @@ -96,9 +94,7 @@ export default { }, }, components: { - statusIcon, SquashBeforeMerge, - CommitsHeader, CommitEdit, CommitMessageDropdown, GlIcon, @@ -320,34 +316,27 @@ export default { showDangerMessageForMergeTrain() { return this.preferredAutoMergeStrategy === MT_MERGE_STRATEGY && this.isPipelineFailed; }, - restructuredWidgetShowMergeButtons() { - if (this.glFeatures.restructuredMrWidget) { - return ( - (this.isMergeAllowed || this.isAutoMergeAvailable) && - this.state.userPermissions.canMerge && - !this.mr.mergeOngoing && - !this.mr.autoMergeEnabled - ); - } - - return true; + shouldShowMergeControls() { + return ( + (this.isMergeAllowed || this.isAutoMergeAvailable) && + (this.stateData.userPermissions?.canMerge || this.mr.canMerge) && + !this.mr.mergeOngoing && + !this.mr.autoMergeEnabled + ); }, sourceBranchDeletedText() { - if (this.glFeatures.restructuredMrWidget) { - if (this.removeSourceBranch) { - return this.mr.state === 'merged' - ? __('Deleted the source branch.') - : __('Source branch will be deleted.'); - } - + if (this.removeSourceBranch) { return this.mr.state === 'merged' - ? __('Did not delete the source branch.') - : __('Source branch will not be deleted.'); + ? __('Deleted the source branch.') + : __('Source branch will be deleted.'); } - return this.removeSourceBranch - ? __('Deletes the source branch.') - : __('Does not delete the source branch.'); + return this.mr.state === 'merged' + ? __('Did not delete the source branch.') + : __('Source branch will not be deleted.'); + }, + showMergeDetailsHeader() { + return ['readyToMerge'].indexOf(this.mr.state) >= 0; }, }, mounted() { @@ -525,10 +514,7 @@ export default { <template> <div data-testid="ready_to_merge_state" - :class="{ - 'gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base': - glFeatures.restructuredMrWidget, - }" + class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" > <div v-if="loading" class="mr-widget-body"> <div class="gl-w-full mr-ready-to-merge-loader"> @@ -541,16 +527,10 @@ export default { </div> </div> <template v-else> - <div - class="mr-widget-body media" - :class="{ - 'mr-widget-body-line-height-1': glFeatures.restructuredMrWidget, - }" - > - <status-icon v-if="!glFeatures.restructuredMrWidget" :status="iconClass" /> + <div class="mr-widget-body mr-widget-body-ready-merge media mr-widget-body-line-height-1"> <div class="media-body"> <div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap"> - <gl-button-group v-if="restructuredWidgetShowMergeButtons" class="gl-align-self-start"> + <gl-button-group v-if="shouldShowMergeControls" class="gl-align-self-start"> <gl-button size="medium" category="primary" @@ -603,19 +583,14 @@ export default { <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" /> <div v-if="shouldShowMergeControls" - :class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }" - class="gl-display-flex gl-align-items-center gl-flex-wrap" + class="gl-display-flex gl-align-items-center gl-flex-wrap gl-w-full gl-order-n1 gl-mb-5" > <gl-form-checkbox v-if="canRemoveSourceBranch" id="remove-source-branch-input" v-model="removeSourceBranch" :disabled="isRemoveSourceBranchButtonDisabled" - :class="{ - 'gl-mx-3': !glFeatures.restructuredMrWidget, - 'gl-mr-5': glFeatures.restructuredMrWidget, - }" - class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center" + class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5" > {{ __('Delete source branch') }} </gl-form-checkbox> @@ -626,16 +601,11 @@ export default { v-model="squashBeforeMerge" :help-path="mr.squashBeforeMergeHelpPath" :is-disabled="isSquashReadOnly" - :class="{ - 'gl-mx-3': !glFeatures.restructuredMrWidget, - 'gl-mr-5': glFeatures.restructuredMrWidget, - }" + class="gl-mr-5" /> <gl-form-checkbox - v-if=" - glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit) - " + v-if="shouldShowSquashEdit || shouldShowMergeEdit" v-model="editCommitMessage" data-testid="widget_edit_commit_message" class="gl-display-flex gl-align-items-center" @@ -644,198 +614,113 @@ export default { </gl-form-checkbox> </div> <div - v-else-if="!glFeatures.restructuredMrWidget" - class="bold js-resolve-mr-widget-items-message gl-ml-3" + v-if="editCommitMessage" + class="gl-w-full gl-order-n1" + data-testid="edit_commit_message" > - <div - v-if="hasPipelineMustSucceedConflict" - class="gl-display-flex gl-align-items-center" - data-testid="pipeline-succeed-conflict" - > - <gl-sprintf :message="pipelineMustSucceedConflictText" /> - <gl-link - :href="mr.pipelineMustSucceedDocsPath" - target="_blank" - class="gl-display-flex gl-ml-2" + <ul class="border-top commits-list flex-list gl-list-style-none gl-p-0 gl-pt-4"> + <commit-edit + v-if="shouldShowSquashEdit" + :value="squashCommitMessage" + :label="__('Squash commit message')" + input-id="squash-message-edit" + class="gl-m-0! gl-p-0!" + @input="setSquashCommitMessage" > - <gl-icon name="question" /> - </gl-link> - </div> - <gl-sprintf v-else :message="mergeDisabledText" /> + <template #header> + <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" /> + </template> + </commit-edit> + <commit-edit + v-if="shouldShowMergeEdit" + :value="commitMessage" + :label="__('Merge commit message')" + input-id="merge-message-edit" + class="gl-m-0! gl-p-0!" + @input="setCommitMessage" + /> + <li class="gl-m-0! gl-p-0!"> + <p class="form-text text-muted"> + <gl-sprintf :message="commitTemplateHintText"> + <template #link="{ content }"> + <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </li> + </ul> </div> - <template v-if="glFeatures.restructuredMrWidget"> - <div - v-if="editCommitMessage" - class="gl-w-full gl-order-n1" - data-testid="edit_commit_message" - > - <ul - :class="{ - 'content-list': !glFeatures.restructuredMrWidget, - 'gl-list-style-none gl-p-0 gl-pt-4': glFeatures.restructuredMrWidget, - }" - class="border-top commits-list flex-list" - > - <commit-edit - v-if="shouldShowSquashEdit" - :value="squashCommitMessage" - :label="__('Squash commit message')" - input-id="squash-message-edit" - class="gl-m-0! gl-p-0!" - @input="setSquashCommitMessage" + <div + v-if="!shouldShowMergeControls" + class="gl-w-full gl-order-n1 mr-widget-merge-details" + data-qa-selector="merged_status_content" + > + <p v-if="showMergeDetailsHeader" class="gl-mb-3 gl-text-gray-900"> + {{ __('Merge details') }} + </p> + <ul class="gl-pl-4 gl-mb-0 gl-ml-3 gl-text-gray-600"> + <li v-if="mr.divergedCommitsCount > 0" class="gl-line-height-normal"> + <gl-sprintf + :message="s__('mrWidget|The source branch is %{link} the target branch')" > - <template #header> - <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" /> + <template #link> + <gl-link :href="mr.targetBranchPath">{{ + n__('%d commit behind', '%d commits behind', mr.divergedCommitsCount) + }}</gl-link> </template> - </commit-edit> - <commit-edit - v-if="shouldShowMergeEdit" - :value="commitMessage" - :label="__('Merge commit message')" - input-id="merge-message-edit" - class="gl-m-0! gl-p-0!" - @input="setCommitMessage" + </gl-sprintf> + </li> + <li class="gl-line-height-normal"> + <added-commit-message + :state="mr.state" + :merge-commit-sha="mr.shortMergeCommitSha" + :is-squash-enabled="squashBeforeMerge" + :is-fast-forward-enabled="!shouldShowMergeEdit" + :commits-count="commitsCount" + :target-branch="stateData.targetBranch" /> - <li class="gl-m-0! gl-p-0!"> - <p class="form-text text-muted"> - <gl-sprintf :message="commitTemplateHintText"> - <template #link="{ content }"> - <gl-link - :href="commitTemplateHelpPage" - class="inline-link" - target="_blank" - > - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </li> - </ul> - </div> - <div - v-if="!restructuredWidgetShowMergeButtons" - class="gl-w-full gl-order-n1 gl-text-gray-500" - data-qa-selector="merged_status_content" - > - <strong v-if="mr.state !== 'closed'"> - {{ __('Merge details') }} - </strong> - <ul class="gl-pl-4 gl-m-0"> - <li v-if="mr.divergedCommitsCount > 0" class="gl-line-height-normal"> - <gl-sprintf - :message="s__('mrWidget|The source branch is %{link} the target branch')" - > - <template #link> - <gl-link :href="mr.targetBranchPath">{{ - n__('%d commit behind', '%d commits behind', mr.divergedCommitsCount) - }}</gl-link> - </template> - </gl-sprintf> - </li> - <li class="gl-line-height-normal"> - <added-commit-message - :state="mr.state" - :merge-commit-sha="mr.shortMergeCommitSha" - :is-squash-enabled="squashBeforeMerge" - :is-fast-forward-enabled="!shouldShowMergeEdit" - :commits-count="commitsCount" - :target-branch="stateData.targetBranch" - /> - </li> - <li v-if="mr.state !== 'closed'" class="gl-line-height-normal"> - {{ sourceBranchDeletedText }} - </li> - <li v-if="mr.relatedLinks" class="gl-line-height-normal"> - <related-links - :state="mr.state" - :related-links="mr.relatedLinks" - :show-assign-to-me="false" - class="mr-ready-merge-related-links gl-display-inline" - /> - </li> - </ul> - </div> - <div - v-else - :class="{ 'gl-mb-5': restructuredWidgetShowMergeButtons }" - class="gl-w-full gl-order-n1 gl-text-gray-500" - > - <added-commit-message - :is-squash-enabled="squashBeforeMerge" - :is-fast-forward-enabled="!shouldShowMergeEdit" - :commits-count="commitsCount" - :target-branch="stateData.targetBranch" - /> - <template v-if="mr.relatedLinks"> - · + </li> + <li v-if="mr.state !== 'closed'" class="gl-line-height-normal"> + {{ sourceBranchDeletedText }} + </li> + <li v-if="mr.relatedLinks" class="gl-line-height-normal"> <related-links :state="mr.state" :related-links="mr.relatedLinks" :show-assign-to-me="false" - :diverged-commits-count="mr.divergedCommitsCount" - :target-branch-path="mr.targetBranchPath" class="mr-ready-merge-related-links gl-display-inline" /> - </template> - </div> - </template> - </div> - <div - v-if="showDangerMessageForMergeTrain && !glFeatures.restructuredMrWidget" - class="gl-mt-5 gl-text-gray-500" - data-testid="failed-pipeline-merge-train-text" - > - {{ __('The latest pipeline for this merge request did not complete successfully.') }} + </li> + </ul> + </div> + <div + v-else + :class="{ 'gl-mb-5': shouldShowMergeControls }" + class="gl-w-full gl-order-n1 gl-text-gray-500" + > + <added-commit-message + :is-squash-enabled="squashBeforeMerge" + :is-fast-forward-enabled="!shouldShowMergeEdit" + :commits-count="commitsCount" + :target-branch="stateData.targetBranch" + /> + <template v-if="mr.relatedLinks"> + · + <related-links + :state="mr.state" + :related-links="mr.relatedLinks" + :show-assign-to-me="false" + :diverged-commits-count="mr.divergedCommitsCount" + :target-branch-path="mr.targetBranchPath" + class="mr-ready-merge-related-links gl-display-inline" + /> + </template> + </div> </div> </div> </div> - <template v-if="shouldShowMergeControls && !glFeatures.restructuredMrWidget"> - <div v-if="!shouldShowMergeEdit" class="mr-fast-forward-message"> - {{ __('Fast-forward merge without a merge commit') }} - </div> - <commits-header - v-if="!glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit)" - :is-squash-enabled="squashBeforeMerge" - :commits-count="commitsCount" - :target-branch="stateData.targetBranch" - :is-fast-forward-enabled="!shouldShowMergeEdit" - :class="{ 'border-bottom': stateData.mergeError }" - > - <ul class="border-top content-list commits-list flex-list"> - <commit-edit - v-if="shouldShowSquashEdit" - :value="squashCommitMessage" - :label="__('Squash commit message')" - input-id="squash-message-edit" - squash - @input="setSquashCommitMessage" - > - <template #header> - <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" /> - </template> - </commit-edit> - <commit-edit - v-if="shouldShowMergeEdit" - :value="commitMessage" - :label="__('Merge commit message')" - input-id="merge-message-edit" - @input="setCommitMessage" - /> - <li> - <p class="form-text text-muted"> - <gl-sprintf :message="commitTemplateHintText"> - <template #link="{ content }"> - <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </li> - </ul> - </commits-header> - </template> </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index b1fbe150fcf..d149f5208fc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -1,19 +1,17 @@ <script> import { GlButton } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { I18N_SHA_MISMATCH } from '../../i18n'; -import statusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'ShaMismatch', components: { - statusIcon, GlButton, + StateContainer, }, i18n: { I18N_SHA_MISMATCH, }, - mixins: [glFeatureFlagMixin()], props: { mr: { type: Object, @@ -24,25 +22,24 @@ export default { </script> <template> - <div class="mr-widget-body media"> - <status-icon :show-disabled-button="false" status="warning" /> - <div class="media-body"> - <span - :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" - class="gl-font-weight-bold" - data-qa-selector="head_mismatch_content" - > - {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }} - </span> + <state-container status="warning"> + <span + class="gl-font-weight-bold gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!" + data-qa-selector="head_mismatch_content" + > + {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }} + </span> + <template #actions> <gl-button - class="gl-ml-3" data-testid="action-button" size="small" category="primary" variant="confirm" + class="gl-align-self-start" :href="mr.mergeRequestDiffsPath" - >{{ $options.i18n.I18N_SHA_MISMATCH.actionButtonLabel }}</gl-button > - </div> - </div> + {{ $options.i18n.I18N_SHA_MISMATCH.actionButtonLabel }} + </gl-button> + </template> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index c6227c4394d..1413a46b4b9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -1,6 +1,5 @@ <script> import { GlIcon, GlTooltipDirective, GlFormCheckbox, GlLink } from '@gitlab/ui'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { SQUASH_BEFORE_MERGE } from '../../i18n'; export default { @@ -12,7 +11,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], i18n: { ...SQUASH_BEFORE_MERGE, }, @@ -36,9 +34,6 @@ export default { tooltipTitle() { return this.isDisabled ? this.$options.i18n.tooltipTitle : null; }, - helpIconName() { - return this.glFeatures.restructuredMrWidget ? 'question-o' : 'question'; - }, }, }; </script> @@ -62,10 +57,10 @@ export default { v-gl-tooltip :href="helpPath" :title="$options.i18n.helpLabel" - :class="{ 'gl-text-blue-600': glFeatures.restructuredMrWidget }" + class="gl-text-blue-600" target="_blank" > - <gl-icon :name="helpIconName" /> + <gl-icon name="question-o" /> <span class="sr-only"> {{ $options.i18n.helpLabel }} </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 25ba4bf12af..035d62eaa59 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -1,16 +1,14 @@ <script> import { GlButton } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import notesEventHub from '~/notes/event_hub'; -import statusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'UnresolvedDiscussions', components: { - statusIcon, GlButton, + StateContainer, }, - mixins: [glFeatureFlagMixin()], props: { mr: { type: Object, @@ -26,38 +24,33 @@ export default { </script> <template> - <div class="mr-widget-body media gl-flex-wrap"> - <status-icon show-disabled-button status="warning" /> - <div class="media-body"> - <span - :class="{ - 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget, - 'gl-display-block': !glFeatures.restructuredMrWidget, - }" - class="gl-ml-3 gl-font-weight-bold gl-w-100" - > - {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }} - </span> + <state-container status="warning"> + <span + class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!" + > + {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }} + </span> + <template #actions> <gl-button - data-testid="jump-to-first" - class="gl-ml-3" + v-if="mr.createIssueToResolveDiscussionsPath" + :href="mr.createIssueToResolveDiscussionsPath" + class="js-create-issue gl-align-self-start gl-vertical-align-top gl-mr-2" size="small" - :icon="glFeatures.restructuredMrWidget ? undefined : 'comment-next'" - :variant="glFeatures.restructuredMrWidget ? 'confirm' : 'default'" - :category="glFeatures.restructuredMrWidget ? 'secondary' : 'primary'" - @click="jumpToFirstUnresolvedDiscussion" + variant="confirm" + category="secondary" > - {{ s__('mrWidget|Jump to first unresolved thread') }} + {{ s__('mrWidget|Create issue to resolve all threads') }} </gl-button> <gl-button - v-if="mr.createIssueToResolveDiscussionsPath" - :href="mr.createIssueToResolveDiscussionsPath" - class="js-create-issue gl-ml-3" + data-testid="jump-to-first" + class="gl-mb-2 gl-md-mb-0 gl-align-self-start gl-vertical-align-top" size="small" - :icon="glFeatures.restructuredMrWidget ? undefined : 'issue-new'" + variant="confirm" + category="primary" + @click="jumpToFirstUnresolvedDiscussion" > - {{ s__('mrWidget|Create issue to resolve all threads') }} + {{ s__('mrWidget|Jump to first unresolved thread') }} </gl-button> - </div> - </div> + </template> + </state-container> </template> 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 5bd7745d704..cf7f83c014a 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 @@ -12,13 +12,13 @@ import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_va import getStateQuery from '../../queries/get_state.query.graphql'; import draftQuery from '../../queries/states/draft.query.graphql'; import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql'; -import StatusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'WorkInProgress', components: { - StatusIcon, GlButton, + StateContainer, }, mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], apollo: { @@ -163,29 +163,22 @@ export default { </script> <template> - <div class="mr-widget-body media"> - <status-icon :show-disabled-button="canUpdate" status="warning" /> - <div class="media-body"> - <div class="float-left"> - <span - :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" - class="gl-font-weight-bold" - > - {{ - __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") - }} - </span> - </div> + <state-container status="warning"> + <span class="gl-font-weight-bold gl-ml-0! gl-text-body! gl-flex-grow-1"> + {{ __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") }} + </span> + <template #actions> <gl-button v-if="canUpdate" size="small" :disabled="isMakingRequest" :loading="isMakingRequest" - class="js-remove-draft gl-ml-3" + variant="confirm" + class="js-remove-draft gl-md-ml-3 gl-align-self-start" @click="handleRemoveDraft" > {{ s__('mrWidget|Mark as ready') }} </gl-button> - </div> - </div> + </template> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue new file mode 100644 index 00000000000..f1c1bde256f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue @@ -0,0 +1,27 @@ +<script> +export default { + props: { + mr: { + type: Object, + required: true, + }, + }, + computed: { + widgets() { + return [].filter((w) => w); + }, + }, +}; +</script> + +<template> + <section role="region" :aria-label="__('Merge request reports')" data-testid="mr-widget-app"> + <component + :is="widget" + v-for="(widget, index) in widgets" + :key="widget.name || index" + :mr="mr" + :class="{ 'mr-widget-border-top': index === 0 }" + /> + </section> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue new file mode 100644 index 00000000000..9c8819327e6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -0,0 +1,158 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import Poll from '~/lib/utils/poll'; +import StatusIcon from '../extensions/status_icon.vue'; +import { EXTENSION_ICON_NAMES } from '../../constants'; + +const FETCH_TYPE_COLLAPSED = 'collapsed'; + +export default { + components: { + StatusIcon, + }, + props: { + /** + * @param {value.collapsed} Object + * @param {value.extended} Object + */ + value: { + type: Object, + required: true, + }, + loadingText: { + type: String, + required: false, + default: __('Loading'), + }, + errorText: { + type: String, + required: false, + default: __('Failed to load'), + }, + fetchCollapsedData: { + type: Function, + required: true, + }, + fetchExtendedData: { + type: Function, + required: false, + default: undefined, + }, + // If the summary slot is not used, this value will be used as a fallback. + summary: { + type: String, + required: false, + default: undefined, + }, + // If the content slot is not used, this value will be used as a fallback. + content: { + type: Object, + required: false, + default: undefined, + }, + multiPolling: { + type: Boolean, + required: false, + default: false, + }, + statusIconName: { + type: String, + default: 'neutral', + required: false, + validator: (value) => Object.keys(EXTENSION_ICON_NAMES).indexOf(value) > -1, + }, + widgetName: { + type: String, + required: true, + }, + }, + data() { + return { + isLoading: false, + error: null, + }; + }, + watch: { + isLoading(newValue) { + this.$emit('is-loading', newValue); + }, + }, + async mounted() { + this.isLoading = true; + + try { + await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED); + } catch { + this.error = this.errorText; + } + + this.isLoading = false; + }, + methods: { + fetch(handler, dataType) { + const requests = this.multiPolling ? handler() : [handler]; + + const promises = requests.map((request) => { + return new Promise((resolve, reject) => { + const poll = new Poll({ + resource: { + fetchData: () => request(), + }, + method: 'fetchData', + successCallback: (response) => { + const headers = normalizeHeaders(response.headers); + + if (headers['POLL-INTERVAL']) { + return; + } + + resolve(response.data); + }, + errorCallback: (e) => { + Sentry.captureException(e); + reject(e); + }, + }); + + poll.makeRequest(); + }); + }); + + return Promise.all(promises).then((data) => { + this.$emit('input', { ...this.value, [dataType]: this.multiPolling ? data : data[0] }); + }); + }, + }, +}; +</script> + +<template> + <section class="media-section" data-testid="widget-extension"> + <div class="media gl-p-5"> + <status-icon + :level="1" + :name="widgetName" + :is-loading="isLoading" + :icon-name="statusIconName" + /> + <div + class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center" + data-testid="widget-extension-top-level" + > + <div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary"> + <slot name="summary">{{ isLoading ? loadingText : summary }}</slot> + </div> + <!-- actions will go here --> + <!-- toggle button will go here --> + </div> + </div> + <div + class="mr-widget-grouped-section gl-relative" + data-testid="widget-extension-collapsed-section" + > + <slot name="content">{{ content }}</slot> + </div> + </section> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js index 22e907f7e48..0fb5e13ad82 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js @@ -6,7 +6,6 @@ import { EXTENSION_ICONS } from '../../constants'; export default { name: 'WidgetAccessibility', enablePolling: true, - telemetry: false, i18n: { loading: s__('Reports|Accessibility scanning results are being parsed'), error: s__('Reports|Accessibility scanning failed loading results'), @@ -76,9 +75,9 @@ export default { return sprintf(s__('AccessibilityReport|Message: %{message}'), { message }); }, prepareReports() { - const { new_errors, existing_errors, resolved_errors } = this.collapsedData; + const { collapsedData } = this; - const newErrors = new_errors.map((error) => { + const newErrors = collapsedData.new_errors.map((error) => { return { header: __('New'), id: uniqueId('new-error-'), @@ -92,7 +91,7 @@ export default { }; }); - const existingErrors = existing_errors.map((error) => { + const existingErrors = collapsedData.existing_errors.map((error) => { return { id: uniqueId('existing-error-'), text: this.formatText(error.code), @@ -105,7 +104,7 @@ export default { }; }); - const resolvedErrors = resolved_errors.map((error) => { + const resolvedErrors = collapsedData.resolved_errors.map((error) => { return { id: uniqueId('resolved-error-'), text: this.formatText(error.code), diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js index 4ffd06de61f..6896f8831e8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js @@ -51,14 +51,14 @@ export const recentFailuresTextBuilder = (summary = {}) => { return i18n.recentFailureSummary(recentlyFailed, failed); }; -export const reportSubTextBuilder = ({ suite_errors, summary }) => { - if (suite_errors?.head || suite_errors?.base) { +export const reportSubTextBuilder = ({ suite_errors: suiteErrors, summary }) => { + if (suiteErrors?.head || suiteErrors?.base) { const errors = []; - if (suite_errors?.head) { - errors.push(`${i18n.headReportParsingError} ${suite_errors.head}`); + if (suiteErrors?.head) { + errors.push(`${i18n.headReportParsingError} ${suiteErrors.head}`); } - if (suite_errors?.base) { - errors.push(`${i18n.baseReportParsingError} ${suite_errors.base}`); + if (suiteErrors?.base) { + errors.push(`${i18n.baseReportParsingError} ${suiteErrors.base}`); } return errors.join('<br />'); } 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 627ddb0445e..d964b4bacac 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 @@ -20,13 +20,6 @@ export default { this.mr.preventMerge, ); }, - shouldShowMergeControls() { - if (this.glFeatures.restructuredMrWidget) { - return this.restructuredWidgetShowMergeButtons; - } - - return this.isMergeAllowed || this.isAutoMergeAvailable; - }, mergeDisabledText() { if (this.pipeline?.status === PIPELINE_SKIPPED_STATUS) { return MERGE_DISABLED_SKIPPED_PIPELINE_TEXT; 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 3e0ac236fdf..1e25143e15c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -1,7 +1,6 @@ <script> import { GlSafeHtmlDirective } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import securityReportExtension from 'ee_else_ce/vue_merge_request_widget/extensions/security_reports'; import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; @@ -17,7 +16,6 @@ import { setFaviconOverlay } from '../lib/utils/favicon'; import Loading from './components/loading.vue'; import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue'; import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; -import WidgetRelatedLinks from './components/mr_widget_related_links.vue'; import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue'; import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue'; import ArchivedState from './components/states/mr_widget_archived.vue'; @@ -40,6 +38,7 @@ 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 WidgetContainer from './components/widget/app.vue'; import { STATE_MACHINE, stateToComponentMap } from './constants'; import eventHub from './event_hub'; import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables'; @@ -59,9 +58,9 @@ export default { components: { Loading, ExtensionsContainer, + WidgetContainer, 'mr-widget-suggest-pipeline': WidgetSuggestPipeline, MrWidgetPipelineContainer, - 'mr-widget-related-links': WidgetRelatedLinks, MrWidgetAlertMessage, 'mr-widget-merged': MergedState, 'mr-widget-closed': ClosedState, @@ -73,9 +72,7 @@ export default { 'mr-widget-nothing-to-merge': NothingToMergeState, 'mr-widget-not-allowed': NotAllowedState, 'mr-widget-missing-branch': MissingBranchState, - 'mr-widget-ready-to-merge': window.gon?.features?.restructuredMrWidget - ? () => import('./components/states/new_ready_to_merge.vue') - : ReadyToMergeState, + 'mr-widget-ready-to-merge': () => import('./components/states/new_ready_to_merge.vue'), 'sha-mismatch': ShaMismatch, 'mr-widget-checking': CheckingState, 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, @@ -163,12 +160,6 @@ export default { shouldRenderCodeQuality() { return this.mr?.codequalityReportsPath; }, - shouldRenderRelatedLinks() { - return ( - (Boolean(this.mr.relatedLinks) || this.mr.divergedCommitsCount > 0) && - !this.mr.isNothingToMergeState - ); - }, shouldRenderSourceBranchRemovalStatus() { return ( !this.mr.canRemoveSourceBranch && @@ -239,9 +230,6 @@ export default { shouldShowCodeQualityExtension() { return window.gon?.features?.refactorCodeQualityExtension; }, - isRestructuredMrWidgetEnabled() { - return window.gon?.features?.restructuredMrWidget; - }, }, watch: { 'mr.machineValue': { @@ -275,11 +263,6 @@ export default { this.registerTestReportExtension(); } }, - shouldRenderSecurityReport(newVal) { - if (newVal) { - this.registerSecurityReportExtension(); - } - }, }, mounted() { MRWidgetService.fetchInitialData() @@ -535,11 +518,6 @@ export default { registerExtension(testReportExtension); } }, - registerSecurityReportExtension() { - if (this.shouldRenderSecurityReport && this.shouldShowSecurityExtension) { - registerExtension(securityReportExtension); - } - }, }, }; </script> @@ -600,7 +578,11 @@ export default { </template> </mr-widget-alert-message> </div> + <extensions-container :mr="mr" /> + + <widget-container v-if="mr" :mr="mr" /> + <grouped-codequality-reports-app v-if="shouldRenderCodeQuality && !shouldShowCodeQualityExtension" :head-blob-path="mr.headBlobPath" @@ -638,23 +620,7 @@ export default { <div class="mr-widget-section" data-qa-selector="mr_widget_content"> <component :is="componentName" :mr="mr" :service="service" /> - <ready-to-merge - v-if="isRestructuredMrWidgetEnabled && mr.commitsCount" - :mr="mr" - :service="service" - /> - <div v-else class="mr-widget-info"> - <mr-widget-related-links - v-if="shouldRenderRelatedLinks" - :state="mr.state" - :related-links="mr.relatedLinks" - :diverged-commits-count="mr.divergedCommitsCount" - :target-branch-path="mr.targetBranchPath" - class="mr-info-list gl-ml-7 gl-pb-5" - /> - - <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" /> - </div> + <ready-to-merge v-if="mr.commitsCount" :mr="mr" :service="service" /> </div> </div> <mr-widget-pipeline-container diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql index efc0673bc26..54770e6579a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql @@ -1,11 +1,9 @@ fragment ReadyToMerge on Project { - __typename id onlyAllowMergeIfPipelineSucceeds mergeRequestsFfOnlyEnabled squashReadOnly mergeRequest(iid: $iid) { - __typename id autoMergeEnabled shouldRemoveSourceBranch diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 18d955652ba..7a458f9ce7e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -27,8 +27,6 @@ export default function deviseState() { return stateKey.shaMismatch; } else if (this.autoMergeEnabled && !this.mergeError) { return stateKey.autoMergeEnabled; - } else if (!this.canMerge && !window.gon?.features?.restructuredMrWidget) { - return stateKey.notAllowedToMerge; } else if (this.canBeMerged) { return stateKey.readyToMerge; } 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 03c9a01cc7a..146cf7e11a7 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 @@ -33,7 +33,6 @@ export default class MergeRequestStore { this.setData(data); this.initCodeQualityReport(data); - this.initSecurityReport(data); this.setGitpodData(data); } @@ -42,19 +41,6 @@ export default class MergeRequestStore { this.codeQuality = data.codequality_reports_path; } - initSecurityReport(data) { - // TODO: check if gl.mrWidgetData can be safely removed after we migrate to the - // widget extension. - this.securityReportPaths = { - apiFuzzingReportPath: data.api_fuzzing_comparison_path, - coverageFuzzingReportPath: data.coverage_fuzzing_comparison_path, - sastReportPath: data.sast_comparison_path, - dastReportPath: data.dast_comparison_path, - secretDetectionReportPath: data.secret_detection_comparison_path, - dependencyScanningReportPath: data.dependency_scanning_comparison_path, - }; - } - setData(data, isRebased) { this.initApprovals(); diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue index e12e06a2454..5b9efff1c06 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue @@ -53,6 +53,7 @@ export default { :variant="buttonVariant" :disabled="disabled" :data-testid="buttonTestid" + data-qa-selector="confirm_danger_button" >{{ buttonText }}</gl-button > <confirm-danger-modal diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue index 37e480f7e41..7a982bc035a 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -66,7 +66,13 @@ export default { actionPrimary() { return { text: this.confirmButtonText, - attributes: [{ variant: 'danger', disabled: !this.isValid, class: 'qa-confirm-button' }], + attributes: [ + { + variant: 'danger', + disabled: !this.isValid, + 'data-qa-selector': 'confirm_danger_modal_button', + }, + ], }; }, actionCancel() { @@ -122,7 +128,8 @@ export default { <gl-form-input id="confirm_name_input" v-model="confirmationPhrase" - class="form-control qa-confirm-input" + class="form-control" + data-qa-selector="confirm_danger_field" data-testid="confirm-danger-input" type="text" /> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js index f4317ba90a2..7c4e372dda1 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js @@ -30,12 +30,12 @@ export function fetchBranches({ commit, state }, search = '') { }); } -export const fetchMilestones = ({ commit, state }, search_title = '') => { +export const fetchMilestones = ({ commit, state }, searchTitle = '') => { commit(types.REQUEST_MILESTONES); const { milestonesEndpoint } = state; return axios - .get(milestonesEndpoint, { params: { search_title } }) + .get(milestonesEndpoint, { params: { search_title: searchTitle } }) .then((response) => { commit(types.RECEIVE_MILESTONES_SUCCESS, response.data); return response; diff --git a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue index acddf16bd27..72148a0aa7c 100644 --- a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue +++ b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue @@ -2,6 +2,7 @@ import { GlBadge } from '@gitlab/ui'; import { s__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; const STATUS_TYPES = { SUCCESS: 'success', @@ -45,7 +46,7 @@ export default { methods: { checkGitlabVersion() { axios - .get('/admin/version_check.json') + .get(joinPaths('/', gon.relative_url_root, '/admin/version_check.json')) .then((res) => { if (res.data) { this.status = res.data.severity; diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 4fdf7f45643..1d1b65aa1af 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -156,6 +156,14 @@ export default { }) .catch(() => {}); }, + handleAttachFile(e) { + e.preventDefault(); + const $gfmForm = $(this.$el).closest('.gfm-form'); + const $gfmTextarea = $gfmForm.find('.js-gfm-input'); + + $gfmForm.find('.div-dropzone').click(); + $gfmTextarea.focus(); + }, }, shortcuts: { bold: keysFor(BOLD_TEXT), @@ -195,6 +203,44 @@ export default { :class="{ 'gl-display-none!': previewMarkdown }" class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center" > + <template v-if="canSuggest"> + <toolbar-button + ref="suggestButton" + :tag="mdSuggestion" + :prepend="true" + :button-title="__('Insert suggestion')" + :cursor-offset="4" + :tag-content="lineContent" + icon="doc-code" + data-qa-selector="suggestion_button" + class="js-suggestion-btn" + @click="handleSuggestDismissed" + /> + <gl-popover + v-if="suggestPopoverVisible" + :target="$refs.suggestButton.$el" + :css-classes="['diff-suggest-popover']" + placement="bottom" + :show="suggestPopoverVisible" + > + <strong>{{ __('New! Suggest changes directly') }}</strong> + <p class="mb-2"> + {{ + __( + 'Suggest code changes which can be immediately applied in one click. Try it out!', + ) + }} + </p> + <gl-button + variant="confirm" + category="primary" + size="small" + @click="handleSuggestDismissed" + > + {{ __('Got it') }} + </gl-button> + </gl-popover> + </template> <toolbar-button tag="**" :button-title=" @@ -237,44 +283,6 @@ export default { icon="quote" @click="handleQuote" /> - <template v-if="canSuggest"> - <toolbar-button - ref="suggestButton" - :tag="mdSuggestion" - :prepend="true" - :button-title="__('Insert suggestion')" - :cursor-offset="4" - :tag-content="lineContent" - icon="doc-code" - data-qa-selector="suggestion_button" - class="js-suggestion-btn" - @click="handleSuggestDismissed" - /> - <gl-popover - v-if="suggestPopoverVisible" - :target="$refs.suggestButton.$el" - :css-classes="['diff-suggest-popover']" - placement="bottom" - :show="suggestPopoverVisible" - > - <strong>{{ __('New! Suggest changes directly') }}</strong> - <p class="mb-2"> - {{ - __( - 'Suggest code changes which can be immediately applied in one click. Try it out!', - ) - }} - </p> - <gl-button - variant="confirm" - category="primary" - size="small" - @click="handleSuggestDismissed" - > - {{ __('Got it') }} - </gl-button> - </gl-popover> - </template> <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> <toolbar-button tag="[{text}](url)" @@ -306,7 +314,7 @@ export default { v-if="!restrictedToolBarItems.includes('task-list')" :prepend="true" tag="- [ ] " - :button-title="__('Add a task list')" + :button-title="__('Add a checklist')" icon="list-task" /> <toolbar-button @@ -324,6 +332,15 @@ export default { :button-title="__('Add a table')" icon="table" /> + <gl-button + v-if="!restrictedToolBarItems.includes('attach-file')" + v-gl-tooltip + :title="__('Attach a file or image')" + data-testid="button-attach-file" + category="tertiary" + icon="paperclip" + @click="handleAttachFile" + /> <toolbar-button v-if="!restrictedToolBarItems.includes('full-screen')" class="js-zen-enter" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 6c99a749edc..aa325862f06 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -74,7 +74,7 @@ export default { </div> <span v-if="canAttachFile" class="uploading-container"> <span class="uploading-progress-container hide"> - <gl-icon name="media" /> + <gl-icon name="paperclip" /> <span class="attaching-file-message"></span> <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> <span class="uploading-progress">0%</span> @@ -82,7 +82,7 @@ export default { </span> <span class="uploading-error-container hide"> <span class="uploading-error-icon"> - <gl-icon name="media" /> + <gl-icon name="paperclip" /> </span> <span class="uploading-error-message"></span> @@ -114,14 +114,6 @@ export default { </gl-sprintf> </span> <gl-button - icon="media" - variant="link" - category="primary" - class="markdown-selector button-attach-file gl-vertical-align-text-bottom" - > - {{ __('Attach a file') }} - </gl-button> - <gl-button variant="link" category="primary" class="button-cancel-uploading-files gl-vertical-align-baseline hide" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 6a83939795c..49217e38a1b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -88,6 +88,6 @@ export default { category="tertiary" class="js-md" data-container="body" - @click="() => $emit('click')" + @click="$emit('click', $event)" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue index 521b1a1075a..e9f278a5db5 100644 --- a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue +++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue @@ -5,6 +5,8 @@ import { GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType, + GlIntersectionObserver, + GlLoadingIcon, } from '@gitlab/ui'; import { __ } from '~/locale'; @@ -32,6 +34,8 @@ export default { GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType, + GlIntersectionObserver, + GlLoadingIcon, }, props: { groupNamespaces: { @@ -69,6 +73,26 @@ export default { required: false, default: false, }, + hasNextPageOfGroups: { + type: Boolean, + required: false, + default: false, + }, + isLoadingMoreGroups: { + type: Boolean, + required: false, + default: false, + }, + isSearchLoading: { + type: Boolean, + required: false, + default: false, + }, + shouldFilterNamespaces: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -84,10 +108,12 @@ export default { return this.groupNamespaces.length; }, filteredGroupNamespaces() { + if (!this.shouldFilterNamespaces) return this.groupNamespaces; if (!this.hasGroupNamespaces) return []; return filterByName(this.groupNamespaces, this.searchTerm); }, filteredUserNamespaces() { + if (!this.shouldFilterNamespaces) return this.userNamespaces; if (!this.hasUserNamespaces) return []; return filterByName(this.userNamespaces, this.searchTerm); }, @@ -107,9 +133,15 @@ export default { return emptyNamespaceTitle.toLowerCase().includes(searchTerm.toLowerCase()); }, }, + watch: { + searchTerm() { + this.$emit('search', this.searchTerm); + }, + }, methods: { handleSelect(item) { this.selectedNamespace = item; + this.searchTerm = ''; this.$emit('select', item); }, handleSelectEmptyNamespace() { @@ -122,7 +154,11 @@ export default { <template> <gl-dropdown :text="selectedNamespaceText" :block="fullWidth" data-qa-selector="namespaces_list"> <template #header> - <gl-search-box-by-type v-model.trim="searchTerm" /> + <gl-search-box-by-type + v-model.trim="searchTerm" + :is-loading="isSearchLoading" + data-qa-selector="namespaces_list_search" + /> </template> <div v-if="filteredEmptyNamespaceTitle"> <gl-dropdown-item @@ -133,29 +169,40 @@ export default { </gl-dropdown-item> <gl-dropdown-divider /> </div> - <div v-if="hasGroupNamespaces" data-qa-selector="namespaces_list_groups"> + <div + v-if="hasUserNamespaces" + data-qa-selector="namespaces_list_users" + data-testid="namespace-list-users" + > <gl-dropdown-section-header v-if="includeHeaders">{{ - $options.i18n.GROUPS + $options.i18n.USERS }}</gl-dropdown-section-header> <gl-dropdown-item - v-for="item in filteredGroupNamespaces" + v-for="item in filteredUserNamespaces" :key="item.id" data-qa-selector="namespaces_list_item" @click="handleSelect(item)" >{{ item.humanName }}</gl-dropdown-item > </div> - <div v-if="hasUserNamespaces" data-qa-selector="namespaces_list_users"> + <div + v-if="hasGroupNamespaces" + data-qa-selector="namespaces_list_groups" + data-testid="namespace-list-groups" + > <gl-dropdown-section-header v-if="includeHeaders">{{ - $options.i18n.USERS + $options.i18n.GROUPS }}</gl-dropdown-section-header> <gl-dropdown-item - v-for="item in filteredUserNamespaces" + v-for="item in filteredGroupNamespaces" :key="item.id" data-qa-selector="namespaces_list_item" @click="handleSelect(item)" >{{ item.humanName }}</gl-dropdown-item > </div> + <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="$emit('load-more-groups')"> + <gl-loading-icon v-if="isLoadingMoreGroups" class="gl-mb-3" size="sm" /> + </gl-intersection-observer> </gl-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.vue b/app/assets/javascripts/vue_shared/components/project_avatar.vue index 402e75962d2..f65cc8bf2f3 100644 --- a/app/assets/javascripts/vue_shared/components/project_avatar.vue +++ b/app/assets/javascripts/vue_shared/components/project_avatar.vue @@ -1,5 +1,6 @@ <script> import { GlAvatar } from '@gitlab/ui'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; export default { @@ -7,6 +8,14 @@ export default { GlAvatar, }, props: { + projectId: { + type: [Number, String], + default: 0, + required: false, + validator(value) { + return typeof value === 'string' ? isGid(value) : true; + }, + }, projectName: { type: String, required: true, @@ -31,6 +40,9 @@ export default { avatarAlt() { return this.alt ?? this.projectName; }, + entityId() { + return isGid(this.projectId) ? getIdFromGraphQLId(this.projectId) : this.projectId; + }, }, AVATAR_SHAPE_OPTION_RECT, }; @@ -39,6 +51,7 @@ export default { <template> <gl-avatar :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :entity-id="entityId" :entity-name="projectName" :src="projectAvatarUrl" :alt="avatarAlt" 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 19ffbe37ce7..66643ff4026 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 @@ -53,6 +53,7 @@ export default { > <gl-icon v-if="selected" class="js-selected-icon" name="mobile-issue-close" /> <project-avatar + :project-id="project.id" :project-avatar-url="projectAvatarUrl" :project-name="projectNameWithNamespace" class="gl-mr-3" diff --git a/app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue b/app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue new file mode 100644 index 00000000000..424a11bf88b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue @@ -0,0 +1,42 @@ +<script> +import { GlTooltip } from '@gitlab/ui'; + +import { formatDate } from '~/lib/utils/datetime_utility'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + components: { + GlTooltip, + }, + mixins: [timeagoMixin], + props: { + target: { + type: [Object, HTMLElement, SVGElement, String, Function], + required: true, + }, + rawTimestamp: { + type: String, + required: true, + }, + timestampTypeText: { + type: String, + required: true, + }, + }, + computed: { + timestampInWords() { + return this.rawTimestamp ? this.timeFormatted(this.rawTimestamp) : ''; + }, + timestamp() { + return this.rawTimestamp ? formatDate(new Date(this.rawTimestamp)) : ''; + }, + }, +}; +</script> + +<template> + <gl-tooltip :target="target"> + <div class="bold" data-testid="header-text">{{ timestampTypeText }} {{ timestampInWords }}</div> + <div class="text-tertiary" data-testid="body-text">{{ timestamp }}</div> + </gl-tooltip> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql index be270e440ed..4af07366a6d 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql @@ -3,11 +3,13 @@ query issueAssignees($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { - __typename id issuable: issue(iid: $iid) { - __typename id + author { + ...User + ...UserAvailability + } assignees { nodes { ...User diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql index 96a40e597ee..445817d3e52 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -3,10 +3,8 @@ query issueParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { - __typename id issuable: issue(iid: $iid) { - __typename id participants { nodes { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql index dffcc053fac..b127b8ec5a9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql @@ -2,7 +2,6 @@ query issueTimeTrackingReport($id: IssueID!) { issuable: issue(id: $id) { - __typename id title timelogs { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql new file mode 100644 index 00000000000..05de680ab05 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql @@ -0,0 +1,26 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query mergeRequestReviewers($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + id + issuable: mergeRequest(iid: $iid) { + id + reviewers { + nodes { + ...User + ...UserAvailability + mergeRequestInteraction { + canMerge + canUpdate + approved + reviewed + } + } + } + userPermissions { + updateMergeRequest + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql index 7127940bb05..f70cd723f2e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql @@ -6,6 +6,13 @@ query getMrAssignees($fullPath: ID!, $iid: String!) { id issuable: mergeRequest(iid: $iid) { id + author { + ...User + ...UserAvailability + mergeRequestInteraction { + canMerge + } + } assignees { nodes { ...User diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql index ede9b75d765..17f548b44b5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql @@ -2,7 +2,6 @@ query mrTimeTrackingReport($id: MergeRequestID!) { issuable: mergeRequest(id: $id) { - __typename id title timelogs { diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue index 6a0bf07c8b4..1925c5d4064 100644 --- a/app/assets/javascripts/vue_shared/components/source_editor.vue +++ b/app/assets/javascripts/vue_shared/components/source_editor.vue @@ -1,5 +1,5 @@ <script> -import { debounce } from 'lodash'; +import { debounce, isEmpty } from 'lodash'; import { CONTENT_UPDATE_DEBOUNCE, EDITOR_READY_EVENT } from '~/editor/constants'; import Editor from '~/editor/source_editor'; @@ -37,9 +37,9 @@ export default { default: '', }, extensions: { - type: [String, Array], + type: [Object, Array], required: false, - default: () => null, + default: () => ({}), }, editorOptions: { type: Object, @@ -74,11 +74,13 @@ export default { blobPath: this.fileName, blobContent: this.value, blobGlobalId: this.fileGlobalId, - extensions: this.extensions, ...this.editorOptions, }); this.editor.onDidChangeModelContent(debounce(this.onFileChange.bind(this), this.debounceValue)); + if (!isEmpty(this.extensions)) { + this.editor.use(this.extensions); + } }, beforeDestroy() { this.editor.dispose(); diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue index 6babbca58c3..9683288f937 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -51,6 +51,10 @@ export default { required: false, default: null, }, + blamePath: { + type: String, + required: true, + }, }, computed: { lines() { @@ -76,6 +80,7 @@ export default { :number="startingFrom + index + 1" :content="line" :language="language" + :blame-path="blamePath" /> </div> <div v-else class="gl-display-flex"> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue index 7b62f0cdb7d..257b9f57222 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue @@ -1,15 +1,14 @@ <script> -import { GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlSafeHtmlDirective } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { setAttributes } from '~/lib/utils/dom_utils'; import { BIDI_CHARS, BIDI_CHARS_CLASS_LIST, BIDI_CHAR_TOOLTIP } from '../constants'; export default { - components: { - GlLink, - }, directives: { SafeHtml: GlSafeHtmlDirective, }, + mixins: [glFeatureFlagMixin()], props: { number: { type: Number, @@ -23,6 +22,10 @@ export default { type: String, required: true, }, + blamePath: { + type: String, + required: true, + }, }, computed: { formattedContent() { @@ -36,9 +39,6 @@ export default { return content; }, - firstLineClass() { - return { 'gl-mt-3!': this.number === 1 }; - }, }, methods: { wrapBidiChar(bidiChar) { @@ -59,21 +59,26 @@ export default { </script> <template> <div class="gl-display-flex"> - <div class="gl-p-0! gl-absolute gl-z-index-3 gl-border-r diff-line-num line-numbers"> - <gl-link + <div + class="gl-p-0! gl-absolute gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + > + <a + v-if="glFeatures.fileLineBlame" + class="gl-user-select-none gl-shadow-none! file-line-blame" + :href="`${blamePath}#L${number}`" + ></a> + <a :id="`L${number}`" - class="gl-user-select-none gl-ml-5 gl-pr-3 gl-shadow-none! file-line-num diff-line-num" - :class="firstLineClass" - :to="`#L${number}`" + class="gl-user-select-none gl-shadow-none! file-line-num" + :href="`#L${number}`" :data-line-number="number" > {{ number }} - </gl-link> + </a> </div> <pre - class="gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11! gl-border-none! code highlight gl-line-height-normal" - :class="firstLineClass" + class="gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-normal" ><code><span :id="`LC${number}`" v-safe-html="formattedContent" :lang="language" class="line" data-testid="content"></span></code></pre> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index 3ac35abcf3a..cc930d67fa4 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -147,3 +147,4 @@ export const HLJS_COMMENT_SELECTOR = 'hljs-comment'; export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; export const NPM_URL = 'https://npmjs.com/package'; +export const GEM_URL = 'https://rubygems.org/gems'; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js index 5b7650c56ae..d957990fe7f 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js @@ -1,7 +1,9 @@ import packageJsonLinker from './utils/package_json_linker'; +import gemspecLinker from './utils/gemspec_linker'; const DEPENDENCY_LINKERS = { package_json: packageJsonLinker, + gemspec: gemspecLinker, }; /** diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js index 56ad55ef553..dbe6812cf16 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js @@ -7,9 +7,10 @@ export const createLink = (href, innerText) => { const link = document.createElement('a'); setAttributes(link, { href: escape(href), rel }); - link.innerText = escape(innerText); + link.textContent = innerText; return link.outerHTML; }; -export const generateHLJSOpenTag = (type) => `<span class="hljs-${escape(type)}">"`; +export const generateHLJSOpenTag = (type, delimiter = '"') => + `<span class="hljs-${escape(type)}">${delimiter}`; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js new file mode 100644 index 00000000000..35de8fd13d6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js @@ -0,0 +1,39 @@ +import { joinPaths } from '~/lib/utils/url_utility'; +import { GEM_URL } from '../../constants'; +import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; + +const methodRegex = '.*add_dependency.*|.*add_runtime_dependency.*|.*add_development_dependency.*'; +const openTagRegex = generateHLJSOpenTag('string', '(&.*;)'); +const closeTagRegex = '&.*</span>'; + +const DEPENDENCY_REGEX = new RegExp( + /* + * Detects gemspec dependencies inside of content that is highlighted by Highlight.js + * Example: s.add_dependency(<span class="hljs-string">'rugged'</span>, <span class="hljs-string">'~> 0.24.0'</span>) + * + * Group 1 (method) : s.add_dependency( + * Group 2 (delimiter) : ' + * Group 3 (packageName): rugged + * Group 4 (closeTag) : '</span> + * Group 5 (rest) : , <span class="hljs-string">'~> 0.24.0'</span>) + */ + `(${methodRegex})${openTagRegex}(.*)(${closeTagRegex})(.*${closeTagRegex})`, + 'gm', +); + +const handleReplace = (method, delimiter, packageName, closeTag, rest) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + const openTag = generateHLJSOpenTag('string linked', delimiter); + const href = joinPaths(GEM_URL, packageName); + const packageLink = createLink(href, packageName); + + return `${method}${openTag}${packageLink}${closeTag}${rest}`; +}; + +export default (result) => { + return result.value.replace( + DEPENDENCY_REGEX, + (_, method, delimiter, packageName, closeTag, rest) => + handleReplace(method, delimiter, packageName, closeTag, rest), + ); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js index d013d077ba3..3c6fc23c138 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js @@ -1,3 +1,4 @@ +import { unescape } from 'lodash'; import { joinPaths } from '~/lib/utils/url_utility'; import { NPM_URL } from '../../constants'; import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; @@ -17,13 +18,15 @@ const DEPENDENCY_REGEX = new RegExp( ); const handleReplace = (original, packageName, version, dependenciesToLink) => { - const href = joinPaths(NPM_URL, packageName); - const packageLink = createLink(href, packageName); - const versionLink = createLink(href, version); + const unescapedPackageName = unescape(packageName); + const unescapedVersion = unescape(version); + const href = joinPaths(NPM_URL, unescapedPackageName); + const packageLink = createLink(href, unescapedPackageName); + const versionLink = createLink(href, unescapedVersion); const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`; - const dependencyToLink = dependenciesToLink[packageName]; + const dependencyToLink = dependenciesToLink[unescapedPackageName]; - if (dependencyToLink && dependencyToLink === version) { + if (dependencyToLink && dependencyToLink === unescapedVersion) { return `${attrOpenTag}${packageLink}${closeAndOpenTag}${versionLink}${closeTag}`; } diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index 1bdae40332f..ccc8b44942a 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -199,6 +199,7 @@ export default { :starting-from="firstChunk.startingFrom" :is-highlighted="firstChunk.isHighlighted" :language="firstChunk.language" + :blame-path="blob.blamePath" /> <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> @@ -213,6 +214,7 @@ export default { :is-highlighted="chunk.isHighlighted" :chunk-index="index" :language="chunk.language" + :blame-path="blob.blamePath" @appear="highlightChunk" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index d07f65cf5c1..c1e618620d8 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -50,7 +50,7 @@ export default { default: __('user avatar'), }, size: { - type: Number, + type: [Number, Object], required: false, default: 20, }, @@ -64,12 +64,19 @@ export default { required: false, default: 'top', }, + enforceGlAvatar: { + type: Boolean, + required: false, + }, }, }; </script> <template> - <user-avatar-image-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props"> + <user-avatar-image-new + v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar" + v-bind="$props" + > <slot></slot> </user-avatar-image-new> <user-avatar-image-old v-else v-bind="$props"> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue index 707b0bbec67..cd610314292 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue @@ -16,6 +16,7 @@ */ import { GlTooltip, GlAvatar } from '@gitlab/ui'; +import { isObject } from 'lodash'; import defaultAvatarUrl from 'images/no_avatar.png'; import { __ } from '~/locale'; import { placeholderImage } from '~/lazy_loader'; @@ -48,7 +49,7 @@ export default { default: __('user avatar'), }, size: { - type: Number, + type: [Number, Object], required: false, default: 20, }, @@ -71,9 +72,16 @@ export default { let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; // Only adds the width to the URL if its not a base64 data image if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) - baseSrc += `?width=${this.size}`; + baseSrc += `?width=${this.maximumSize}`; return baseSrc; }, + maximumSize() { + if (isObject(this.size)) { + return Math.max(...Object.values(this.size)); + } + + return this.size; + }, resultantSrcAttribute() { return this.lazy ? placeholderImage : this.sanitizedSource; }, diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index 887deff17c9..f80abed4d69 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -55,7 +55,7 @@ export default { default: '', }, imgSize: { - type: Number, + type: [Number, Object], required: false, default: 20, }, @@ -74,12 +74,19 @@ export default { required: false, default: '', }, + enforceGlAvatar: { + type: Boolean, + required: false, + }, }, }; </script> <template> - <user-avatar-link-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props"> + <user-avatar-link-new + v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar" + v-bind="$props" + > <slot></slot> <template #avatar-badge> <slot name="avatar-badge"></slot> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue index 3b459569274..83551c689c4 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue @@ -56,7 +56,7 @@ export default { default: '', }, imgSize: { - type: Number, + type: [Number, Object], required: false, default: 20, }, @@ -75,6 +75,10 @@ export default { required: false, default: '', }, + enforceGlAvatar: { + type: Boolean, + required: false, + }, }, computed: { shouldShowUsername() { @@ -97,6 +101,7 @@ export default { :tooltip-text="avatarTooltipText" :tooltip-placement="tooltipPlacement" :lazy="lazy" + :enforce-gl-avatar="enforceGlAvatar" > <slot></slot> </user-avatar-image> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue index 60b26d688b2..9da298ad705 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue @@ -21,7 +21,7 @@ export default { default: 10, }, imgSize: { - type: Number, + type: [Number, Object], required: false, default: 20, }, 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 a0d8ca117a4..2b9804796ae 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 @@ -14,6 +14,7 @@ import { glEmojiTag } from '~/emoji'; import createFlash from '~/flash'; import { followUser, unfollowUser } from '~/rest_api'; import { isUserBusy } from '~/set_status_modal/utils'; +import Tracking from '~/tracking'; import { USER_POPOVER_DELAY } from './constants'; const MAX_SKELETON_LINES = 4; @@ -37,6 +38,7 @@ export default { directives: { SafeHtml: GlSafeHtmlDirective, }, + mixins: [Tracking.mixin()], props: { target: { type: HTMLElement, @@ -117,6 +119,11 @@ export default { }, async follow() { this.toggleFollowLoading = true; + + this.track('click_button', { + label: 'follow_from_user_popover', + }); + try { await followUser(this.user.id); this.$emit('follow'); @@ -132,6 +139,11 @@ export default { }, async unfollow() { this.toggleFollowLoading = true; + + this.track('click_button', { + label: 'unfollow_from_user_popover', + }); + try { await unfollowUser(this.user.id); this.$emit('unfollow'); diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index 91f20863089..43a590c2367 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -77,6 +77,11 @@ export default { required: false, default: null, }, + issuableAuthor: { + type: Object, + required: false, + default: null, + }, }, data() { return { @@ -178,7 +183,7 @@ export default { [], ); - return this.moveCurrentUserToStart(mergedSearchResults); + return this.moveCurrentUserAndAuthorToStart(mergedSearchResults); }, isSearchEmpty() { return this.search === ''; @@ -196,14 +201,21 @@ export default { showCurrentUser() { return this.currentUser.username && !this.isCurrentUserInList && this.isSearchEmpty; }, + showAuthor() { + return ( + this.issuableAuthor && + !this.users.some((user) => user.id === this.issuableAuthor.id) && + this.isSearchEmpty + ); + }, selectedFiltered() { if (this.shouldShowParticipants) { - return this.moveCurrentUserToStart(this.value); + return this.moveCurrentUserAndAuthorToStart(this.value); } const foundUsernames = this.users.map(({ username }) => username); const filtered = this.value.filter(({ username }) => foundUsernames.includes(username)); - return this.moveCurrentUserToStart(filtered); + return this.moveCurrentUserAndAuthorToStart(filtered); }, selectedUserNames() { return this.value.map(({ username }) => username); @@ -254,20 +266,22 @@ export default { showDivider(list) { return list.length > 0 && this.isSearchEmpty; }, - moveCurrentUserToStart(users) { - if (!users) { - return []; + moveCurrentUserAndAuthorToStart(users = []) { + let sortedUsers = [...users]; + + const author = sortedUsers.find((user) => user.id === this.issuableAuthor?.id); + if (author) { + sortedUsers = [author, ...sortedUsers.filter((user) => user.id !== author.id)]; } - const usersCopy = [...users]; - const currentUser = usersCopy.find((user) => user.username === this.currentUser.username); + + const currentUser = sortedUsers.find((user) => user.username === this.currentUser.username); if (currentUser) { currentUser.canMerge = this.currentUser.canMerge; - const index = usersCopy.indexOf(currentUser); - usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]); + sortedUsers = [currentUser, ...sortedUsers.filter((user) => user.id !== currentUser.id)]; } - return usersCopy; + return sortedUsers; }, setSearchKey(value) { this.search = value.trim(); @@ -298,7 +312,7 @@ export default { <gl-loading-icon v-if="isLoading" data-testid="loading-participants" - size="lg" + size="md" class="gl-absolute gl-left-0 gl-top-0 gl-right-0" /> <template v-else> @@ -312,8 +326,8 @@ export default { > <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{ $options.i18n.unassigned - }}</span></gl-dropdown-item - > + }}</span> + </gl-dropdown-item> </template> <gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> <gl-dropdown-item @@ -342,7 +356,17 @@ export default { /> </gl-dropdown-item> </template> - <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> + <gl-dropdown-item + v-if="showAuthor" + data-testid="issuable-author" + @click.native.capture.stop="selectAssignee(issuableAuthor)" + > + <sidebar-participant + :user="issuableAuthor" + :issuable-type="issuableType" + class="gl-pl-6!" + /> + </gl-dropdown-item> <gl-dropdown-item v-for="unselectedUser in unselectedFiltered" :key="unselectedUser.id" 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 cac0d5a45c9..6d179b3dc92 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -10,6 +10,21 @@ const KEY_WEB_IDE = 'webide'; const KEY_GITPOD = 'gitpod'; const KEY_PIPELINE_EDITOR = 'pipeline_editor'; +export const i18n = { + modal: { + title: __('Enable Gitpod?'), + content: s__( + 'Gitpod|To use Gitpod you must first enable the feature in the integrations section of your %{linkStart}user preferences%{linkEnd}.', + ), + actionCancelText: __('Cancel'), + actionPrimaryText: __('Enable Gitpod'), + }, + webIdeText: s__('WebIDE|Quickly and easily edit multiple files in your project.'), + webIdeTooltip: s__( + 'WebIDE|Quickly and easily edit multiple files in your project. Press . to open', + ), +}; + export default { components: { ActionsButton, @@ -19,16 +34,7 @@ export default { GlLink, ConfirmForkModal, }, - i18n: { - modal: { - title: __('Enable Gitpod?'), - content: s__( - 'Gitpod|To use Gitpod you must first enable the feature in the integrations section of your %{linkStart}user preferences%{linkEnd}.', - ), - actionCancelText: __('Cancel'), - actionPrimaryText: __('Enable Gitpod'), - }, - }, + i18n, props: { isFork: { type: Boolean, @@ -207,8 +213,8 @@ export default { return { key: KEY_WEB_IDE, text: this.webIdeActionText, - secondaryText: __('Quickly and easily edit multiple files in your project.'), - tooltip: '', + secondaryText: this.$options.i18n.webIdeText, + tooltip: this.$options.i18n.webIdeTooltip, attrs: { 'data-qa-selector': 'web_ide_button', 'data-track-action': 'click_consolidated_edit_ide', diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 14328b1f25f..b6d69faebb5 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -1,4 +1,4 @@ -import { __, sprintf } from '~/locale'; +import { __, n__, sprintf } from '~/locale'; import { IssuableType, WorkspaceType } from '~/issues/constants'; const INTERVALS = { @@ -15,51 +15,62 @@ export const ISO_SHORT_FORMAT = 'yyyy-mm-dd'; export const DATE_FORMATS = [SHORT_DATE_FORMAT, ISO_SHORT_FORMAT]; +const getTimeLabel = (days) => n__('1 day', '%d days', days); + +/* eslint-disable @gitlab/require-i18n-strings */ export const timeRanges = [ { - label: __('30 minutes'), + label: n__('1 minute', '%d minutes', 30), + shortcut: '30_minutes', duration: { seconds: 60 * 30 }, name: 'thirtyMinutes', interval: INTERVALS.minute, }, { - label: __('3 hours'), + label: n__('1 hour', '%d hours', 3), + shortcut: '3_hours', duration: { seconds: 60 * 60 * 3 }, name: 'threeHours', interval: INTERVALS.hour, }, { - label: __('8 hours'), + label: n__('1 hour', '%d hours', 8), + shortcut: '8_hours', duration: { seconds: 60 * 60 * 8 }, name: 'eightHours', default: true, interval: INTERVALS.hour, }, { - label: __('1 day'), + label: getTimeLabel(1), + shortcut: '1_day', duration: { seconds: 60 * 60 * 24 * 1 }, name: 'oneDay', interval: INTERVALS.hour, }, { - label: __('3 days'), + label: getTimeLabel(3), + shortcut: '3_days', duration: { seconds: 60 * 60 * 24 * 3 }, name: 'threeDays', interval: INTERVALS.hour, }, { - label: __('7 days'), + label: getTimeLabel(7), + shortcut: '7_days', duration: { seconds: 60 * 60 * 24 * 7 * 1 }, name: 'oneWeek', interval: INTERVALS.day, }, { - label: __('30 days'), + label: getTimeLabel(30), + shortcut: '30_days', duration: { seconds: 60 * 60 * 24 * 30 }, name: 'oneMonth', interval: INTERVALS.day, }, ]; +/* eslint-enable @gitlab/require-i18n-strings */ export const defaultTimeRange = timeRanges.find((tr) => tr.default); export const getTimeWindow = (timeWindowName) => diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index b616b390032..38083327593 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -7,6 +7,7 @@ import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/dat import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { @@ -17,6 +18,7 @@ export default { GlFormCheckbox, GlSprintf, IssuableAssignees, + WorkItemTypeIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -50,6 +52,11 @@ export default { required: false, default: false, }, + showWorkItemTypeIcon: { + type: Boolean, + required: false, + default: false, + }, }, computed: { issuableId() { @@ -118,8 +125,8 @@ export default { return sprintf( n__( - '%{completedCount} of %{count} task completed', - '%{completedCount} of %{count} tasks completed', + '%{completedCount} of %{count} checklist item completed', + '%{completedCount} of %{count} checklist items completed', count, ), { completedCount, count }, @@ -225,6 +232,7 @@ export default { </span> </div> <div class="issuable-info"> + <work-item-type-icon v-if="showWorkItemTypeIcon" :work-item-type="issuable.type" /> <slot v-if="hasSlotContents('reference')" name="reference"></slot> <span v-else data-testid="issuable-reference" class="issuable-reference"> {{ reference }} diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index 189bbb56432..bc10f84b819 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -182,6 +182,11 @@ export default { required: false, default: false, }, + showWorkItemTypeIcon: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -344,6 +349,7 @@ export default { :label-filter-param="labelFilterParam" :show-checkbox="showBulkEditSidebar" :checked="issuableChecked(issuable)" + :show-work-item-type-icon="showWorkItemTypeIcon" @checked-input="handleIssuableCheckedInput(issuable, $event)" > <template #reference> diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js index 507f333a34e..f6b864dfde0 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/constants.js +++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js @@ -46,13 +46,6 @@ export const AvailableSortOptions = [ }, ]; -export const IssuableTypes = { - Issue: 'ISSUE', - Incident: 'INCIDENT', - TestCase: 'TEST_CASE', - Requirement: 'REQUIREMENT', -}; - export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_SKELETON_COUNT = 5; diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index cdc5903b934..1f23fdfaafd 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -12,6 +12,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isExternal } from '~/lib/utils/url_utility'; import { n__, sprintf } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { IssuableStates } from '~/vue_shared/issuable/list/constants'; export default { @@ -22,6 +23,7 @@ export default { GlAvatarLink, GlAvatarLabeled, TimeAgoTooltip, + WorkItemTypeIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -65,6 +67,16 @@ export default { required: false, default: null, }, + issuableType: { + type: String, + required: false, + default: '', + }, + showWorkItemTypeIcon: { + type: Boolean, + required: false, + default: false, + }, }, computed: { badgeVariant() { @@ -81,8 +93,8 @@ export default { return sprintf( n__( - '%{completedCount} of %{count} task completed', - '%{completedCount} of %{count} tasks completed', + '%{completedCount} of %{count} checklist item completed', + '%{completedCount} of %{count} checklist items completed', count, ), { completedCount, count }, @@ -122,7 +134,13 @@ export default { </div> </div> <span> - {{ __('Created') }} + <template v-if="showWorkItemTypeIcon"> + <work-item-type-icon :work-item-type="issuableType" show-text /> + {{ __('created') }} + </template> + <template v-else> + {{ __('Created') }} + </template> <time-ago-tooltip data-testid="startTimeItem" :time="createdAt" /> {{ __('by') }} </span> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue index 7ed93c042f8..2bc57ecba55 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue @@ -87,6 +87,11 @@ export default { required: false, default: 0, }, + showWorkItemTypeIcon: { + type: Boolean, + required: false, + default: false, + }, }, methods: { handleKeydownTitle(e, issuableMeta) { @@ -110,6 +115,8 @@ export default { :created-at="issuable.createdAt" :author="issuable.author" :task-completion-status="taskCompletionStatus" + :issuable-type="issuable.type" + :show-work-item-type-icon="showWorkItemTypeIcon" > <template #status-badge> <slot name="status-badge"></slot> diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 8e9b8ef3e6f..232749a2d01 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -125,7 +125,7 @@ export default { <h4>{{ activePanel.title }}</h4> <p v-if="hasTextDetails">{{ details }}</p> - <component :is="details" v-else /> + <component :is="details" v-else v-bind="activePanel.detailProps || {}" /> <slot name="extra-description"></slot> </div> diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index 0c55cc2f8a6..9e5361e8302 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -79,7 +79,7 @@ export default { @bottomReached="bottomReached" > <template #items> - <feature v-for="feature in features" :key="feature.title" :feature="feature" /> + <feature v-for="feature in features" :key="feature.name" :feature="feature" /> </template> </gl-infinite-scroll> </template> diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue index 90f6230ef72..c954a86e593 100644 --- a/app/assets/javascripts/whats_new/components/feature.vue +++ b/app/assets/javascripts/whats_new/components/feature.vue @@ -30,7 +30,6 @@ export default { return dateInWords(date); }, }, - safeHtmlConfig: { ADD_ATTR: ['target'] }, }; </script> @@ -38,35 +37,35 @@ export default { <div class="gl-py-6 gl-px-6 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> <gl-link v-if="feature.image_url" - :href="feature.url" + :href="feature.documentation_link" target="_blank" class="gl-display-block" data-testid="whats-new-image-link" data-track-action="click_whats_new_item" - :data-track-label="feature.title" - :data-track-property="feature.url" + :data-track-label="feature.name" + :data-track-property="feature.documentation_link" > <div class="whats-new-item-image gl-bg-size-cover" :style="`background-image: url(${feature.image_url});`" > - <span class="gl-sr-only">{{ feature.title }}</span> + <span class="gl-sr-only">{{ feature.name }}</span> </div> </gl-link> <gl-link - :href="feature.url" + :href="feature.documentation_link" target="_blank" class="whats-new-item-title-link gl-display-block gl-mt-4 gl-mb-1" data-track-action="click_whats_new_item" - :data-track-label="feature.title" - :data-track-property="feature.url" + :data-track-label="feature.name" + :data-track-property="feature.documentation_link" > - <h5 class="gl-font-lg gl-my-0" data-test-id="feature-title">{{ feature.title }}</h5> + <h5 class="gl-font-lg gl-my-0" data-test-id="feature-name">{{ feature.name }}</h5> </gl-link> <div v-if="releaseDate" class="gl-mb-3" data-testid="release-date">{{ releaseDate }}</div> - <div v-if="feature.packages" class="gl-mb-3"> + <div v-if="feature.available_in" class="gl-mb-3"> <gl-badge - v-for="packageName in feature.packages" + v-for="packageName in feature.available_in" :key="packageName" size="md" variant="tier" @@ -77,15 +76,15 @@ export default { </gl-badge> </div> <div - v-safe-html:[$options.safeHtmlConfig]="feature.body" + v-safe-html:[$options.safeHtmlConfig]="feature.description" class="gl-pt-3 gl-line-height-20" ></div> <gl-button - :href="feature.url" + :href="feature.documentation_link" target="_blank" data-track-action="click_whats_new_item" - :data-track-label="feature.title" - :data-track-property="feature.url" + :data-track-label="feature.name" + :data-track-property="feature.documentation_link" > {{ __('Learn more') }} <gl-icon name="arrow-right" /> </gl-button> diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue index 2dc8e3a1101..2a0913e380a 100644 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ b/app/assets/javascripts/work_items/components/item_state.vue @@ -26,7 +26,7 @@ export default { type: String, required: true, }, - loading: { + disabled: { type: Boolean, required: false, default: false, @@ -61,15 +61,17 @@ export default { :id="$options.labelId" :value="state" :options="$options.states" - :disabled="loading" - class="gl-w-auto hide-select-decoration" + :disabled="disabled" + class="gl-w-auto hide-select-decoration gl-pl-3" + :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }" @change="setState" /> </gl-form-group> </template> <style> -.hide-select-decoration:not(:focus, :hover) { +.hide-select-decoration:not(:focus, :hover), +.hide-select-decoration:disabled { background-image: none; box-shadow: none; } diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 1cdc9c28f05..551ebbadb21 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -36,7 +36,7 @@ export default { <template> <h2 class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-5 gl-mt-0 gl-w-full" - :class="{ 'gl-cursor-not-allowed': disabled }" + :class="{ 'gl-cursor-text': disabled }" aria-labelledby="item-title" > <div @@ -46,7 +46,8 @@ export default { :aria-label="__('Title')" :data-placeholder="placeholder" :contenteditable="!disabled" - class="gl-pseudo-placeholder gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base" + class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base" + :class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }" @blur="handleBlur" @keyup="handleInput" @keydown.enter.exact="handleSubmit" diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 77002eeaf55..2753c3fa388 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -1,15 +1,24 @@ <script> -import { GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; export default { i18n: { deleteTask: s__('WorkItem|Delete task'), + enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'), + disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'), }, components: { GlDropdown, GlDropdownItem, + GlDropdownDivider, GlModal, }, directives: { @@ -22,14 +31,33 @@ export default { required: false, default: null, }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, canDelete: { type: Boolean, required: false, default: false, }, + isConfidential: { + type: Boolean, + required: false, + default: false, + }, + isParentConfidential: { + type: Boolean, + required: false, + default: false, + }, }, - emits: ['deleteWorkItem'], + emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'], methods: { + handleToggleWorkItemConfidentiality() { + this.track('click_toggle_work_item_confidentiality'); + this.$emit('toggleWorkItemConfidentiality', !this.isConfidential); + }, handleDeleteWorkItem() { this.track('click_delete_work_item'); this.$emit('deleteWorkItem'); @@ -44,7 +72,7 @@ export default { </script> <template> - <div v-if="canDelete"> + <div> <gl-dropdown icon="ellipsis_v" text-sr-only @@ -53,9 +81,24 @@ export default { no-caret right > - <gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{ - $options.i18n.deleteTask - }}</gl-dropdown-item> + <template v-if="canUpdate && !isParentConfidential"> + <gl-dropdown-item + data-testid="confidentiality-toggle-action" + @click="handleToggleWorkItemConfidentiality" + >{{ + isConfidential + ? $options.i18n.disableTaskConfidentiality + : $options.i18n.enableTaskConfidentiality + }}</gl-dropdown-item + > + <gl-dropdown-divider v-if="canDelete" /> + </template> + <gl-dropdown-item + v-if="canDelete" + v-gl-modal="'work-item-confirm-delete'" + data-testid="delete-action" + >{{ $options.i18n.deleteTask }}</gl-dropdown-item + > </gl-dropdown> <gl-modal modal-id="work-item-confirm-delete" diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 9ff424aa20f..7342f215b5e 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -18,11 +18,17 @@ import { n__, s__ } from '~/locale'; import Tracking from '~/tracking'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; function isTokenSelectorElement(el) { - return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item'); + return ( + el?.classList.contains('gl-token-close') || + el?.classList.contains('dropdown-item') || + // TODO: replace this logic when we have a class added to clear-all button in GitLab UI + (el?.classList.contains('gl-button') && + el?.closest('.form-control')?.classList.contains('gl-token-selector')) + ); } function addClass(el) { @@ -69,6 +75,11 @@ export default { required: false, default: false, }, + canInviteMembers: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -130,7 +141,7 @@ export default { if (this.searchUsers.some((user) => user.username === this.currentUser.username)) { return this.moveCurrentUserToStart(this.searchUsers); } - return [this.currentUser, ...this.searchUsers]; + return [addClass(this.currentUser), ...this.searchUsers]; } return this.searchUsers; }, @@ -138,16 +149,25 @@ export default { return this.searchKey.length === 0; }, addAssigneesText() { + if (!this.canUpdate) { + return s__('WorkItem|None'); + } return this.allowsMultipleAssignees ? s__('WorkItem|Add assignees') : s__('WorkItem|Add assignee'); }, + assigneeIds() { + return this.localAssignees.map(({ id }) => id); + }, }, watch: { - assignees(newVal) { - if (!this.isEditing) { - this.localAssignees = newVal.map(addClass); - } + assignees: { + handler(newVal) { + if (!this.isEditing) { + this.localAssignees = newVal.map(addClass); + } + }, + deep: true, }, }, created() { @@ -169,19 +189,33 @@ export default { handleBlur(e) { if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return; this.isEditing = false; - this.setAssignees(this.localAssignees); + this.setAssignees(this.assigneeIds); }, - setAssignees(assignees) { - this.$apollo.mutate({ - mutation: localUpdateWorkItemMutation, - variables: { - input: { - id: this.workItemId, - assignees, + async setAssignees(assigneeIds) { + try { + const { + data: { + workItemUpdate: { errors }, }, - }, - }); - this.track('updated_assignees'); + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + assigneesWidget: { + assigneeIds, + }, + }, + }, + }); + if (errors.length > 0) { + this.throwUpdateError(); + return; + } + this.track('updated_assignees'); + } catch { + this.throwUpdateError(); + } }, handleFocus() { this.isEditing = true; @@ -205,13 +239,25 @@ export default { }, moveCurrentUserToStart(users = []) { if (this.currentUser) { - return [this.currentUser, ...users.filter((user) => user.id !== this.currentUser.id)]; + return [ + addClass(this.currentUser), + ...users.filter((user) => user.id !== this.currentUser.id), + ]; } return users; }, closeDropdown() { this.$refs.tokenSelector.closeDropdown(); }, + assignToCurrentUser() { + this.setAssignees([this.currentUser.id]); + this.localAssignees = [addClass(this.currentUser)]; + }, + throwUpdateError() { + this.$emit('error', i18n.updateError); + // If mutation is rejected, we're rolling back to initial state + this.localAssignees = this.assignees.map(addClass); + }, }, }; </script> @@ -227,11 +273,12 @@ export default { ref="tokenSelector" :selected-tokens="localAssignees" :container-class="containerClass" - class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0!" :class="{ 'gl-hover-border-gray-200': canUpdate }" :dropdown-items="dropdownItems" :loading="isLoadingUsers" :view-only="!canUpdate" + :allow-clear-all="isEditing" + class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2" @input="handleAssigneesInput" @text-input="debouncedSearchKeyUpdate" @focus="handleFocus" @@ -241,7 +288,7 @@ export default { > <template #empty-placeholder> <div - class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-top-2" + class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-pl-2 gl-top-2" data-testid="empty-state" > <gl-icon name="profile" /> @@ -251,7 +298,7 @@ export default { size="small" class="assign-myself" data-testid="assign-self" - @click.stop="setAssignees([currentUser])" + @click.stop="assignToCurrentUser" >{{ __('Assign myself') }}</gl-button > </div> @@ -262,7 +309,7 @@ export default { :title="token.name" :data-user-id="getUserId(token.id)" data-placement="top" - class="gl-text-decoration-none! gl-text-body! gl-display-flex gl-md-display-inline-flex! gl-align-items-center js-user-link" + class="gl-ml-n2 gl-text-decoration-none! gl-text-body! gl-display-flex gl-md-display-inline-flex! gl-align-items-center js-user-link" > <gl-avatar :size="24" :src="token.avatarUrl" /> <span class="gl-pl-2">{{ token.name }}</span> @@ -279,7 +326,7 @@ export default { <rect width="280" height="20" x="10" y="130" rx="4" /> </gl-skeleton-loader> </template> - <template #dropdown-footer> + <template v-if="canInviteMembers" #dropdown-footer> <gl-dropdown-divider /> <gl-dropdown-item @click="closeDropdown"> <invite-members-trigger diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index 90e3cd45cb4..cf59789ce2d 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -172,7 +172,7 @@ export default { <template> <gl-form-group v-if="isEditing" - class="gl-my-5" + class="gl-my-5 gl-border-t gl-pt-6" :label="__('Description')" label-for="work-item-description" > @@ -182,7 +182,7 @@ export default { :is-submitting="isSubmitting" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" - class="gl-p-3 bordered-box" + class="gl-p-3 bordered-box gl-mt-5" > <template #textarea> <textarea @@ -217,9 +217,9 @@ export default { }}</gl-button> </div> </gl-form-group> - <div v-else class="gl-mb-5"> - <div class="gl-display-flex gl-align-items-center gl-mb-5"> - <h3 class="gl-font-base gl-my-0">{{ __('Description') }}</h3> + <div v-else class="gl-mb-5 gl-border-t"> + <div class="gl-display-inline-flex gl-align-items-center gl-mb-5"> + <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label> <gl-button v-if="canEdit" class="gl-ml-auto" diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index ad90fe88947..a5580c14a7a 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,7 +1,16 @@ <script> -import { GlAlert, GlSkeletonLoader, GlIcon, GlButton } from '@gitlab/ui'; +import { + GlAlert, + GlSkeletonLoader, + GlLoadingIcon, + GlIcon, + GlBadge, + GlButton, + GlTooltipDirective, +} from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { i18n, WIDGET_TYPE_ASSIGNEES, @@ -11,8 +20,12 @@ import { WIDGET_TYPE_HIERARCHY, WORK_ITEM_VIEWED_STORAGE_KEY, } from '../constants'; + import workItemQuery from '../graphql/work_item.query.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; + import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; @@ -24,9 +37,14 @@ import WorkItemInformation from './work_item_information.vue'; export default { i18n, + directives: { + GlTooltip: GlTooltipDirective, + }, components: { GlAlert, + GlBadge, GlButton, + GlLoadingIcon, GlSkeletonLoader, GlIcon, WorkItemAssignees, @@ -38,6 +56,7 @@ export default { WorkItemWeight, WorkItemInformation, LocalStorageSync, + WorkItemTypeIcon, }, mixins: [glFeatureFlagMixin()], props: { @@ -62,6 +81,7 @@ export default { error: undefined, workItem: {}, showInfoBanner: true, + updateInProgress: false, }; }, apollo: { @@ -114,7 +134,7 @@ export default { return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); }, workItemWeight() { - return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); }, workItemHierarchy() { return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); @@ -122,9 +142,15 @@ export default { parentWorkItem() { return this.workItemHierarchy?.parent; }, + parentWorkItemConfidentiality() { + return this.parentWorkItem?.confidential; + }, parentUrl() { return `../../issues/${this.parentWorkItem?.iid}`; }, + workItemIconName() { + return this.workItem?.workItemType?.iconName; + }, }, beforeDestroy() { /** make sure that if the user has not even dismissed the alert , @@ -135,6 +161,54 @@ export default { dismissBanner() { this.showInfoBanner = false; }, + toggleConfidentiality(confidentialStatus) { + this.updateInProgress = true; + let updateMutation = updateWorkItemMutation; + let inputVariables = { + id: this.workItemId, + confidential: confidentialStatus, + }; + + if (this.parentWorkItem) { + updateMutation = updateWorkItemTaskMutation; + inputVariables = { + id: this.parentWorkItem.id, + taskData: { + id: this.workItemId, + confidential: confidentialStatus, + }, + }; + } + + this.$apollo + .mutate({ + mutation: updateMutation, + variables: { + input: inputVariables, + }, + }) + .then( + ({ + data: { + workItemUpdate: { errors, workItem, task }, + }, + }) => { + if (errors?.length) { + throw new Error(errors[0]); + } + + this.$emit('workItemUpdated', { + confidential: workItem?.confidential || task?.confidential, + }); + }, + ) + .catch((error) => { + this.error = error.message; + }) + .finally(() => { + this.updateInProgress = false; + }); + }, }, WORK_ITEM_VIEWED_STORAGE_KEY, }; @@ -142,7 +216,7 @@ export default { <template> <section class="gl-pt-5"> - <gl-alert v-if="error" variant="danger" @dismiss="error = undefined"> + <gl-alert v-if="error" class="gl-mb-3" variant="danger" @dismiss="error = undefined"> {{ error }} </gl-alert> @@ -153,33 +227,61 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <div class="gl-display-flex gl-align-items-center"> + <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body"> <ul v-if="parentWorkItem" - class="list-unstyled gl-display-flex gl-mr-auto" + class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0" data-testid="work-item-parent" > - <li class="gl-ml-n4"> - <gl-button icon="issues" category="tertiary" :href="parentUrl">{{ - parentWorkItem.title - }}</gl-button> - <gl-icon name="chevron-right" :size="16" /> + <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden"> + <gl-button + v-gl-tooltip.hover + class="gl-text-truncate gl-max-w-full" + icon="issues" + category="tertiary" + :href="parentUrl" + :title="parentWorkItem.title" + >{{ parentWorkItem.title }}</gl-button + > + <gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" /> </li> - <li class="gl-px-4 gl-py-3 gl-line-height-0"> - <gl-icon name="task-done" /> + <li + class="gl-px-4 gl-py-3 gl-line-height-0 gl-display-flex gl-align-items-center gl-overflow-hidden gl-flex-shrink-0" + > + <work-item-type-icon + :work-item-icon-name="workItemIconName" + :work-item-type="workItemType && workItemType.toUpperCase()" + /> {{ workItemType }} </li> </ul> - <span + <work-item-type-icon v-else + :work-item-icon-name="workItemIconName" + :work-item-type="workItemType && workItemType.toUpperCase()" + show-text class="gl-font-weight-bold gl-text-secondary gl-mr-auto" data-testid="work-item-type" - >{{ workItemType }}</span + /> + <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" /> + <gl-badge + v-if="workItem.confidential" + v-gl-tooltip.bottom + :title="$options.i18n.confidentialTooltip" + variant="warning" + icon="eye-slash" + class="gl-mr-3 gl-cursor-help" + >{{ __('Confidential') }}</gl-badge > <work-item-actions + v-if="canUpdate || canDelete" :work-item-id="workItem.id" :can-delete="canDelete" + :can-update="canUpdate" + :is-confidential="workItem.confidential" + :is-parent-confidential="parentWorkItemConfidentiality" @deleteWorkItem="$emit('deleteWorkItem')" + @toggleWorkItemConfidentiality="toggleConfidentiality" @error="error = $event" /> <gl-button @@ -206,11 +308,13 @@ export default { :work-item-title="workItem.title" :work-item-type="workItemType" :work-item-parent-id="workItemParentId" + :can-update="canUpdate" @error="error = $event" /> <work-item-state :work-item="workItem" :work-item-parent-id="workItemParentId" + :can-update="canUpdate" @error="error = $event" /> <template v-if="workItemsMvc2Enabled"> @@ -221,6 +325,7 @@ export default { :assignees="workItemAssignees.assignees.nodes" :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" :work-item-type="workItemType" + :can-invite-members="workItemAssignees.canInviteMembers" @error="error = $event" /> <work-item-labels @@ -229,15 +334,16 @@ export default { :can-update="canUpdate" @error="error = $event" /> - <work-item-weight - v-if="workItemWeight" - class="gl-mb-5" - :can-update="canUpdate" - :weight="workItemWeight.weight" - :work-item-id="workItem.id" - :work-item-type="workItemType" - /> </template> + <work-item-weight + v-if="workItemWeight" + class="gl-mb-5" + :can-update="canUpdate" + :weight="workItemWeight.weight" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="error = $event" + /> <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index df7c6cab7ef..39a662a6c54 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -2,9 +2,13 @@ import { GlAlert, GlModal } from '@gitlab/ui'; import { s__ } from '~/locale'; import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql'; +import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql'; import WorkItemDetail from './work_item_detail.vue'; export default { + i18n: { + errorMessage: s__('WorkItem|Something went wrong when deleting the task. Please try again.'), + }, components: { GlAlert, GlModal, @@ -45,6 +49,13 @@ export default { }, methods: { deleteWorkItem() { + if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) { + this.deleteWorkItemWithTaskData(); + } else { + this.deleteWorkItemWithoutTaskData(); + } + }, + deleteWorkItemWithTaskData() { this.$apollo .mutate({ mutation: deleteWorkItemFromTaskMutation, @@ -70,17 +81,33 @@ export default { }, }) => { if (errors?.length) { - throw new Error(errors[0].message); + throw new Error(errors[0]); } this.$emit('workItemDeleted', descriptionHtml); - this.$refs.modal.hide(); + this.hide(); }, ) - .catch((e) => { - this.error = - e.message || - s__('WorkItem|Something went wrong when deleting the task. Please try again.'); + .catch((error) => { + this.setErrorMessage(error.message); + }); + }, + deleteWorkItemWithoutTaskData() { + this.$apollo + .mutate({ + mutation: deleteWorkItemMutation, + variables: { input: { id: this.workItemId } }, + }) + .then(({ data }) => { + if (data.workItemDelete.errors?.length) { + throw new Error(data.workItemDelete.errors[0]); + } + + this.$emit('workItemDeleted', this.workItemId); + this.hide(); + }) + .catch((error) => { + this.setErrorMessage(error.message); }); }, closeModal() { @@ -91,7 +118,7 @@ export default { this.$refs.modal.hide(); }, setErrorMessage(message) { - this.error = message; + this.error = message || this.$options.i18n.errorMessage; }, show() { this.$refs.modal.show(); diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 78ed67998d7..e73488bbd70 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -202,7 +202,8 @@ export default { :dropdown-items="searchLabels" :loading="isLoading" :view-only="!canUpdate" - class="gl-flex-grow-1 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" + class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" + :class="{ 'gl-hover-border-gray-200': canUpdate }" @input="focusTokenSelector" @text-input="debouncedSearchKeyUpdate" @focus="handleFocus" diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 176f84f6c1a..86f03583ea3 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -1,16 +1,10 @@ import Vue from 'vue'; -import VueApollo from 'vue-apollo'; import { GlToast } from '@gitlab/ui'; -import createDefaultClient from '~/lib/graphql'; +import { createApolloProvider } from '../../graphql/provider'; import WorkItemLinks from './work_item_links.vue'; -Vue.use(VueApollo); Vue.use(GlToast); -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - export default function initWorkItemLinks() { if (!window.gon.features.workItemsHierarchy) { return; @@ -22,16 +16,20 @@ export default function initWorkItemLinks() { return; } + const { projectPath, wiHasIssueWeightsFeature } = workItemLinksRoot.dataset; + // eslint-disable-next-line no-new new Vue({ el: workItemLinksRoot, name: 'WorkItemLinksRoot', - apolloProvider, + apolloProvider: createApolloProvider(), components: { workItemLinks: WorkItemLinks, }, provide: { - projectPath: workItemLinksRoot.dataset.projectPath, + projectPath, + fullPath: projectPath, + hasIssueWeightsFeature: wiHasIssueWeightsFeature, }, render: (createElement) => createElement('work-item-links', { diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 89f086cfca5..534ebabee08 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -1,8 +1,14 @@ <script> -import { GlButton, GlBadge, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlAlert, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { produce } from 'immer'; import { s__ } from '~/locale'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { isMetaKey } from '~/lib/utils/common_utils'; +import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; +import SidebarEventHub from '~/sidebar/event_hub'; + import { STATE_OPEN, WIDGET_ICONS, @@ -10,18 +16,26 @@ import { WIDGET_TYPE_HIERARCHY, } from '../../constants'; import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; +import workItemQuery from '../../graphql/work_item.query.graphql'; +import WorkItemDetailModal from '../work_item_detail_modal.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; import WorkItemLinksMenu from './work_item_links_menu.vue'; export default { components: { GlButton, - GlBadge, GlIcon, + GlAlert, GlLoadingIcon, WorkItemLinksForm, WorkItemLinksMenu, + WorkItemDetailModal, + }, + directives: { + GlTooltip: GlTooltipDirective, }, + inject: ['projectPath'], props: { workItemId: { type: String, @@ -35,32 +49,44 @@ export default { }, }, apollo: { - children: { + workItem: { query: getWorkItemLinksQuery, variables() { return { id: this.issuableGid, }; }, - update(data) { - return ( - data.workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children - .nodes ?? [] - ); - }, skip() { return !this.issuableId; }, + error(e) { + this.error = e.message || this.$options.i18n.fetchError; + }, }, }, data() { return { isShownAddForm: false, isOpen: true, - children: [], + activeChildId: null, + activeToast: null, + prefetchedWorkItem: null, + error: undefined, }; }, computed: { + children() { + return ( + this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children + .nodes ?? [] + ); + }, + canUpdate() { + return this.workItem?.userPermissions.updateWorkItem || false; + }, + confidential() { + return this.workItem?.confidential || false; + }, // Only used for children for now but should be extended later to support parents and siblings isChildrenEmpty() { return this.children?.length === 0; @@ -77,28 +103,149 @@ export default { return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null; }, isLoading() { - return this.$apollo.queries.children.loading; + return this.$apollo.queries.workItem.loading; }, childrenIds() { return this.children.map((c) => c.id); }, + childrenCountLabel() { + return this.isLoading && this.children.length === 0 ? '...' : this.children.length; + }, + }, + mounted() { + SidebarEventHub.$on('confidentialityUpdated', this.refetchWorkItems); + }, + destroyed() { + SidebarEventHub.$off('confidentialityUpdated', this.refetchWorkItems); }, methods: { - badgeVariant(state) { - return state === STATE_OPEN ? 'success' : 'info'; + refetchWorkItems() { + this.$apollo.queries.workItem.refetch(); + }, + iconClass(state) { + return state === STATE_OPEN ? 'gl-text-green-500' : 'gl-text-blue-500'; + }, + iconName(state) { + return state === STATE_OPEN ? 'issue-open-m' : 'issue-close'; }, toggle() { this.isOpen = !this.isOpen; }, - toggleAddForm() { - this.isShownAddForm = !this.isShownAddForm; + showAddForm() { + this.isOpen = true; + this.isShownAddForm = true; + this.$nextTick(() => { + this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus(); + }); + }, + hideAddForm() { + this.isShownAddForm = false; }, addChild(child) { - this.children = [child, ...this.children]; + const { defaultClient: client } = this.$apollo.provider.clients; + this.toggleChildFromCache(child, child.id, client); + }, + openChild(childItemId, e) { + if (isMetaKey(e)) { + return; + } + e.preventDefault(); + this.activeChildId = childItemId; + this.$refs.modal.show(); + this.updateWorkItemIdUrlQuery(childItemId); + }, + closeModal() { + this.activeChildId = null; + this.updateWorkItemIdUrlQuery(undefined); + }, + handleWorkItemDeleted(childId) { + const { defaultClient: client } = this.$apollo.provider.clients; + this.toggleChildFromCache(null, childId, client); + this.activeToast = this.$toast.show(s__('WorkItem|Task deleted')); + }, + updateWorkItemIdUrlQuery(childItemId) { + updateHistory({ + url: setUrlParams({ work_item_id: getIdFromGraphQLId(childItemId) }), + replace: true, + }); + }, + childPath(childItemId) { + return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(childItemId)}`; + }, + toggleChildFromCache(workItem, childId, store) { + const sourceData = store.readQuery({ + query: getWorkItemLinksQuery, + variables: { id: this.issuableGid }, + }); + + const newData = produce(sourceData, (draftState) => { + const widgetHierarchy = draftState.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + + const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); + + if (index >= 0) { + widgetHierarchy.children.nodes.splice(index, 1); + } else { + widgetHierarchy.children.nodes.push(workItem); + } + }); + + store.writeQuery({ + query: getWorkItemLinksQuery, + variables: { id: this.issuableGid }, + data: newData, + }); + }, + async updateWorkItem(workItem, childId, parentId) { + return this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId } } }, + update: (store) => this.toggleChildFromCache(workItem, childId, store), + }); + }, + async undoChildRemoval(workItem, childId) { + const { data } = await this.updateWorkItem(workItem, childId, this.issuableGid); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast?.hide(); + } + }, + async removeChild(childId) { + const { data } = await this.updateWorkItem(null, childId, null); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId), + }, + }); + } + }, + prefetchWorkItem(id) { + this.prefetch = setTimeout( + () => + this.$apollo.addSmartQuery('prefetchedWorkItem', { + query: workItemQuery, + variables: { + id, + }, + update: (data) => data.workItem, + }), + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + ); + }, + clearPrefetching() { + clearTimeout(this.prefetch); }, }, i18n: { title: s__('WorkItem|Child items'), + fetchError: s__( + 'WorkItem|Something went wrong when fetching the items list. Please refresh this page.', + ), emptyStateMessage: s__( 'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!', ), @@ -112,21 +259,32 @@ export default { <template> <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10"> <div - class="gl-p-4 gl-display-flex gl-justify-content-space-between" + class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" > - <h5 class="gl-m-0 gl-line-height-32 gl-flex-grow-1">{{ $options.i18n.title }}</h5> + <div class="gl-display-flex gl-flex-grow-1"> + <h5 class="gl-m-0 gl-line-height-24">{{ $options.i18n.title }}</h5> + <span + class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3" + data-testid="children-count" + > + <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-gray-500" /> + {{ childrenCountLabel }} + </span> + </div> <gl-button - v-if="!isShownAddForm" + v-if="canUpdate" category="secondary" + size="small" data-testid="toggle-add-form" - @click="toggleAddForm" + @click="showAddForm" > {{ $options.i18n.addChildButtonLabel }} </gl-button> - <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4 gl-ml-3"> + <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> <gl-button category="tertiary" + size="small" :icon="toggleIcon" :aria-label="toggleLabel" data-testid="toggle-links" @@ -134,48 +292,81 @@ export default { /> </div> </div> + <gl-alert v-if="error && !isLoading" variant="danger" @dismiss="error = undefined"> + {{ error }} + </gl-alert> <div v-if="isOpen" - class="gl-bg-gray-10 gl-p-4 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + :class="{ 'gl-p-5 gl-pb-3': !error }" data-testid="links-body" > <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" /> <template v-else> - <div v-if="isChildrenEmpty && !isShownAddForm" data-testid="links-empty"> - <p class="gl-my-3"> + <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty"> + <p class="gl-mt-3 gl-mb-4"> {{ $options.i18n.emptyStateMessage }} </p> </div> <work-item-links-form v-if="isShownAddForm" + ref="wiLinksForm" data-testid="add-links-form" :issuable-gid="issuableGid" :children-ids="childrenIds" - @cancel="toggleAddForm" + :parent-confidential="confidential" + @cancel="hideAddForm" @addWorkItemChild="addChild" /> <div v-for="child in children" :key="child.id" - class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" + class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" data-testid="links-child" > - <div> - <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" /> - <span class="gl-word-break-all">{{ child.title }}</span> + <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> + <gl-icon + :name="iconName(child.state)" + class="gl-mr-3" + :class="iconClass(child.state)" + /> + <gl-icon + v-if="child.confidential" + v-gl-tooltip.top + name="eye-slash" + class="gl-mr-2 gl-text-orange-500" + data-testid="confidential-icon" + :title="__('Confidential')" + /> + <gl-button + :href="childPath(child.id)" + category="tertiary" + variant="link" + class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" + @click="openChild(child.id, $event)" + @mouseover="prefetchWorkItem(child.id)" + @mouseout="clearPrefetching" + > + {{ child.title }} + </gl-button> </div> - <div - class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0 gl-display-inline-flex gl-align-items-center" - > - <gl-badge :variant="badgeVariant(child.state)"> - <span class="gl-sm-display-block">{{ - $options.WORK_ITEM_STATUS_TEXT[child.state] - }}</span> - </gl-badge> - <work-item-links-menu :work-item-id="child.id" :parent-work-item-id="issuableGid" /> + <div class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center"> + <work-item-links-menu + v-if="canUpdate" + :work-item-id="child.id" + :parent-work-item-id="issuableGid" + data-testid="links-menu" + @removeChild="removeChild(child.id)" + /> </div> </div> + <work-item-detail-modal + ref="modal" + :work-item-id="activeChildId" + @close="closeModal" + @workItemDeleted="handleWorkItemDeleted(activeChildId)" + /> </template> </div> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index fadba0753db..8b848995d44 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -1,9 +1,11 @@ <script> -import { GlAlert, GlForm, GlFormCombobox, GlButton } from '@gitlab/ui'; +import { GlAlert, GlFormGroup, GlForm, GlFormCombobox, GlButton, GlFormInput } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; -import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; +import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; +import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql'; +import { TASK_TYPE_NAME } from '../../constants'; export default { components: { @@ -11,6 +13,8 @@ export default { GlForm, GlFormCombobox, GlButton, + GlFormGroup, + GlFormInput, }, inject: ['projectPath'], props: { @@ -24,24 +28,22 @@ export default { required: false, default: () => [], }, + parentConfidential: { + type: Boolean, + required: false, + default: false, + }, }, apollo: { - availableWorkItems: { - query: projectWorkItemsQuery, + workItemTypes: { + query: projectWorkItemTypesQuery, variables() { return { - projectPath: this.projectPath, - searchTerm: this.search?.title || this.search, - types: ['TASK'], + fullPath: this.projectPath, }; }, - skip() { - return this.search.length === 0; - }, update(data) { - return data.workspace.workItems.edges - .filter((wi) => !this.childrenIds.includes(wi.node.id)) - .map((wi) => wi.node); + return data.workspace?.workItemTypes?.nodes; }, }, }, @@ -50,8 +52,32 @@ export default { availableWorkItems: [], search: '', error: null, + childToCreateTitle: null, }; }, + computed: { + actionsList() { + return [ + { + label: this.$options.i18n.createChildOptionLabel, + fn: () => { + this.childToCreateTitle = this.search?.title || this.search; + }, + }, + ]; + }, + addOrCreateButtonLabel() { + return this.childToCreateTitle + ? this.$options.i18n.createChildOptionLabel + : this.$options.i18n.addTaskButtonLabel; + }, + addOrCreateMethod() { + return this.childToCreateTitle ? this.createChild : this.addChild; + }, + taskWorkItemType() { + return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; + }, + }, methods: { getIdFromGraphQLId, unsetError() { @@ -79,35 +105,78 @@ export default { } }) .catch(() => { - this.error = this.$options.i18n.errorMessage; + this.error = this.$options.i18n.addChildErrorMessage; }) .finally(() => { this.search = ''; }); }, + createChild() { + this.$apollo + .mutate({ + mutation: createWorkItemMutation, + variables: { + input: { + title: this.search?.title || this.search, + projectPath: this.projectPath, + workItemTypeId: this.taskWorkItemType, + hierarchyWidget: { + parentId: this.issuableGid, + }, + confidential: this.parentConfidential, + }, + }, + }) + .then(({ data }) => { + if (data.workItemCreate?.errors?.length) { + [this.error] = data.workItemCreate.errors; + } else { + this.unsetError(); + this.$emit('addWorkItemChild', data.workItemCreate.workItem); + } + }) + .catch(() => { + this.error = this.$options.i18n.createChildErrorMessage; + }) + .finally(() => { + this.search = ''; + this.childToCreateTitle = null; + }); + }, }, i18n: { - inputLabel: __('Children'), - errorMessage: s__( + inputLabel: __('Title'), + addTaskButtonLabel: s__('WorkItem|Add task'), + addChildErrorMessage: s__( 'WorkItem|Something went wrong when trying to add a child. Please try again.', ), + createChildOptionLabel: s__('WorkItem|Create task'), + createChildErrorMessage: s__( + 'WorkItem|Something went wrong when trying to create a child. Please try again.', + ), + placeholder: s__('WorkItem|Add a title'), + fieldValidationMessage: __('Maximum of 255 characters'), }, }; </script> <template> <gl-form - class="gl-mb-3 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base" + class="gl-bg-white gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base" + @submit.prevent="createChild" > <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError"> {{ error }} </gl-alert> + <!-- Follow up issue to turn this functionality back on https://gitlab.com/gitlab-org/gitlab/-/issues/368757 --> <gl-form-combobox + v-if="false" v-model="search" :token-list="availableWorkItems" match-value-to-attr="title" class="gl-mb-4" :label-text="$options.i18n.inputLabel" + :action-list="actionsList" label-sr-only autofocus > @@ -117,11 +186,35 @@ export default { <div>{{ item.title }}</div> </div> </template> + <template #action="{ item }"> + <span class="gl-text-blue-500">{{ item.label }}</span> + </template> </gl-form-combobox> - <gl-button category="secondary" data-testid="add-child-button" @click="addChild"> - {{ s__('WorkItem|Add task') }} + <gl-form-group + :label="$options.i18n.inputLabel" + :description="$options.i18n.fieldValidationMessage" + > + <gl-form-input + ref="wiTitleInput" + v-model="search" + :placeholder="$options.i18n.placeholder" + maxlength="255" + class="gl-mb-3" + autofocus + /> + </gl-form-group> + <gl-button + category="primary" + variant="confirm" + size="small" + type="submit" + :disabled="search.length === 0" + data-testid="add-child-button" + class="gl-mr-2" + > + {{ $options.i18n.createChildOptionLabel }} </gl-button> - <gl-button category="tertiary" @click="$emit('cancel')"> + <gl-button category="secondary" size="small" @click="$emit('cancel')"> {{ s__('WorkItem|Cancel') }} </gl-button> </gl-form> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue index 6deb87c5dca..1aa4a433a58 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue @@ -1,10 +1,5 @@ <script> import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { produce } from 'immer'; -import { s__ } from '~/locale'; -import changeWorkItemParentMutation from '../../graphql/change_work_item_parent_link.mutation.graphql'; -import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; -import { WIDGET_TYPE_HIERARCHY } from '../../constants'; export default { components: { @@ -12,78 +7,6 @@ export default { GlDropdown, GlIcon, }, - props: { - workItemId: { - type: String, - required: true, - }, - parentWorkItemId: { - type: String, - required: true, - }, - }, - data() { - return { - activeToast: null, - }; - }, - methods: { - toggleChildFromCache(data, store) { - const sourceData = store.readQuery({ - query: getWorkItemLinksQuery, - variables: { id: this.parentWorkItemId }, - }); - - const newData = produce(sourceData, (draftState) => { - const widgetHierarchy = draftState.workItem.widgets.find( - (widget) => widget.type === WIDGET_TYPE_HIERARCHY, - ); - - const index = widgetHierarchy.children.nodes.findIndex( - (child) => child.id === this.workItemId, - ); - - if (index >= 0) { - widgetHierarchy.children.nodes.splice(index, 1); - } else { - widgetHierarchy.children.nodes.push(data.workItemUpdate.workItem); - } - }); - - store.writeQuery({ - query: getWorkItemLinksQuery, - variables: { id: this.parentWorkItemId }, - data: newData, - }); - }, - async addChild(data) { - const { data: resp } = await this.$apollo.mutate({ - mutation: changeWorkItemParentMutation, - variables: { id: this.workItemId, parentId: this.parentWorkItemId }, - update: this.toggleChildFromCache.bind(this, data), - }); - - if (resp.workItemUpdate.errors.length === 0) { - this.activeToast?.hide(); - } - }, - async removeChild() { - const { data } = await this.$apollo.mutate({ - mutation: changeWorkItemParentMutation, - variables: { id: this.workItemId, parentId: null }, - update: this.toggleChildFromCache.bind(this, null), - }); - - if (data.workItemUpdate.errors.length === 0) { - this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { - action: { - text: s__('WorkItem|Undo'), - onClick: this.addChild.bind(this, data), - }, - }); - } - }, - }, }; </script> @@ -93,7 +16,7 @@ export default { <template #button-content> <gl-icon name="ellipsis_v" :size="14" /> </template> - <gl-dropdown-item @click="removeChild"> + <gl-dropdown-item @click="$emit('removeChild')"> {{ s__('WorkItem|Remove') }} </gl-dropdown-item> </gl-dropdown> diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state.vue index 87f4a8822b1..080d4025cc3 100644 --- a/app/assets/javascripts/work_items/components/work_item_state.vue +++ b/app/assets/javascripts/work_items/components/work_item_state.vue @@ -27,6 +27,11 @@ export default { required: false, default: null, }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -102,7 +107,7 @@ export default { <item-state v-if="workItem.state" :state="workItem.state" - :loading="updateInProgress" + :disabled="updateInProgress || !canUpdate" @changed="updateWorkItemState" /> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue index b4c13037038..cd5cc3894f6 100644 --- a/app/assets/javascripts/work_items/components/work_item_title.vue +++ b/app/assets/javascripts/work_items/components/work_item_title.vue @@ -31,6 +31,11 @@ export default { required: false, default: null, }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, }, computed: { tracking() { @@ -84,5 +89,5 @@ export default { </script> <template> - <item-title :title="workItemTitle" @title-changed="updateTitle" /> + <item-title :title="workItemTitle" :disabled="!canUpdate" @title-changed="updateTitle" /> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue new file mode 100644 index 00000000000..fd914fa350b --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -0,0 +1,44 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { WORK_ITEMS_TYPE_MAP } from '../constants'; + +export default { + components: { + GlIcon, + }, + props: { + workItemType: { + type: String, + required: false, + default: '', + }, + showText: { + type: Boolean, + required: false, + default: false, + }, + workItemIconName: { + type: String, + required: false, + default: '', + }, + }, + computed: { + iconName() { + return ( + this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue' + ); + }, + workItemTypeName() { + return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name; + }, + }, +}; +</script> + +<template> + <span> + <gl-icon :name="iconName" class="gl-mr-2" /> + <span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span> + </span> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue index 30e2c1e56b8..b0ad7c97bb1 100644 --- a/app/assets/javascripts/work_items/components/work_item_weight.vue +++ b/app/assets/javascripts/work_items/components/work_item_weight.vue @@ -1,9 +1,10 @@ <script> import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { __ } from '~/locale'; import Tracking from '~/tracking'; -import { TRACKING_CATEGORY_SHOW } from '../constants'; -import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; +import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; /* eslint-disable @gitlab/require-i18n-strings */ const allowedKeys = [ @@ -97,17 +98,36 @@ export default { } }, updateWeight(event) { + if (!this.canUpdate) return; this.isEditing = false; + + const weight = Number(event.target.value); + if (this.weight === weight) { + return; + } + this.track('updated_weight'); - this.$apollo.mutate({ - mutation: localUpdateWorkItemMutation, - variables: { - input: { - id: this.workItemId, - weight: event.target.value === '' ? null : Number(event.target.value), + this.$apollo + .mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + weightWidget: { + weight: event.target.value === '' ? null : weight, + }, + }, }, - }, - }); + }) + .then(({ data }) => { + if (data.workItemUpdate.errors.length) { + throw new Error(data.workItemUpdate.errors.join('\n')); + } + }) + .catch((error) => { + this.$emit('error', i18n.updateError); + Sentry.captureException(error); + }); }, }, }; diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 2140b418e6d..a2aea3cd327 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -8,11 +8,6 @@ export const STATE_EVENT_CLOSE = 'CLOSE'; export const TRACKING_CATEGORY_SHOW = 'workItems:show'; -export const i18n = { - fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), - updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), -}; - export const TASK_TYPE_NAME = 'Task'; export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES'; @@ -22,13 +17,48 @@ export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner'; -export const WIDGET_TYPE_TASK_ICON = 'task-done'; +export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; +export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE'; +export const WORK_ITEM_TYPE_ENUM_TASK = 'TASK'; +export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE'; +export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; + +export const i18n = { + fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), + updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), + confidentialTooltip: s__( + 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.', + ), +}; export const WIDGET_ICONS = { - TASK: 'task-done', + TASK: 'issue-type-task', }; export const WORK_ITEM_STATUS_TEXT = { CLOSED: s__('WorkItem|Closed'), OPEN: s__('WorkItem|Open'), }; + +export const WORK_ITEMS_TYPE_MAP = { + [WORK_ITEM_TYPE_ENUM_INCIDENT]: { + icon: `issue-type-incident`, + name: s__('WorkItem|Incident'), + }, + [WORK_ITEM_TYPE_ENUM_ISSUE]: { + icon: `issue-type-issue`, + name: s__('WorkItem|Issue'), + }, + [WORK_ITEM_TYPE_ENUM_TASK]: { + icon: `issue-type-task`, + name: s__('WorkItem|Task'), + }, + [WORK_ITEM_TYPE_ENUM_TEST_CASE]: { + icon: `issue-type-test-case`, + name: s__('WorkItem|Test case'), + }, + [WORK_ITEM_TYPE_ENUM_REQUIREMENTS]: { + icon: `issue-type-requirements`, + name: s__('WorkItem|Requirements'), + }, +}; diff --git a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql deleted file mode 100644 index dc5286174d8..00000000000 --- a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql +++ /dev/null @@ -1,13 +0,0 @@ -mutation changeWorkItemParentLink($id: WorkItemID!, $parentId: WorkItemID) { - workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) { - workItem { - id - workItemType { - id - } - title - state - } - errors - } -} diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql index 7f9aaf43068..4cc23fa0071 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql @@ -1,9 +1,10 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation createWorkItem($input: WorkItemCreateInput!) { workItemCreate(input: $input) { workItem { ...WorkItem } + errors } } diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql index ccfe62cc585..1f98cd4fa2b 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) { workItemCreateFromTask(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql index 43c92cf89ec..790b8e60b6a 100644 --- a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) { localUpdateWorkItem(input: $input) @client { diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 8788ad21e7b..b70c06fddea 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -2,7 +2,7 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_WEIGHT } from '../constants'; +import { WIDGET_TYPE_LABELS } from '../constants'; import typeDefs from './typedefs.graphql'; import workItemQuery from './work_item.query.graphql'; @@ -10,7 +10,7 @@ export const temporaryConfig = { typeDefs, cacheConfig: { possibleTypes: { - LocalWorkItemWidget: ['LocalWorkItemLabels', 'LocalWorkItemWeight'], + LocalWorkItemWidget: ['LocalWorkItemLabels'], }, typePolicies: { WorkItem: { @@ -25,15 +25,15 @@ export const temporaryConfig = { allowScopedLabels: true, nodes: [], }, - { - __typename: 'LocalWorkItemWeight', - type: 'WEIGHT', - weight: null, - }, ] ); }, }, + widgets: { + merge(_, incoming) { + return incoming; + }, + }, }, }, }, @@ -49,20 +49,6 @@ export const resolvers = { }); const data = produce(sourceData, (draftData) => { - if (input.assignees) { - const assigneesWidget = draftData.workItem.widgets.find( - (widget) => widget.type === WIDGET_TYPE_ASSIGNEES, - ); - assigneesWidget.assignees.nodes = [...input.assignees]; - } - - if (input.weight != null) { - const weightWidget = draftData.workItem.mockWidgets.find( - (widget) => widget.type === WIDGET_TYPE_WEIGHT, - ); - weightWidget.weight = input.weight; - } - if (input.labels) { const labelsWidget = draftData.workItem.mockWidgets.find( (widget) => widget.type === WIDGET_TYPE_LABELS, diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 48228b15a53..36ffba8a540 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -1,7 +1,6 @@ enum LocalWidgetType { ASSIGNEES LABELS - WEIGHT } interface LocalWorkItemWidget { @@ -19,20 +18,29 @@ type LocalWorkItemLabels implements LocalWorkItemWidget { nodes: [Label!] } -type LocalWorkItemWeight implements LocalWorkItemWidget { - type: LocalWidgetType! - weight: Int -} - extend type WorkItem { mockWidgets: [LocalWorkItemWidget] } +input LocalUserInput { + id: ID! + name: String + username: String + webUrl: String + avatarUrl: String +} + +input LocalLabelInput { + id: ID! + title: String! + color: String + description: String +} + input LocalUpdateWorkItemInput { id: WorkItemID! - assignees: [UserCore!] - labels: [Label] - weight: Int + assignees: [LocalUserInput!] + labels: [LocalLabelInput] } type LocalWorkItemPayload { diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql index 25eb8099251..0a887fcfc00 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation workItemUpdate($input: WorkItemUpdateInput!) { workItemUpdate(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql index ad861a60d15..fad5a9fa5bc 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) { workItemUpdate: workItemUpdateTask(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql index 148b340b439..6a94c96b347 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation workItemUpdateWidgets($input: WorkItemUpdateWidgetsInput!) { workItemUpdateWidgets(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index 5f64eda96aa..e8ef27ec778 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -5,9 +5,11 @@ fragment WorkItem on WorkItem { title state description + confidential workItemType { id name + iconName } userPermissions { deleteWorkItem @@ -22,6 +24,7 @@ fragment WorkItem on WorkItem { ... on WorkItemWidgetAssignees { type allowsMultipleAssignees + canInviteMembers assignees { nodes { ...User @@ -34,12 +37,11 @@ fragment WorkItem on WorkItem { id iid title + confidential } children { - edges { - node { - id - } + nodes { + id } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 61cb8802187..a9f7b714551 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" query workItem($id: WorkItemID!) { workItem(id: $id) { @@ -12,10 +12,6 @@ query workItem($id: WorkItemID!) { ...Label } } - ... on LocalWorkItemWeight { - type - weight - } } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql index c2496f53cc8..df62ca1c143 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -5,6 +5,11 @@ query workItemQuery($id: WorkItemID!) { id } title + userPermissions { + deleteWorkItem + updateWorkItem + } + confidential widgets { type ... on WorkItemWidgetHierarchy { @@ -15,6 +20,7 @@ query workItemQuery($id: WorkItemID!) { children { nodes { id + confidential workItemType { id } diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index be72ec33465..004dc22c9b8 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -27,7 +27,6 @@ @import './pages/registry'; @import './pages/search'; @import './pages/service_desk'; -@import './pages/settings_ci_cd'; @import './pages/settings'; @import './pages/storage_quota'; @import './pages/tree'; diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss index ceac5da7f80..6a6febbf7b4 100644 --- a/app/assets/stylesheets/components/avatar.scss +++ b/app/assets/stylesheets/components/avatar.scss @@ -37,7 +37,7 @@ $avatar-sizes: ( ), 60: ( font-size: 32px, - line-height: 58px, + line-height: 60px, border-radius: $border-radius-large ), 64: ( @@ -47,7 +47,7 @@ $avatar-sizes: ( ), 90: ( font-size: 36px, - line-height: 88px, + line-height: 90px, border-radius: $border-radius-large ), 96: ( @@ -72,7 +72,6 @@ $avatar-sizes: ( float: left; margin-right: $gl-padding; border-radius: $avatar-radius; - border: 1px solid $t-gray-a-08; @each $size, $size-config in $avatar-sizes { &.s#{$size} { @@ -83,13 +82,12 @@ $avatar-sizes: ( .avatar { transition-property: none; - width: 40px; height: 40px; padding: 0; background: $gray-lightest; overflow: hidden; - border-color: rgba($black, $gl-avatar-border-opacity); + box-shadow: inset 0 0 0 1px rgba($gray-950, $gl-avatar-border-opacity); &.avatar-inline { float: none; @@ -180,6 +178,10 @@ $avatar-sizes: ( @each $size, $size-config in $avatar-sizes { &.s#{$size} { border-radius: map-get($size-config, 'border-radius'); + + .avatar { + border-radius: map-get($size-config, 'border-radius'); + } } } } diff --git a/app/assets/stylesheets/components/batch_comments/review_bar.scss b/app/assets/stylesheets/components/batch_comments/review_bar.scss index 6f5c5c5a080..5e1128dc4ce 100644 --- a/app/assets/stylesheets/components/batch_comments/review_bar.scss +++ b/app/assets/stylesheets/components/batch_comments/review_bar.scss @@ -11,7 +11,7 @@ padding-right: $gutter_collapsed_width; background: $white; border-top: 1px solid $border-color; - transition: padding $sidebar-transition-duration; + transition: padding $gl-transition-duration-medium; .page-with-icon-sidebar & { padding-left: $contextual-sidebar-collapsed-width; diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss deleted file mode 100644 index 59bd69955d3..00000000000 --- a/app/assets/stylesheets/components/rich_content_editor.scss +++ /dev/null @@ -1,54 +0,0 @@ -/** -* Overrides styles from ToastUI editor -*/ - -.tui-editor-defaultUI { - - // Toolbar buttons - .tui-editor-defaultUI-toolbar .toolbar-button { - color: $gray-500; - border: 0; - - &:hover, - &:active { - color: $blue-500; - border: 0; - } - } - - // Contextual menu's & popups - .tui-popup-wrapper { - @include gl-overflow-hidden; - @include gl-rounded-base; - @include gl-border-gray-200; - - hr { - @include gl-m-0; - @include gl-bg-gray-200; - } - - button { - @include gl-text-gray-700; - } - } - - /** - * Overrides styles from ToastUI's Code Mirror (markdown mode) editor. - * Toast UI internally overrides some of these using the `.tui-md-` prefix. - * https://codemirror.net/doc/manual.html#styling - */ - - .te-md-container .CodeMirror * { - @include gl-font-monospace; - @include gl-font-size-monospace; - @include gl-line-height-20; - } -} - -/** -* Styling below ensures that YouTube videos are displayed in the editor the same as they would in about.gitlab.com -* https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/main/source/stylesheets/_base.scss#L977 -*/ -.video_container { - padding-bottom: 56.25%; -} diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 549289450a4..f947042ba51 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -84,89 +84,6 @@ border-bottom: 1px solid $white-dark; padding: 11px 0; margin-bottom: 11px; - - &.no-bottom-space { - border-bottom: 0; - margin-bottom: 0; - } -} - -.cover-block { - text-align: center; - background: $gray-light; - padding-top: 44px; - position: relative; - - .avatar-holder { - .avatar, - .identicon { - margin: 0 auto; - float: none; - } - - .identicon { - border-radius: 50%; - } - } - - .cover-title { - color: $gl-text-color; - font-size: 23px; - - h1 { - color: $gl-text-color; - margin-bottom: 6px; - font-size: 23px; - } - - .visibility-icon { - display: inline-block; - margin-left: 5px; - font-size: 18px; - color: color('gray'); - } - - p { - padding: 0 $gl-padding; - color: $gl-text-color; - } - } - - .cover-controls { - @include media-breakpoint-up(sm) { - position: absolute; - top: 1rem; - right: 1.25rem; - } - - &.left { - @include media-breakpoint-up(sm) { - left: 1.25rem; - right: auto; - } - } - } - - &.user-cover-block { - padding: 24px 0 0; - - .nav-links { - width: 100%; - float: none; - - &.scrolling-tabs { - float: none; - } - } - - li:first-child { - margin-left: auto; - } - - li:last-child { - margin-right: auto; - } - } } .content-block { diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 1fa03d66f32..b1e5ca50a8b 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -1,30 +1,3 @@ -.calendar-block { - padding-left: 0; - padding-right: 0; - border-top: 0; - - @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) { - overflow-x: auto; - } -} - -.user-calendar-activities { - direction: ltr; - - .str-truncated { - max-width: 70%; - } -} - -.user-calendar { - text-align: center; - min-height: 172px; - - .calendar { - display: inline-block; - } -} - .user-contrib-cell { &:hover { cursor: pointer; @@ -42,18 +15,6 @@ } } -.user-contrib-text { - font-size: 12px; - fill: $calendar-user-contrib-text; -} - -.calendar-hint { - font-size: 12px; - direction: ltr; - margin-top: -23px; - float: right; -} - .pika-single.gitlab-theme { .pika-label { color: $gl-text-color-secondary; diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 036cec15935..ad0036df607 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -134,16 +134,6 @@ .avatar-container { @include gl-font-weight-normal; flex: none; - box-shadow: $avatar-box-shadow; - - &.rect-avatar { - @include gl-border-none; - - .avatar.s32 { - border-radius: $border-radius-default; - box-shadow: $avatar-box-shadow; - } - } } } @@ -214,7 +204,7 @@ // .page-with-contextual-sidebar { - transition: padding-left $sidebar-transition-duration; + transition: padding-left $gl-transition-duration-medium; @include media-breakpoint-up(md) { padding-left: $contextual-sidebar-collapsed-width; @@ -243,7 +233,7 @@ @include gl-fixed; @include gl-bottom-0; @include gl-left-0; - transition: width $sidebar-transition-duration, left $sidebar-transition-duration; + transition: width $gl-transition-duration-medium, left $gl-transition-duration-medium; z-index: 600; width: $contextual-sidebar-width; top: $header-height; diff --git a/app/assets/stylesheets/framework/contextual_sidebar_header.scss b/app/assets/stylesheets/framework/contextual_sidebar_header.scss index 7159dadf7cc..a3d752dcc3d 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar_header.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar_header.scss @@ -5,7 +5,7 @@ > a, > button { - transition: padding $sidebar-transition-duration; + transition: padding $gl-transition-duration-medium; font-weight: $gl-font-weight-bold; display: flex; width: 100%; @@ -25,7 +25,7 @@ } .avatar-container { - flex: 0 0 40px; + flex: 0 0 32px; background-color: $white; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 43e14a63f9d..d91524d99e6 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -125,10 +125,6 @@ padding-right: 25px; } - .fa { - color: $gray-darkest; - } - &:hover { border-color: $gray-darkest; } @@ -148,10 +144,6 @@ text-overflow: ellipsis; width: 160px; - .fa { - position: absolute; - } - .gl-spinner { position: absolute; top: 9px; @@ -387,10 +379,6 @@ margin: 0; text-align: left; text-overflow: inherit; - - &.btn .fa:not(:last-child) { - margin-left: 5px; - } } > button.dropdown-epic-button { @@ -477,6 +465,12 @@ height: 2 * $gl-padding; margin: 0 10px 0 0; } + + .sidebar-participant { + .merge-icon { + top: calc(50% + 5px); + } + } } .dropdown-menu-user-full-name { @@ -645,14 +639,12 @@ border-color: $blue-300; box-shadow: 0 0 4px $dropdown-input-focus-shadow; - ~ .fa, ~ .dropdown-input-clear { color: $gray-700; } } &:hover { - ~ .fa, ~ .dropdown-input-clear { color: $gray-700; } @@ -710,14 +702,6 @@ z-index: 9; background-color: $dropdown-loading-bg; font-size: 28px; - - .fa { - position: absolute; - top: 50%; - left: 50%; - margin-top: -14px; - margin-left: -14px; - } } .dropdown-label-box { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index f322c6c8929..b980d7fdaa7 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -202,6 +202,10 @@ float: none; border-left: 1px solid $gray-100; + .file-line-num { + @include gl-min-w-9; + } + i { float: none; margin-right: 0; @@ -478,6 +482,11 @@ span.idiff { background-color: transparent; border: transparent; } + + .gl-dark & { + background: transparent; + filter: invert(1) hue-rotate(180deg); + } } .code-navigation-line:hover { @@ -575,3 +584,11 @@ span.idiff { @include gl-text-center; } } + +// *:nth-of-type(1n+30) - makes sure we do not render elements 30+ right away when +// viewing a file. Even though the HTML is injected in the DOM, as long as we do +// not render those elements, the browser doesn't need to spend resources +// calculating and repainting what's hidden. +.file-holder [data-loading] .file-content *:nth-of-type(1n+30) { + @include gl-display-none; +} diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 1c43212f501..2b76e70fa17 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -31,7 +31,8 @@ width: 100%; padding-left: 10px; padding-right: 10px; - white-space: pre; + white-space: break-spaces; + word-break: break-word; &:empty::before { content: '\200b'; @@ -48,8 +49,9 @@ a { font-family: $monospace-font; - display: block; white-space: nowrap; + @include gl-display-flex; + @include gl-justify-content-end; i, svg { @@ -90,3 +92,55 @@ td.line-numbers { cursor: pointer; text-decoration: underline wavy $red-500; } + +.blob-viewer { + .line-numbers { + // for server-side-rendering + .line-links { + @include gl-display-flex; + + + &:first-child { + margin-top: 10px; + } + + &:last-child { + margin-bottom: 10px; + } + } + + // for client + &.line-links { + min-width: 6rem; + border-bottom-left-radius: 0; + + + pre { + margin-left: 6rem; + } + } + } + + .line-links { + &:hover a::before, + &:focus-within a::before { + @include gl-visibility-visible; + } + } + + .file-line-num { + min-width: 4.5rem; + @include gl-justify-content-end; + @include gl-flex-grow-1; + @include gl-pr-3; + } + + .file-line-blame { + @include gl-ml-3; + } + + .file-line-num, + .file-line-blame { + @include gl-align-items-center; + @include gl-display-flex; + } +} diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 2cea3b96ff7..47856f1a0d3 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -478,7 +478,7 @@ } @mixin side-panel-toggle { - transition: width $sidebar-transition-duration; + transition: width $gl-transition-duration-medium; height: $toggle-sidebar-height; padding: 0 $gl-padding; background-color: $gray-light; diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 74aed1bd984..92ca8654287 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -135,27 +135,8 @@ } @include media-breakpoint-down(md) { - $controls-margin: $btn-margin-5 - 2px; flex: 0 0 100%; margin-top: $gl-padding-8; - - .controls-item, - .controls-item-full, - .controls-item:last-child { - flex: 1 1 35%; - display: block; - width: 100%; - margin: $controls-margin; - - .btn, - .dropdown { - margin: 0; - } - } - - .controls-item-full { - flex: 1 1 100%; - } } @include media-breakpoint-down(sm) { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 13201d43fd0..ae0f18753ad 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -13,7 +13,7 @@ } .page-initialised .content-wrapper { - transition: padding $sidebar-transition-duration; + transition: padding $gl-transition-duration-medium; } .right-sidebar-collapsed { @@ -109,7 +109,7 @@ @include maintain-sidebar-dimensions; width: 0; padding: 0; - transition: width $sidebar-transition-duration; + transition: width $gl-transition-duration-medium; &.right-sidebar-expanded { @include maintain-sidebar-dimensions; diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss index 953c42219a9..f9e95d16f63 100644 --- a/app/assets/stylesheets/framework/sortable.scss +++ b/app/assets/stylesheets/framework/sortable.scss @@ -1,6 +1,4 @@ .sortable-container { - background-color: $gray-light; - .flex-list { padding: 5px; margin-bottom: 0; diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 086b83b13e0..43effbdd7d7 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -35,6 +35,10 @@ background-color: $white; } + &:not(.note-form).internal-note { + background-color: $orange-50; + } + .timeline-entry-inner { position: relative; } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index b5e0dcd875a..031f5dc45ca 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -435,6 +435,35 @@ } } + li.inapplicable { + // for a single line list item, no paragraph (tight list) + > s { + color: $gl-text-color-disabled; + } + + // additional blocks, other than paragraphs + > div { + text-decoration: line-through; + color: $gl-text-color-disabled; + } + + // because of the embedded checkbox, putting line-through on the entire + // paragraph causes the space between the checkbox and the text to have the + // line-through. Targeting just the `s` fixes this + > p:first-of-type > s { + color: $gl-text-color-disabled; + } + + > p:not(:first-of-type) { + text-decoration: line-through; + color: $gl-text-color-disabled; + } + + .drag-icon { + color: $gl-text-color; + } + } + a.with-attachment-icon, a[href*='/uploads/'], a[href*='storage.googleapis.com/google-code-attachments/'] { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 1e921b4234e..e9ad930ef2b 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -5,7 +5,6 @@ $grid-size: 8px; $gutter-collapsed-width: 62px; $gutter-width: 290px; $gutter-inner-width: 250px; -$sidebar-transition-duration: 0.3s; $sidebar-breakpoint: 1024px; $default-transition-duration: 0.15s; $contextual-sidebar-width: 256px; @@ -454,7 +453,6 @@ $default-icon-size: 16px; $layout-link-gray: #7e7c7c; $btn-side-margin: $grid-size; $btn-sm-side-margin: 7px; -$btn-margin-5: 5px; $count-arrow-border: #dce0e5; $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; @@ -880,8 +878,6 @@ $image-comment-cursor-top-offset: 12; Security & Compliance Carousel */ $security-and-compliance-carousel-image-carousel-width: 1000px; -$security-and-compliance-carousel-image-discover-button-width: 45%; -$security-and-compliance-carousel-image-discover-buttons-max-width: 280px; $security-and-compliance-carousel-image-discover-footer-max-width: 500px; $security-and-compliance-carousel-image-discover-text-carousel-max-width: 650px; $security-and-compliance-carousel-image-discover-text-carousel-caption-height: 100%; diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss index 1a536b97142..e3ac615234c 100644 --- a/app/assets/stylesheets/framework/vue_transitions.scss +++ b/app/assets/stylesheets/framework/vue_transitions.scss @@ -2,7 +2,7 @@ .fade-leave-active, .fade-in-enter-active, .fade-out-leave-active { - transition: opacity $sidebar-transition-duration $general-hover-transition-curve; + transition: opacity $gl-transition-duration-medium $general-hover-transition-curve; } .fade-enter, diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index fcbd05141b9..96df8487c0e 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -98,32 +98,33 @@ } } -@mixin line-number-link($color) { - min-width: $gl-spacing-scale-9; +@mixin line-link($color, $icon) { &::before { - @include gl-display-none; + @include gl-visibility-hidden; @include gl-align-self-center; - @include gl-mt-2; - @include gl-mr-2; - @include gl-w-4; - @include gl-h-4; - @include gl-absolute; - @include gl-left-3; - background-color: $color; - mask-image: asset_url('icons-stacked.svg#link'); + @include gl-mr-1; + @include gl-w-5; + @include gl-h-5; + background-color: rgba($color, 0.3); + mask-image: asset_url('icons-stacked.svg##{$icon}'); mask-repeat: no-repeat; mask-size: cover; mask-position: center; content: ''; } - &:hover::before { - @include gl-display-inline-block; + &:hover { + &::before { + background-color: rgba($color, 0.6); + } } +} - &:focus::before { - @include gl-display-inline-block; +@mixin line-hover-bg($color: $white-normal) { + &:hover, + &:focus-within { + background-color: darken($color, 10); } } diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 709e7f5ae18..5e6e10e44be 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -127,7 +127,15 @@ $dark-il: #de935f; .code.dark { // Line numbers .file-line-num { - @include line-number-link($dark-line-num-color); + @include line-link($white, 'link'); + } + + .file-line-blame { + @include line-link($white, 'git'); + } + + .line-links { + @include line-hover-bg($dark-main-bg); } .line-numbers, diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 0ed9c209417..19c3d6926e7 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -120,7 +120,15 @@ $monokai-gh: #75715e; // Line numbers .file-line-num { - @include line-number-link($monokai-line-num-color); + @include line-link($white, 'link'); + } + + .file-line-blame { + @include line-link($white, 'git'); + } + + .line-links { + @include line-hover-bg($monokai-bg); } .line-numbers, diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index 868e466b1f8..4c716d20ddf 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -25,7 +25,15 @@ // Line numbers .file-line-num { - @include line-number-link($black-transparent); + @include line-link($black, 'link'); + } + + .file-line-blame { + @include line-link($black, 'git'); + } + + .line-links { + @include line-hover-bg; } .line-numbers, diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index 6260339a48d..70086be1606 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -123,7 +123,15 @@ $solarized-dark-il: #2aa198; // Line numbers .file-line-num { - @include line-number-link($solarized-dark-line-color); + @include line-link($white, 'link'); + } + + .file-line-blame { + @include line-link($white, 'git'); + } + + .line-links { + @include line-hover-bg($solarized-dark-pre-bg); } .line-numbers, diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index e6f098f4cdf..8d223d1fdb1 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -109,7 +109,15 @@ $solarized-light-il: #2aa198; @include hljs-override('title.class_.inherited__', $solarized-light-no); // Line numbers .file-line-num { - @include line-number-link($solarized-light-line-color); + @include line-link($black, 'link'); + } + + .file-line-blame { + @include line-link($black, 'git'); + } + + .line-links { + @include line-hover-bg($solarized-light-pre-bg); } .line-numbers, diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 770a90bbc57..9761e3961dd 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -95,7 +95,15 @@ $white-gc-bg: #eaf2f5; // Line numbers .file-line-num { - @include line-number-link($black-transparent); + @include line-link($black, 'link'); +} + +.file-line-blame { + @include line-link($black, 'git'); +} + +.line-links { + @include line-hover-bg; } .line-numbers, diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index 81d35b8bc7b..197073412e8 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -35,7 +35,7 @@ .boards-app { @include media-breakpoint-up(sm) { - transition: width $sidebar-transition-duration; + transition: width $gl-transition-duration-medium; width: 100%; &.is-compact { @@ -349,7 +349,7 @@ .right-sidebar.right-sidebar-expanded { &.boards-sidebar-slide-enter-active, &.boards-sidebar-slide-leave-active { - transition: width $sidebar-transition-duration, padding $sidebar-transition-duration; + transition: width $gl-transition-duration-medium, padding $gl-transition-duration-medium; } &.boards-sidebar-slide-enter, diff --git a/app/assets/stylesheets/page_bundles/escalation_policies.scss b/app/assets/stylesheets/page_bundles/escalation_policies.scss index 6f3873fea0c..84c62ba93dd 100644 --- a/app/assets/stylesheets/page_bundles/escalation_policies.scss +++ b/app/assets/stylesheets/page_bundles/escalation_policies.scss @@ -15,17 +15,11 @@ $stroke-size: 1px; .right-arrow { - @include gl-relative; height: $stroke-size; - background-color: var(--gray-900, $gray-900); - min-width: $gl-spacing-scale-7; &-head { - @include gl-absolute; - top: -2*$stroke-size; - left: calc(100% - #{5*$stroke-size}); - @include gl-p-1; - @include gl-border-solid; + top: -2 * $stroke-size; + left: calc(100% - #{5 * $stroke-size}); border-width: 0 $stroke-size $stroke-size 0; border-color: var(--gray-900, $gray-900); transform: rotate(-45deg); @@ -41,14 +35,10 @@ $stroke-size: 1px; .rule-condition { @media (min-width: $breakpoint-lg) { flex-basis: 25%; - flex-shrink: 0; + @include gl-flex-shrink-0; } @media (max-width: $breakpoint-lg) { @include gl-w-full; } } - -.rule-action { - min-width: 0; -} diff --git a/app/assets/stylesheets/page_bundles/group.scss b/app/assets/stylesheets/page_bundles/group.scss index 38dd07f617c..71dbb855103 100644 --- a/app/assets/stylesheets/page_bundles/group.scss +++ b/app/assets/stylesheets/page_bundles/group.scss @@ -72,36 +72,43 @@ } } -.group-nav-container .nav-controls { - .group-filter-form { - flex: 1 1 auto; - margin-right: $gl-padding-8; - } - - .dropdown-menu-right { - margin-top: 0; - } - - @include media-breakpoint-down(sm) { - .dropdown, - .dropdown .dropdown-toggle, - .btn-success { - display: block; +.group-nav-container { + .nav-controls { + .group-filter-form { + flex: 1 1 auto; + margin-right: $gl-padding-8; } - .group-filter-form, - .dropdown { - margin-bottom: 10px; - margin-right: 0; + .dropdown-menu-right { + margin-top: 0; } - &, - .group-filter-form, - .group-filter-form-field, - .dropdown, - .dropdown .dropdown-toggle, - .btn-success { - width: 100%; + @include media-breakpoint-down(sm) { + .dropdown, + .dropdown .dropdown-toggle, + .btn-success { + display: block; + } + + .group-filter-form, + .dropdown { + margin-bottom: 10px; + margin-right: 0; + } + + &, + .group-filter-form, + .group-filter-form-field, + .dropdown, + .dropdown .dropdown-toggle, + .btn-success { + width: 100%; + } } } + + // Remove this selector once https://gitlab.com/gitlab-org/gitlab/-/issues/370050 is addressed. + .scrolling-tabs-container { + width: 100%; + } } diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 1b27e51e793..b7a75884425 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -416,8 +416,6 @@ $tabs-holder-z-index: 250; .label-branch { @include gl-font-monospace; font-size: 95%; - color: var(--gl-text-color, $gl-text-color); - font-weight: normal; overflow: hidden; word-break: break-all; } @@ -477,8 +475,7 @@ $tabs-holder-z-index: 250; margin: 0 0 0 10px; } - .bold, - .gl-font-weight-bold { + .bold { font-weight: $gl-font-weight-bold; color: var(--gray-600, $gray-600); margin-left: 10px; @@ -494,8 +491,7 @@ $tabs-holder-z-index: 250; } .spacing, - .bold, - .gl-font-weight-bold { + .bold { vertical-align: middle; } @@ -602,6 +598,12 @@ $tabs-holder-z-index: 250; padding: $gl-padding; } +.mr-widget-body-ready-merge { + @include media-breakpoint-down(sm) { + @include gl-p-3; + } +} + .mr-widget-border-top { border-top: 1px solid var(--border-color, $border-color); } @@ -820,3 +822,21 @@ $tabs-holder-z-index: 250; height: 180px; } } + +.mr-widget-merge-details { + li:not(:last-child) { + @include gl-mb-3; + } +} + +.mr-ready-merge-related-links, +.mr-widget-merge-details { + a { + @include gl-text-decoration-underline; + + &:hover, + &:focus { + @include gl-text-decoration-none; + } + } +} diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index e6afc70acbb..98e9e2b3c27 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -225,12 +225,20 @@ } } -.test-reports-table { - .build-log { - @include build-log(); +.progress-bar.bg-primary { + background-color: var(--blue-500, $blue-500) !important; +} + +.ci-job-component { + .job-failed { + background-color: var(--red-50, $red-50); } } -.progress-bar.bg-primary { - background-color: var(--blue-500, $blue-500) !important; +.gl-dark { + .ci-job-component { + .job-failed { + background-color: var(--gray-200, $gray-200); + } + } } diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss new file mode 100644 index 00000000000..59b8823c113 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/profile.scss @@ -0,0 +1,212 @@ +@import 'mixins_and_variables_and_functions'; + +.calendar-block { + padding-left: 0; + padding-right: 0; + border-top: 0; + + @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) { + overflow-x: auto; + } +} + +.calendar-hint { + font-size: 12px; + direction: ltr; + margin-top: -23px; + float: right; +} + +.cover-block { + text-align: center; + background: var(--gray-50, $gray-light); + padding-top: 44px; + position: relative; + + .avatar-holder { + .avatar, + .identicon { + margin: 0 auto; + float: none; + } + + .identicon { + border-radius: 50%; + } + } + + .cover-title { + color: var(--gl-text-color, $gl-text-color); + font-size: 23px; + + h1 { + color: var(--gl-text-color, $gl-text-color); + margin-bottom: 6px; + font-size: 23px; + } + + .visibility-icon { + display: inline-block; + margin-left: 5px; + font-size: 18px; + color: color('gray'); + } + + p { + padding: 0 $gl-padding; + color: var(--gl-text-color, $gl-text-color); + } + } + + .cover-controls { + @include media-breakpoint-up(sm) { + position: absolute; + top: 1rem; + right: 1.25rem; + } + + &.left { + @include media-breakpoint-up(sm) { + left: 1.25rem; + right: auto; + } + } + } + + &.user-cover-block { + padding: 24px 0 0; + + .nav-links { + width: 100%; + float: none; + + &.scrolling-tabs { + float: none; + } + } + + li:first-child { + margin-left: auto; + } + + li:last-child { + margin-right: auto; + } + } +} + +// Middle dot divider between each element in a list of items. +.middle-dot-divider { + @include middle-dot-divider; +} + +.middle-dot-divider-sm { + @include media-breakpoint-up(sm) { + @include middle-dot-divider; + } +} + +.profile-user-bio { + // Limits the width of the user bio for readability. + max-width: 600px; + margin: 10px auto; +} + +.user-calendar { + text-align: center; + min-height: 172px; + + .calendar { + display: inline-block; + } +} + +.user-calendar-activities { + direction: ltr; + + .str-truncated { + max-width: 70%; + } +} + +.user-contrib-text { + font-size: 12px; + fill: $calendar-user-contrib-text; +} + +.user-profile { + .profile-header { + margin: 0 $gl-padding; + + &.with-no-profile-tabs { + margin-bottom: $gl-padding-24; + } + + .avatar-holder { + width: 90px; + margin: 0 auto 10px; + } + } + + .user-profile-nav { + font-size: 0; + } + + .fade-right { + right: 0; + } + + .fade-left { + left: 0; + } + + .activities-block { + .event-item { + padding-left: 40px; + } + + .gl-label-scoped { + --label-inset-border: inset 0 0 0 1px currentColor; + } + + @include media-breakpoint-up(lg) { + margin-right: 5px; + } + } + + .projects-block { + @include media-breakpoint-up(lg) { + margin-left: 5px; + } + } + + @include media-breakpoint-down(xs) { + .cover-block { + padding-top: 20px; + } + + .user-profile-nav { + a { + margin-right: 0; + } + } + + .activities-block { + .event-item { + padding-left: 0; + } + } + } +} + +.linkedin-icon { + color: $linkedin; +} + +.skype-icon { + color: $skype; +} + +.twitter-icon { + color: $twitter; +} diff --git a/app/assets/stylesheets/page_bundles/runner_details.scss b/app/assets/stylesheets/page_bundles/runner_details.scss new file mode 100644 index 00000000000..6e5580a18e4 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/runner_details.scss @@ -0,0 +1,3 @@ +.runner-details-grid-template { + grid-template-columns: auto 1fr; +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 51f964a4b70..69797c6b303 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -132,7 +132,7 @@ // stylelint-disable-next-line length-zero-no-unit bottom: var(--review-bar-height, 0px); right: 0; - transition: width $sidebar-transition-duration; + transition: width $gl-transition-duration-medium; background-color: $white; z-index: 200; overflow: hidden; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index c0a283ec643..a151c28fe93 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -318,7 +318,7 @@ ul.related-merge-requests > li gl-emoji { .issuable-header-slide-enter-active, .issuable-header-slide-leave-active { - @include gl-transition-slow; + @include gl-transition-medium; } .issuable-header-slide-enter, diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 7f0bdadd2bc..1beb9f05b6c 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -41,6 +41,20 @@ font-size: 13px; } + .borderless { + .login-box, + .omniauth-container { + box-shadow: none; + } + + .g-recaptcha { + > div { + margin-left: auto; + margin-right: auto; + } + } + } + .login-box, .omniauth-container { box-shadow: 0 0 0 1px $border-color; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 96fe6caeea2..b016d0a1068 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -342,10 +342,10 @@ $comparison-empty-state-height: 62px; .mr-compare { .diff-file .file-title-flex-parent { - top: calc(#{$header-height} + #{$mr-tabs-height} + 36px); + top: calc(#{$header-height} + #{$mr-tabs-height}); .with-performance-bar & { - top: calc(#{$performance-bar-height} + #{$header-height} + #{$mr-tabs-height} + 36px); + top: calc(#{$performance-bar-height} + #{$header-height} + #{$mr-tabs-height}); } } } diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 645f145328b..9692becef4f 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -121,15 +121,6 @@ border-radius: $label-border-radius; padding-top: $gl-vert-padding; padding-bottom: $gl-vert-padding; - - .icon svg { - position: relative; - top: 2px; - margin-right: $btn-margin-5; - width: $gl-font-size; - height: $gl-font-size; - fill: $orange-600; - } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 4d0cf30a3b2..db07f16dfd0 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -4,7 +4,7 @@ $system-note-svg-size: 16px; @mixin vertical-line($left) { &::before { content: ''; - border-left: 2px solid $gray-50; + border-left: 2px solid $gray-10; position: absolute; top: 0; bottom: 0; @@ -29,7 +29,7 @@ $system-note-svg-size: 16px; .issuable-discussion { .main-notes-list { - @include vertical-line(36px); + @include vertical-line(35px); } } @@ -300,17 +300,17 @@ $system-note-svg-size: 16px; .timeline-icon { display: flex; align-items: center; - background-color: $white; + background-color: $gray-10; width: $system-note-icon-size; height: $system-note-icon-size; - border: 1px solid $border-color; + border: 1px solid $gray-10; border-radius: $system-note-icon-size; margin: -6px 20px 0 0; svg { width: $system-note-svg-size; height: $system-note-svg-size; - fill: $gray-darkest; + fill: $gray-400; display: block; margin: 0 auto; } diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 812cc6ab4e6..951e31ef768 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -29,23 +29,6 @@ } } -// Middle dot divider between each element in a list of items. -.middle-dot-divider { - @include middle-dot-divider; -} - -.middle-dot-divider-sm { - @include media-breakpoint-up(sm) { - @include middle-dot-divider; - } -} - -.profile-user-bio { - // Limits the width of the user bio for readability. - max-width: 600px; - margin: 10px auto; -} - .user-avatar-button { .file-name { display: inline-block; @@ -156,71 +139,6 @@ } } -.user-profile { - .profile-header { - margin: 0 $gl-padding; - - &.with-no-profile-tabs { - margin-bottom: $gl-padding-24; - } - - .avatar-holder { - width: 90px; - margin: 0 auto 10px; - } - } - - .user-profile-nav { - font-size: 0; - } - - .fade-right { - right: 0; - } - - .fade-left { - left: 0; - } - - .activities-block { - .event-item { - padding-left: 40px; - } - - .gl-label-scoped { - --label-inset-border: inset 0 0 0 1px currentColor; - } - - @include media-breakpoint-up(lg) { - margin-right: 5px; - } - } - - .projects-block { - @include media-breakpoint-up(lg) { - margin-left: 5px; - } - } - - @include media-breakpoint-down(xs) { - .cover-block { - padding-top: 20px; - } - - .user-profile-nav { - a { - margin-right: 0; - } - } - - .activities-block { - .event-item { - padding-left: 0; - } - } - } -} - table.u2f-registrations { th:not(:last-child), td:not(:last-child) { @@ -366,15 +284,3 @@ table.u2f-registrations { .gitlab-slack-slack-logo { transform: scale(200%); // Slack logo SVG is scaled down 50% and has empty space around it } - -.skype-icon { - color: $skype; -} - -.linkedin-icon { - color: $linkedin; -} - -.twitter-icon { - color: $twitter; -} diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index f1865a7dc40..6c909b8d9fa 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -82,19 +82,17 @@ input[type='checkbox']:hover { min-width: $search-input-field-x-min-width; } - &.is-active { - &.is-searching { - .in-search-scope-help { - position: absolute; - top: $gl-spacing-scale-2; - right: 2.125rem; - z-index: 2; - } + &.is-searching { + .in-search-scope-help { + position: absolute; + top: $gl-spacing-scale-2; + right: 2.125rem; + z-index: 2; } } - &.is-not-searching { - .in-search-scope-help { + &.is-not-focused { + .gl-search-box-by-type-clear { display: none; } } @@ -104,28 +102,11 @@ input[type='checkbox']:hover { box-shadow: none; border-color: transparent; } - - &.is-active { - .keyboard-shortcut-helper { - display: none; - } - } - - &.is-not-active { - .btn.gl-clear-icon-button, - .in-search-scope-help { - display: none; - } - } } .header-search-dropdown-menu { max-height: $dropdown-max-height; - top: $header-height; -} - -.header-search-dropdown-content { - max-height: $dropdown-max-height; + top: 100%; } .search { diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss deleted file mode 100644 index 7d74070b4f2..00000000000 --- a/app/assets/stylesheets/pages/settings_ci_cd.scss +++ /dev/null @@ -1,18 +0,0 @@ -.triggers-container { - .label-container { - display: inline-block; - margin-left: 10px; - } -} - -.trigger-description { - max-width: 100px; -} - -.trigger-actions { - white-space: nowrap; -} - -.auto-devops-card { - margin-bottom: $gl-vert-padding; -} diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 801c9ea828f..ffe4d5dde9d 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -1043,7 +1043,7 @@ kbd { text-align: left; } .context-header .avatar-container { - flex: 0 0 40px; + flex: 0 0 32px; background-color: #333; } .context-header .sidebar-context-title { @@ -1376,18 +1376,6 @@ kbd { .nav-sidebar-inner-scroll > div.context-header a .avatar-container { font-weight: 400; flex: none; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); -} -.nav-sidebar-inner-scroll > div.context-header a .avatar-container.rect-avatar { - border-style: none; -} -.nav-sidebar-inner-scroll - > div.context-header - 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 { margin-bottom: 60px; @@ -1400,18 +1388,6 @@ kbd { .sidebar-top-level-items .context-header a .avatar-container { font-weight: 400; flex: none; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); -} -.sidebar-top-level-items .context-header a .avatar-container.rect-avatar { - border-style: none; -} -.sidebar-top-level-items - .context-header - 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.active @@ -1628,7 +1604,6 @@ svg.s16 { float: left; margin-right: 16px; border-radius: 50%; - border: 1px solid rgba(0, 0, 0, 0.08); } .avatar.s16, .avatar-container.s16 { @@ -1649,7 +1624,7 @@ svg.s16 { padding: 0; background: #222; overflow: hidden; - border-color: rgba(255, 255, 255, 0.1); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1); } .avatar.avatar-tile { border-radius: 0; @@ -1676,7 +1651,7 @@ svg.s16 { background-color: #232150; } .identicon.bg3 { - background-color: #f1f1ff; + background-color: #1a1a40; } .identicon.bg4 { background-color: #033464; @@ -1714,9 +1689,15 @@ svg.s16 { .rect-avatar.s16 { border-radius: 2px; } +.rect-avatar.s16 .avatar { + border-radius: 2px; +} .rect-avatar.s32 { border-radius: 4px; } +.rect-avatar.s32 .avatar { + border-radius: 4px; +} :root { color-scheme: dark; } @@ -1817,6 +1798,10 @@ body.gl-dark { background-color: #262626; border-right: 1px solid #303030; } +.avatar-container, +.avatar { + background: rgba(255, 255, 255, 0.04); +} .nav-sidebar li a { color: var(--gray-600); } @@ -1907,7 +1892,7 @@ body.gl-dark .header-search input::placeholder { body.gl-dark .header-search input:active::placeholder { color: #868686; } -body.gl-dark .header-search.is-not-active .keyboard-shortcut-helper { +body.gl-dark .header-search .keyboard-shortcut-helper { color: #fafafa; background-color: rgba(250, 250, 250, 0.2); } diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 43ca5a512d5..00ca98bfd27 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -1022,7 +1022,7 @@ kbd { text-align: left; } .context-header .avatar-container { - flex: 0 0 40px; + flex: 0 0 32px; background-color: #fff; } .context-header .sidebar-context-title { @@ -1355,18 +1355,6 @@ kbd { .nav-sidebar-inner-scroll > div.context-header a .avatar-container { font-weight: 400; flex: none; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); -} -.nav-sidebar-inner-scroll > div.context-header a .avatar-container.rect-avatar { - border-style: none; -} -.nav-sidebar-inner-scroll - > div.context-header - 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 { margin-bottom: 60px; @@ -1379,18 +1367,6 @@ kbd { .sidebar-top-level-items .context-header a .avatar-container { font-weight: 400; flex: none; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); -} -.sidebar-top-level-items .context-header a .avatar-container.rect-avatar { - border-style: none; -} -.sidebar-top-level-items - .context-header - 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.active @@ -1607,7 +1583,6 @@ svg.s16 { float: left; margin-right: 16px; border-radius: 50%; - border: 1px solid rgba(0, 0, 0, 0.08); } .avatar.s16, .avatar-container.s16 { @@ -1628,7 +1603,7 @@ svg.s16 { padding: 0; background: #fdfdfd; overflow: hidden; - border-color: rgba(0, 0, 0, 0.1); + box-shadow: inset 0 0 0 1px rgba(31, 31, 31, 0.1); } .avatar.avatar-tile { border-radius: 0; @@ -1693,9 +1668,15 @@ svg.s16 { .rect-avatar.s16 { border-radius: 2px; } +.rect-avatar.s16 .avatar { + border-radius: 2px; +} .rect-avatar.s32 { border-radius: 4px; } +.rect-avatar.s32 .avatar { + border-radius: 4px; +} .tab-width-8 { tab-size: 8; diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index 3090edfb123..c0e2d8d44d4 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -11,9 +11,6 @@ html { font-family: sans-serif; line-height: 1.15; } -header { - display: block; -} body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, @@ -31,8 +28,7 @@ hr { height: 0; overflow: visible; } -h1, -h3 { +h1 { margin-top: 0; margin-bottom: 0.25rem; } @@ -53,10 +49,6 @@ img { vertical-align: middle; border-style: none; } -svg { - overflow: hidden; - vertical-align: middle; -} label { display: inline-block; margin-bottom: 0.5rem; @@ -86,8 +78,7 @@ fieldset { [hidden] { display: none !important; } -h1, -h3 { +h1 { margin-bottom: 0.25rem; font-weight: 600; line-height: 1.2; @@ -96,9 +87,6 @@ h3 { h1 { font-size: 2.1875rem; } -h3 { - font-size: 1.53125rem; -} hr { margin-top: 0.5rem; margin-bottom: 0.5rem; @@ -132,13 +120,6 @@ hr { max-width: 1140px; } } -.row { - display: flex; - flex-wrap: wrap; - margin-right: -15px; - margin-left: -15px; -} -.col-md-6, .col-sm-12, .col { position: relative; @@ -151,29 +132,11 @@ hr { flex-grow: 1; max-width: 100%; } -.order-1 { - order: 1; -} -.order-12 { - order: 12; -} @media (min-width: 576px) { .col-sm-12 { flex: 0 0 100%; max-width: 100%; } - .order-sm-1 { - order: 1; - } - .order-sm-12 { - order: 12; - } -} -@media (min-width: 768px) { - .col-md-6 { - flex: 0 0 50%; - max-width: 50%; - } } .form-control { display: block; @@ -241,39 +204,18 @@ hr { fieldset:disabled a.btn { pointer-events: none; } -.navbar { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - padding: 0.25rem 0.5rem; -} -.navbar .container { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; -} -.fixed-top { - position: fixed; - top: 0; - right: 0; - left: 0; - z-index: 1030; -} .mt-3 { margin-top: 1rem !important; } .mb-3 { margin-bottom: 1rem !important; } +.text-nowrap { + white-space: nowrap !important; +} .text-center { text-align: center !important; } -.font-weight-normal { - font-weight: 400 !important; -} .gl-form-input, .gl-form-input.form-control { background-color: #fff; @@ -373,8 +315,7 @@ body { [type="submit"] { cursor: pointer; } -h1, -h3 { +h1 { margin-top: 20px; margin-bottom: 10px; } @@ -384,9 +325,6 @@ a { hr { overflow: hidden; } -svg { - vertical-align: baseline; -} .form-control { font-size: 0.875rem; } @@ -442,13 +380,6 @@ body.navless { border-color: #e3e3e3; color: #303030; } -.btn svg { - height: 15px; - width: 15px; -} -.btn svg:not(:last-child) { - margin-right: 5px; -} .light { color: #303030; } @@ -504,26 +435,6 @@ label.label-bold { .gl-show-field-errors .gl-field-hint { color: #303030; } -.navbar-empty { - justify-content: center; - height: var(--header-height, 48px); - background: #fff; - border-bottom: 1px solid #dbdbdb; -} -.navbar-empty .tanuki-logo, -.navbar-empty .brand-header-logo { - max-height: 100%; -} -.tanuki-logo .tanuki { - fill: #e24329; -} -.tanuki-logo .left-cheek, -.tanuki-logo .right-cheek { - fill: #fc6d26; -} -.tanuki-logo .chin { - fill: #fca326; -} input::-moz-placeholder { color: #868686; opacity: 1; @@ -534,9 +445,6 @@ input::-ms-input-placeholder { input:-ms-input-placeholder { color: #868686; } -svg { - fill: currentColor; -} .login-page .container { max-width: 960px; } @@ -569,6 +477,14 @@ svg { .login-page p { font-size: 13px; } +.login-page .borderless .login-box, +.login-page .borderless .omniauth-container { + box-shadow: none; +} +.login-page .borderless .g-recaptcha > div { + margin-left: auto; + margin-right: auto; +} .login-page .login-box, .login-page .omniauth-container { box-shadow: 0 0 0 1px #dbdbdb; @@ -732,61 +648,76 @@ svg { } } -.gl-border-solid { - border-style: solid; -} -.gl-border-gray-100 { - border-color: #dbdbdb; -} -.gl-border-1 { - border-width: 1px; -} -.gl-rounded-base { - border-radius: 0.25rem; -} .gl-text-green-600 { color: #217645; } .gl-text-red-500 { color: #dd2b0e; } -.gl-display-flex { - display: flex; -} .gl-display-block { display: block; } -.gl-align-items-center { - align-items: center; +.gl-w-10 { + width: 3.5rem; } -.gl-flex-wrap { - flex-wrap: wrap; +.gl-w-half { + width: 50%; +} +.gl-w-90p { + width: 90%; } .gl-w-full { width: 100%; } +@media (max-width: 575.98px) { + .gl-xs-w-full { + width: 100%; + } +} .gl-p-4 { padding: 0.75rem; } +.gl-pt-5 { + padding-top: 1rem; +} .gl-mt-2 { margin-top: 0.25rem; } .gl-mt-5 { margin-top: 1rem; } +.gl-mr-auto { + margin-right: auto; +} +.gl-mr-2 { + margin-right: 0.25rem; +} +.gl-mb-1 { + margin-bottom: 0.125rem; +} +.gl-mb-2 { + margin-bottom: 0.25rem; +} .gl-mb-3 { margin-bottom: 0.5rem; } .gl-mb-5 { margin-bottom: 1rem; } -@media (min-width: 576px) { - .gl-sm-mt-0 { - margin-top: 0; - } +.gl-ml-auto { + margin-left: auto; } -.gl-font-weight-bold { - font-weight: 600; +.gl-ml-2 { + margin-left: 0.25rem; +} +.gl-text-center { + text-align: center; +} +.gl-font-size-h2 { + font-size: 1.1875rem; +} +.gl-font-weight-normal { + font-weight: 400; } @import "startup/cloaking"; diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index e6e736ef47c..eeb4604f32a 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -98,6 +98,8 @@ $white-light: #2b2b2b; $white-normal: #333; $white-dark: #444; +$theme-indigo-50: #1a1a40; + $border-color: #4f4f4f; $nav-active-bg: rgba(255, 255, 255, 0.08); diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index 34bb4925249..92740aaf89e 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -48,6 +48,17 @@ border-right: 1px solid $gray-50; } +.gl-avatar:not(.gl-avatar-identicon), +.avatar-container, +.avatar { + background: rgba($gray-950, 0.04); +} + +.gl-avatar { + @include gl-border-none; + box-shadow: inset 0 0 0 1px rgba($gray-950, $gl-avatar-border-opacity); +} + .nav-sidebar { li { a { @@ -149,3 +160,8 @@ body.gl-dark { background-color: $gray-200; } } + +.timeline-entry.internal-note:not(.note-form) { + // soften on darkmode + background-color: mix($gray-50, $orange-50, 75%); +} diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index 2b6221a6c87..042e21cebd6 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -176,11 +176,9 @@ } } - &.is-not-active { - .keyboard-shortcut-helper { - color: $search-and-nav-links; - background-color: rgba($search-and-nav-links, 0.2); - } + .keyboard-shortcut-helper { + color: $search-and-nav-links; + background-color: rgba($search-and-nav-links, 0.2); } } diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb index 554e057ca83..cf85e4b3d33 100644 --- a/app/channels/awareness_channel.rb +++ b/app/channels/awareness_channel.rb @@ -66,6 +66,7 @@ class AwarenessChannel < ApplicationCable::Channel # rubocop:disable Gitlab/Name { id: user.id, name: user.name, + username: user.username, avatar_url: user.avatar_url(size: 36), last_activity: last_activity, last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words( diff --git a/app/components/diffs/overflow_warning_component.html.haml b/app/components/diffs/overflow_warning_component.html.haml index b334bfbcd89..551d995cb22 100644 --- a/app/components/diffs/overflow_warning_component.html.haml +++ b/app/components/diffs/overflow_warning_component.html.haml @@ -1,6 +1,6 @@ = render Pajamas::AlertComponent.new(title: _('Too many changes to show.'), variant: :warning, - alert_options: { class: 'gl-mb-5' }) do |c| + alert_options: { class: 'gl-mb-5', data: { testid: "too-many-changes-alert" } }) do |c| = c.body do = message diff --git a/app/components/pajamas/avatar_component.html.haml b/app/components/pajamas/avatar_component.html.haml new file mode 100644 index 00000000000..502f673fe2c --- /dev/null +++ b/app/components/pajamas/avatar_component.html.haml @@ -0,0 +1,12 @@ +- if src + = image_tag src, + srcset: srcset, + alt: alt, + class: avatar_classes, + height: @size, + width: @size, + loading: "lazy", + **@avatar_options +- else + %div{ @avatar_options, alt: alt, class: avatar_classes } + = initial diff --git a/app/components/pajamas/avatar_component.rb b/app/components/pajamas/avatar_component.rb new file mode 100644 index 00000000000..073968e0491 --- /dev/null +++ b/app/components/pajamas/avatar_component.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Pajamas + class AvatarComponent < Pajamas::Component + include Gitlab::Utils::StrongMemoize + + # @param record [User, Project, Group] + # @param alt [String] text for the alt tag + # @param class [String] custom CSS class(es) + # @param size [Integer] size in pixel + # @param [Hash] avatar_options + def initialize(record, alt: nil, class: "", size: 64, avatar_options: {}) + @record = record + @alt = alt + @class = binding.local_variable_get(:class) + @size = filter_attribute(size.to_i, SIZE_OPTIONS, default: 64) + @avatar_options = avatar_options + end + + private + + SIZE_OPTIONS = [16, 24, 32, 48, 64, 96].freeze + + def avatar_classes + classes = ["gl-avatar", "gl-avatar-s#{@size}", @class] + classes.push("gl-avatar-circle") if @record.is_a?(User) + + unless src + classes.push("gl-avatar-identicon") + classes.push("gl-avatar-identicon-bg#{((@record.id || 0) % 7) + 1}") + end + + classes.join(' ') + end + + def src + strong_memoize(:src) do + if @record.is_a?(User) + # Users show a gravatar instead of an identicon. Also avatars of + # blocked users are only shown if the current_user is an admin. + # To not duplicate this logic, we are using existing helpers here. + current_user = begin + helpers.current_user + rescue StandardError + nil + end + helpers.avatar_icon_for_user(@record, @size, current_user: current_user) + elsif @record.try(:avatar_url) + "#{@record.avatar_url}?width=#{@size}" + end + end + end + + def srcset + return unless src + + retina_src = src.gsub(/(?<=width=)#{@size}+/, (@size * 2).to_s) + "#{src} 1x, #{retina_src} 2x" + end + + def alt + @alt || @record.name + end + + def initial + @record.name[0, 1].upcase + end + end +end diff --git a/app/components/pajamas/button_component.html.haml b/app/components/pajamas/button_component.html.haml index 8ce7d9e0315..5cf57deb7f1 100644 --- a/app/components/pajamas/button_component.html.haml +++ b/app/components/pajamas/button_component.html.haml @@ -1,4 +1,4 @@ -= content_tag tag, {**@button_options, **base_attributes, class: button_class, href: @href, target: @target } do += content_for :pajamas_button_content, flush: true do - if @loading = gl_loading_icon(inline: true, css_class: 'gl-button-icon gl-button-loading-indicator') - if @icon && (!@loading || content) @@ -6,3 +6,10 @@ - if content %span.gl-button-text{ class: @button_text_classes } = content + +- if link? + = link_to @href, { **@button_options, **base_attributes, class: button_class, target: @target, method: @method } do + = content_for :pajamas_button_content +- else + = content_tag 'button', { **@button_options, **base_attributes, class: button_class } do + = content_for :pajamas_button_content diff --git a/app/components/pajamas/button_component.rb b/app/components/pajamas/button_component.rb index c6193d1ae05..4233e446d5b 100644 --- a/app/components/pajamas/button_component.rb +++ b/app/components/pajamas/button_component.rb @@ -13,6 +13,7 @@ module Pajamas # @param [String] icon # @param [String] href # @param [String] target + # @param [Symbol] method # @param [Hash] button_options # @param [String] button_text_classes # @param [String] icon_classes @@ -28,6 +29,7 @@ module Pajamas icon: nil, href: nil, target: nil, + method: nil, button_options: {}, button_text_classes: nil, icon_classes: nil @@ -43,6 +45,7 @@ module Pajamas @icon = icon @href = href @target = filter_attribute(target, TARGET_OPTIONS) + @method = filter_attribute(method, METHOD_OPTIONS) @button_options = button_options @button_text_classes = button_text_classes @icon_classes = icon_classes @@ -75,6 +78,7 @@ module Pajamas SIZE_OPTIONS = [:small, :medium].freeze TYPE_OPTIONS = [:button, :reset, :submit].freeze TARGET_OPTIONS = %w[_self _blank _parent _top].freeze + METHOD_OPTIONS = [:get, :post, :put, :delete, :patch].freeze CATEGORY_CLASSES = { primary: '', @@ -101,8 +105,8 @@ module Pajamas delegate :sprite_icon, to: :helpers delegate :gl_loading_icon, to: :helpers - def tag - @href ? 'a' : 'button' + def link? + @href.present? end def base_attributes diff --git a/app/components/pajamas/checkbox_component.rb b/app/components/pajamas/checkbox_component.rb index ae78d0453f8..d9987b7653c 100644 --- a/app/components/pajamas/checkbox_component.rb +++ b/app/components/pajamas/checkbox_component.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true # Renders a Pajamas compliant checkbox element -# Must be used in an instance of `ActionView::Helpers::FormBuilder` +# An instance of `ActionView::Helpers::FormBuilder` must be passed as the `form` argument. +# The easiest way to use this component is by using the `gitlab_ui_checkbox_component` helper. +# See https://docs.gitlab.com/ee/development/fe_guide/haml.html#gitlab_ui_checkbox_component +# To use a checkbox without an instance of `ActionView::Helpers::FormBuilder` use `CheckboxTagComponent`. module Pajamas class CheckboxComponent < Pajamas::Component include Pajamas::Concerns::CheckboxRadioLabelWithHelpText @@ -31,6 +34,8 @@ module Pajamas @value = checked_value if checkbox_options[:multiple] end + private + attr_reader( :form, :method, @@ -43,8 +48,6 @@ module Pajamas :value ) - private - def label_content label? ? label : label_argument end diff --git a/app/components/pajamas/checkbox_tag_component.html.haml b/app/components/pajamas/checkbox_tag_component.html.haml new file mode 100644 index 00000000000..ad02c966fad --- /dev/null +++ b/app/components/pajamas/checkbox_tag_component.html.haml @@ -0,0 +1,6 @@ +.gl-form-checkbox.custom-control.custom-checkbox + = check_box_tag(name, + value, + checked, + formatted_input_options) + = render_label_tag_with_help_text diff --git a/app/components/pajamas/checkbox_tag_component.rb b/app/components/pajamas/checkbox_tag_component.rb new file mode 100644 index 00000000000..45e88588059 --- /dev/null +++ b/app/components/pajamas/checkbox_tag_component.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Renders a Pajamas compliant checkbox element +module Pajamas + class CheckboxTagComponent < Pajamas::Component + include Pajamas::Concerns::CheckboxRadioLabelWithHelpText + include Pajamas::Concerns::CheckboxRadioOptions + + renders_one :label + renders_one :help_text + + def initialize( + name:, + label_options: {}, + checkbox_options: {}, + value: '1', + checked: false + ) + @name = name + @label_options = label_options + @input_options = checkbox_options + @value = value + @checked = checked + end + + private + + attr_reader( + :name, + :label_options, + :input_options, + :value, + :checked + ) + + def label_content + label + end + + def help_text_content + help_text + end + end +end diff --git a/app/components/pajamas/concerns/checkbox_radio_label_with_help_text.rb b/app/components/pajamas/concerns/checkbox_radio_label_with_help_text.rb index 4ece904fb85..298ed200101 100644 --- a/app/components/pajamas/concerns/checkbox_radio_label_with_help_text.rb +++ b/app/components/pajamas/concerns/checkbox_radio_label_with_help_text.rb @@ -7,6 +7,10 @@ module Pajamas form.label(method, formatted_label_options) { label_entry } end + def render_label_tag_with_help_text + label_tag(name, formatted_label_options) { label_entry } + end + private def label_entry diff --git a/app/components/pajamas/radio_component.rb b/app/components/pajamas/radio_component.rb index 52a761b9d7d..7a3d95c8565 100644 --- a/app/components/pajamas/radio_component.rb +++ b/app/components/pajamas/radio_component.rb @@ -28,6 +28,8 @@ module Pajamas @value = value end + private + attr_reader( :form, :method, @@ -38,8 +40,6 @@ module Pajamas :value ) - private - def label_content label? ? label : label_argument end diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index e05e87ffd89..6f21b123eb0 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -13,6 +13,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController before_action :disable_query_limiting, only: [:usage_data] + before_action do + push_frontend_feature_flag(:ci_variable_settings_graphql) + end + feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned :general, :reporting, :metrics_and_profiling, :network, :preferences, :update, :reset_health_check_token diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index a6a21cf3649..b0d7c8cb8f2 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -11,7 +11,6 @@ class Admin::ApplicationsController < Admin::ApplicationController def index applications = ApplicationsFinder.new.execute @applications = Kaminari.paginate_array(applications).page(params[:page]) - @application_counts = OauthAccessToken.distinct_resource_owner_counts(@applications) end def show diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index bf573d45852..a53e832329f 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -58,7 +58,6 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController def broadcast_message_params params.require(:broadcast_message).permit(%i( - color theme ends_at message diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb index d4b7d750759..7d643435ddb 100644 --- a/app/controllers/admin/ci/variables_controller.rb +++ b/app/controllers/admin/ci/variables_controller.rb @@ -31,7 +31,7 @@ class Admin::Ci::VariablesController < Admin::ApplicationController def render_instance_variables render status: :ok, - json: { + json: { variables: Ci::InstanceVariableSerializer.new.represent(variables) } end @@ -41,7 +41,7 @@ class Admin::Ci::VariablesController < Admin::ApplicationController end def variables_params - params.permit(variables_attributes: [*variable_params_attributes]) + params.permit(variables_attributes: Array(variable_params_attributes)) end def variable_params_attributes diff --git a/app/controllers/admin/dev_ops_report_controller.rb b/app/controllers/admin/dev_ops_report_controller.rb index 47e3337aed7..71ee19ddf39 100644 --- a/app/controllers/admin/dev_ops_report_controller.rb +++ b/app/controllers/admin/dev_ops_report_controller.rb @@ -1,11 +1,16 @@ # frozen_string_literal: true class Admin::DevOpsReportController < Admin::ApplicationController - include RedisTracking + include ProductAnalyticsTracking helper_method :show_adoption? - track_redis_hll_event :show, name: 'i_analytics_dev_ops_score', if: -> { should_track_devops_score? } + track_custom_event :show, + name: 'i_analytics_dev_ops_score', + action: 'perform_analytics_usage_action', + label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly', + destinations: %i[redis_hll snowplow], + conditions: -> { should_track_devops_score? } feature_category :devops_reports @@ -24,6 +29,14 @@ class Admin::DevOpsReportController < Admin::ApplicationController def should_track_devops_score? true end + + def tracking_namespace_source + nil + end + + def tracking_project_source + nil + end end Admin::DevOpsReportController.prepend_mod_with('Admin::DevOpsReportController') diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 6fd1e9bb70e..3f3c3581555 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -48,8 +48,8 @@ class Admin::ProjectsController < Admin::ApplicationController flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name } redirect_to admin_projects_path, status: :found - rescue Projects::DestroyService::DestroyError => ex - redirect_to admin_projects_path, status: :found, alert: ex.message + rescue Projects::DestroyService::DestroyError => e + redirect_to admin_projects_path, status: :found, alert: e.message end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb index 0165c6471db..7dbae565d07 100644 --- a/app/controllers/admin/runner_projects_controller.rb +++ b/app/controllers/admin/runner_projects_controller.rb @@ -9,7 +9,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController def create @runner = Ci::Runner.find(params[:runner_project][:runner_id]) - if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute + if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute.success? redirect_to edit_admin_runner_url(@runner), notice: s_('Runners|Runner assigned to project.') else redirect_to edit_admin_runner_url(@runner), alert: 'Failed adding runner to project' diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb index f81b02ad31f..41f95addc66 100644 --- a/app/controllers/admin/system_info_controller.rb +++ b/app/controllers/admin/system_info_controller.rb @@ -37,8 +37,16 @@ class Admin::SystemInfoController < Admin::ApplicationController ].freeze def show - @cpus = Vmstat.cpu rescue nil - @memory = Vmstat.memory rescue nil + @cpus = begin + Vmstat.cpu + rescue StandardError + nil + end + @memory = begin + Vmstat.memory + rescue StandardError + nil + end mounts = Sys::Filesystem.mounts @disks = [] @@ -52,9 +60,9 @@ class Admin::SystemInfoController < Admin::ApplicationController disk = Sys::Filesystem.stat(mount.mount_point) @disks.push({ bytes_total: disk.bytes_total, - bytes_used: disk.bytes_used, - disk_name: mount.name, - mount_path: disk.path + bytes_used: disk.bytes_used, + disk_name: mount.name, + mount_path: disk.path }) rescue Sys::Filesystem::Error end diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb index b451928e591..69bcfdf4791 100644 --- a/app/controllers/admin/topics_controller.rb +++ b/app/controllers/admin/topics_controller.rb @@ -45,6 +45,22 @@ class Admin::TopicsController < Admin::ApplicationController notice: _('Topic %{topic_name} was successfully removed.') % { topic_name: @topic.title_or_name } end + def merge + source_topic = Projects::Topic.find(merge_params[:source_topic_id]) + target_topic = Projects::Topic.find(merge_params[:target_topic_id]) + + begin + ::Topics::MergeService.new(source_topic, target_topic).execute + rescue ArgumentError => e + return render status: :bad_request, json: { type: :alert, message: e.message } + end + + message = _('Topic %{source_topic} was successfully merged into topic %{target_topic}.') + redirect_to admin_topics_path, + status: :found, + notice: message % { source_topic: source_topic.name, target_topic: target_topic.name } + end + private def topic @@ -63,4 +79,8 @@ class Admin::TopicsController < Admin::ApplicationController :title ] end + + def merge_params + params.permit([:source_topic_id, :target_topic_id]) + end end diff --git a/app/controllers/admin/usage_trends_controller.rb b/app/controllers/admin/usage_trends_controller.rb index 2cede1aec05..082b38ac3a8 100644 --- a/app/controllers/admin/usage_trends_controller.rb +++ b/app/controllers/admin/usage_trends_controller.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class Admin::UsageTrendsController < Admin::ApplicationController - include RedisTracking + include ProductAnalyticsTracking - track_redis_hll_event :index, name: 'i_analytics_instance_statistics' + track_custom_event :index, + name: 'i_analytics_instance_statistics', + action: 'perform_analytics_usage_action', + label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly', + destinations: %i[redis_hll snowplow] feature_category :devops_reports @@ -11,4 +15,12 @@ class Admin::UsageTrendsController < Admin::ApplicationController def index end + + def tracking_namespace_source + @group + end + + def tracking_project_source + nil + end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 874eb8985fb..5cc0c8f3970 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -88,7 +88,7 @@ class Admin::UsersController < Admin::ApplicationController result = Users::RejectService.new(current_user).execute(user) if result[:status] == :success - redirect_to admin_users_path, status: :found, notice: _("You've rejected %{user}" % { user: user.name }) + redirect_back_or_admin_user(notice: _("You've rejected %{user}" % { user: user.name })) else redirect_back_or_admin_user(alert: result[:message]) end diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 6d1ffc1f2e8..88592efcec7 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -5,7 +5,6 @@ class AutocompleteController < ApplicationController skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches] before_action :check_search_rate_limit!, only: [:users, :projects] - before_action :authorize_admin_project, only: :deploy_keys_with_owners feature_category :users, [:users, :user] feature_category :projects, [:projects] @@ -61,7 +60,9 @@ class AutocompleteController < ApplicationController end def deploy_keys_with_owners - deploy_keys = DeployKey.with_write_access_for_project(project) + deploy_keys = Autocomplete::DeployKeysWithWriteAccessFinder + .new(current_user, project) + .execute render json: DeployKeys::BasicDeployKeySerializer.new.represent( deploy_keys, { with_owner: true, user: current_user } @@ -70,10 +71,6 @@ class AutocompleteController < ApplicationController private - def authorize_admin_project - render_403 unless Ability.allowed?(current_user, :admin_project, project) - end - def project @project ||= Autocomplete::ProjectFinder .new(current_user, params) diff --git a/app/controllers/concerns/accepts_pending_invitations.rb b/app/controllers/concerns/accepts_pending_invitations.rb index 5601b7a7f79..53dec698fa0 100644 --- a/app/controllers/concerns/accepts_pending_invitations.rb +++ b/app/controllers/concerns/accepts_pending_invitations.rb @@ -3,12 +3,12 @@ module AcceptsPendingInvitations extend ActiveSupport::Concern - def accept_pending_invitations - return unless resource.active_for_authentication? + def accept_pending_invitations(user: resource) + return unless user.active_for_authentication? - if resource.pending_invitations.load.any? - resource.accept_pending_invitations! - clear_stored_location_for_resource + if user.pending_invitations.load.any? + user.accept_pending_invitations! + clear_stored_location_for(user: user) after_pending_invitations_hook end end @@ -17,8 +17,8 @@ module AcceptsPendingInvitations # no-op end - def clear_stored_location_for_resource - session_key = stored_location_key_for(resource) + def clear_stored_location_for(user:) + session_key = stored_location_key_for(user) session.delete(session_key) end diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 0fb77e2aaf4..b6ba1b13cc3 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -98,8 +98,7 @@ module CreatesCommit project_new_merge_request_path( @project_to_commit_into, merge_request: { - source_project_id: @project_to_commit_into.id, - target_project_id: target_project.id, + target_project_id: @project_to_commit_into.default_merge_request_target.id, source_branch: @branch_name, target_branch: @start_branch } diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index a5e49b1b16a..f1d80e37674 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -171,7 +171,7 @@ module IssuableActions discussions = Discussion.build_collection(notes, issuable) if issuable.is_a?(MergeRequest) - render_cached(discussions, with: discussion_serializer, cache_context: -> (_) { discussion_cache_context }, context: self) + render_mr_discussions(discussions, discussion_serializer, discussion_cache_context) elsif issuable.is_a?(Issue) render json: discussion_serializer.represent(discussions, context: self) if stale?(etag: [discussion_cache_context, discussions]) else @@ -182,6 +182,20 @@ module IssuableActions private + def render_mr_discussions(discussions, serializer, cache_context) + return unless stale?(etag: [cache_context, discussions]) + + if Feature.enabled?(:disabled_mr_discussions_redis_cache, project) + render json: serializer.represent(discussions, context: self) + else + render_cached_discussions(discussions, serializer, cache_context) + end + end + + def render_cached_discussions(discussions, serializer, cache_context) + render_cached(discussions, with: serializer, cache_context: -> (_) { cache_context }, context: self) + end + def paginated_discussions return if params[:per_page].blank? return if issuable.instance_of?(MergeRequest) && Feature.disabled?(:paginated_mr_discussions, project) diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 928c617471b..b595c3c6790 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -217,7 +217,8 @@ module NotesActions :note, :line_code, # LegacyDiffNote :position, # DiffNote - :confidential + :confidential, + :internal ).tap do |create_params| create_params.merge!( params.permit(:merge_request_diff_head_sha, :in_reply_to_discussion_id) diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb index dc7ba8295b9..260b433cc6f 100644 --- a/app/controllers/concerns/product_analytics_tracking.rb +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -13,6 +13,14 @@ module ProductAnalyticsTracking route_events_to(destinations, name, &block) end end + + def track_custom_event(*controller_actions, name:, conditions: nil, action:, label:, destinations: [:redis_hll], &block) + custom_conditions = [:trackable_html_request?, *conditions] + + after_action only: controller_actions, if: custom_conditions do + route_custom_events_to(destinations, name, action, label, &block) + end + end end private @@ -25,13 +33,40 @@ module ProductAnalyticsTracking end end + def route_custom_events_to(destinations, name, action, label, &block) + track_unique_redis_hll_event(name, &block) if destinations.include?(:redis_hll) + + return unless destinations.include?(:snowplow) && event_enabled?(name) + + optional_arguments = { + namespace: tracking_namespace_source, + project: tracking_project_source + }.compact + + Gitlab::Tracking.event( + self.class.to_s, + action, + user: current_user, + property: name, + label: label, + **optional_arguments + ) + end + def event_enabled?(event) events_to_ff = { g_analytics_valuestream: :route_hll_to_snowplow, i_search_paid: :route_hll_to_snowplow_phase2, i_search_total: :route_hll_to_snowplow_phase2, - i_search_advanced: :route_hll_to_snowplow_phase2 + i_search_advanced: :route_hll_to_snowplow_phase2, + i_ecosystem_jira_service_list_issues: :route_hll_to_snowplow_phase2, + users_viewing_analytics_group_devops_adoption: :route_hll_to_snowplow_phase2, + i_analytics_dev_ops_adoption: :route_hll_to_snowplow_phase2, + i_analytics_dev_ops_score: :route_hll_to_snowplow_phase2, + p_analytics_merge_request: :route_hll_to_snowplow_phase2, + i_analytics_instance_statistics: :route_hll_to_snowplow_phase2, + g_analytics_contribution: :route_hll_to_snowplow_phase2 } Feature.enabled?(events_to_ff[event.to_sym], tracking_namespace_source) diff --git a/app/controllers/concerns/redis_tracking.rb b/app/controllers/concerns/redis_tracking.rb index c1135d2f759..445e72b8266 100644 --- a/app/controllers/concerns/redis_tracking.rb +++ b/app/controllers/concerns/redis_tracking.rb @@ -29,7 +29,7 @@ module RedisTracking private def track_unique_redis_hll_event(event_name, &block) - custom_id = block_given? ? yield(self) : nil + custom_id = block ? yield(self) : nil unique_id = custom_id || visitor_id diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index f914e804e18..e98d36854f1 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -143,10 +143,8 @@ module UploadsActions end def bypass_auth_checks_on_uploads? - if ::Feature.enabled?(:enforce_auth_checks_on_uploads, target_project) - if target_project && !target_project.public? && target_project.enforce_auth_checks_on_uploads? - return false - end + if target_project && !target_project.public? && target_project.enforce_auth_checks_on_uploads? + return false end action_name == 'show' && embeddable? diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 0fbceb43be1..e64d838b7d1 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -10,8 +10,8 @@ class Groups::BoardsController < Groups::ApplicationController push_frontend_feature_flag(:board_multi_select, group) push_frontend_feature_flag(:realtime_labels, group) experiment(:prominent_create_board_btn, subject: current_user) do |e| - e.control { } - e.candidate { } + e.control {} + e.candidate {} end.run end diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index b1afac1f1c7..e164a834519 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -10,6 +10,9 @@ module Groups before_action :define_variables, only: [:show] before_action :push_licensed_features, only: [:show] before_action :assign_variables_to_gon, only: [:show] + before_action do + push_frontend_feature_flag(:ci_variable_settings_graphql, @group) + end feature_category :continuous_integration urgency :low diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 1e23db9f32b..220b0b4509c 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -46,7 +46,7 @@ module Groups end def group_variables_params - params.permit(variables_attributes: [*variable_params_attributes]) + params.permit(variables_attributes: Array(variable_params_attributes)) end def variable_params_attributes diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 327b4832f31..32b187c3260 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -34,6 +34,10 @@ class GroupsController < Groups::ApplicationController before_action :track_experiment_event, only: [:new] + before_action only: :issues do + push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?) + end + helper_method :captcha_required? skip_cross_project_access_check :index, :new, :create, :edit, :update, diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index 2d607fb7ff7..893c0b6ac54 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -47,7 +47,14 @@ class Import::BulkImportsController < ApplicationController end def create - responses = create_params.map { |entry| ::BulkImports::CreateService.new(current_user, entry, credentials).execute } + responses = create_params.map do |entry| + if entry[:destination_name] + entry[:destination_slug] ||= entry[:destination_name] + entry.delete(:destination_name) + end + + ::BulkImports::CreateService.new(current_user, entry, credentials).execute + end render json: responses.map { |response| { success: response.success?, id: response.payload[:id], message: response.message } } end @@ -100,6 +107,7 @@ class Import::BulkImportsController < ApplicationController source_type source_full_path destination_name + destination_slug destination_namespace ] end diff --git a/app/controllers/oauth/token_info_controller.rb b/app/controllers/oauth/token_info_controller.rb index 789356f4410..626184150bd 100644 --- a/app/controllers/oauth/token_info_controller.rb +++ b/app/controllers/oauth/token_info_controller.rb @@ -9,7 +9,7 @@ class Oauth::TokenInfoController < Doorkeeper::TokenInfoController # maintain backwards compatibility render json: token_json.merge( - 'scopes' => token_json[:scope], + 'scopes' => token_json[:scope], 'expires_in_seconds' => token_json[:expires_in] ), status: :ok else diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 45decccfc36..817f272d458 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -6,6 +6,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include AuthHelper include InitializesCurrentUserMode include KnownSignIn + include AcceptsPendingInvitations after_action :verify_known_sign_in @@ -25,7 +26,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController # the number of failed sign in attempts def failure if params[:username].present? && AuthHelper.form_based_provider?(failed_strategy.name) - user = User.by_login(params[:username]) + user = User.find_by_login(params[:username]) user&.increment_failed_attempts! log_failed_login(params[:username], failed_strategy.name) @@ -159,6 +160,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def sign_in_user_flow(auth_user_class) auth_user = build_auth_user(auth_user_class) + new_user = auth_user.new? user = auth_user.find_and_update! if auth_user.valid_sign_in? @@ -178,6 +180,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.') end + accept_pending_invitations(user: user) if new_user store_after_sign_up_path_for_user if intent_to_register? sign_in_and_redirect(user, event: :authentication) end diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 1a8908e8571..07d786ab060 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -3,10 +3,6 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController feature_category :authentication_and_authorization - before_action do - push_frontend_feature_flag(:personal_access_tokens_scoped_to_projects, current_user) - end - def index set_index_vars scopes = params[:scopes].split(',').map(&:squish).select(&:present?).map(&:to_sym) unless params[:scopes].nil? @@ -62,7 +58,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController end def active_personal_access_tokens - tokens = finder(state: 'active', sort: 'expires_at_asc').execute + tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute if Feature.enabled?('access_token_pagination') tokens = tokens.page(page) diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 2e71b4801ed..0b7d4626c6d 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -234,7 +234,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def groups_notification(groups) group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence - leave_group_links = groups.map { |group| view_context.link_to (s_("leave %{group_name}") % { group_name: group.full_name }), leave_group_members_path(group), remote: false, method: :delete}.to_sentence + leave_group_links = groups.map { |group| view_context.link_to (s_("leave %{group_name}") % { group_name: group.full_name }), leave_group_members_path(group), remote: false, method: :delete }.to_sentence s_(%{The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}.}) .html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe } diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb index 82fff287c4a..f3283c88740 100644 --- a/app/controllers/projects/alerting/notifications_controller.rb +++ b/app/controllers/projects/alerting/notifications_controller.rb @@ -13,8 +13,6 @@ module Projects prepend_before_action :repository, :project_without_auth feature_category :incident_management - # Goal is to increase the urgency to medium. - # See https://gitlab.com/gitlab-org/gitlab/-/issues/361310. urgency :low, [:create] def create diff --git a/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb b/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb index 7b38c069a60..ab2cf3abdde 100644 --- a/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb +++ b/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb @@ -2,6 +2,7 @@ class Projects::Analytics::CycleAnalytics::StagesController < Projects::ApplicationController include ::Analytics::CycleAnalytics::StageActions + include Gitlab::Utils::StrongMemoize extend ::Gitlab::Utils::Override respond_to :json @@ -10,6 +11,7 @@ class Projects::Analytics::CycleAnalytics::StagesController < Projects::Applicat before_action :authorize_read_cycle_analytics! before_action :only_default_value_stream_is_allowed! + before_action :authorize_stage!, only: [:median, :count, :average, :records] urgency :low @@ -25,7 +27,26 @@ class Projects::Analytics::CycleAnalytics::StagesController < Projects::Applicat Analytics::CycleAnalytics::ProjectValueStream end + override :cycle_analytics_configuration + def cycle_analytics_configuration(stages) + super(stages.select { |stage| permitted_stage?(stage) }) + end + def only_default_value_stream_is_allowed! render_404 if params[:value_stream_id] != Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME end + + def permitted_stage?(stage) + permissions[stage.name.to_sym] # name matches the permission key (only when default stages are used) + end + + def permissions + strong_memoize(:permissions) do + Gitlab::CycleAnalytics::Permissions.new(user: current_user, project: parent).get + end + end + + def authorize_stage! + render_403 unless permitted_stage?(stage) + end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 97aae56c4ec..f5188e28b81 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(:highlight_js, @project) + push_frontend_feature_flag(:file_line_blame, @project) 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 36986a714fb..82b35a22669 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -10,8 +10,8 @@ class Projects::BoardsController < Projects::ApplicationController push_frontend_feature_flag(:board_multi_select, project) push_frontend_feature_flag(:realtime_labels, project&.group) experiment(:prominent_create_board_btn, subject: current_user) do |e| - e.control { } - e.candidate { } + e.control {} + e.candidate {} end.run end diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 85e258b62e8..84e5d59a2c3 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -4,7 +4,6 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController before_action :check_can_collaborate! before_action do push_frontend_feature_flag(:schema_linting, @project) - push_frontend_feature_flag(:simulate_pipeline, @project) end feature_category :pipeline_authoring diff --git a/app/controllers/projects/ci/secure_files_controller.rb b/app/controllers/projects/ci/secure_files_controller.rb deleted file mode 100644 index 59ddca19081..00000000000 --- a/app/controllers/projects/ci/secure_files_controller.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class Projects::Ci::SecureFilesController < Projects::ApplicationController - before_action :authorize_read_secure_files! - - feature_category :pipeline_authoring - - def show - render_404 unless Feature.enabled?(:ci_secure_files, project) - end -end diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 09a06aaed8c..d7fd65f02a8 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -88,7 +88,7 @@ class Projects::CompareController < Projects::ApplicationController # target == start_ref == from def target_project strong_memoize(:target_project) do - next source_project unless compare_params.key?(:from_project_id) + next source_project.default_merge_request_target unless compare_params.key?(:from_project_id) next source_project if compare_params[:from_project_id].to_i == source_project.id target_project = target_projects(source_project).find_by_id(compare_params[:from_project_id]) diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb index 1d1fe91ad70..16392775c09 100644 --- a/app/controllers/projects/feature_flags_controller.rb +++ b/app/controllers/projects/feature_flags_controller.rb @@ -111,9 +111,9 @@ class Projects::FeatureFlagsController < Projects::ApplicationController .permit(:name, :description, :active, scopes_attributes: [:id, :environment_scope, :active, :_destroy, strategies: [:name, parameters: [:groupId, :percentage, :userIds]]], - strategies_attributes: [:id, :name, :user_list_id, :_destroy, - parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness], - scopes_attributes: [:id, :environment_scope, :_destroy]]) + strategies_attributes: [:id, :name, :user_list_id, :_destroy, + parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness], + scopes_attributes: [:id, :environment_scope, :_destroy]]) end def feature_flag_json(feature_flag) diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb index 050b26a40c7..d1eb86c5e49 100644 --- a/app/controllers/projects/google_cloud/base_controller.rb +++ b/app/controllers/projects/google_cloud/base_controller.rb @@ -80,4 +80,16 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController Gitlab::Tracking.event('Projects::GoogleCloud', action, **options) end + + def gcp_projects + google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + google_api_client.list_projects + end + + def refs + params = { per_page: 50 } + branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) + tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true) + (branches + tags).map(&:name) + end end diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb index fa672058247..8d252c35031 100644 --- a/app/controllers/projects/google_cloud/configuration_controller.rb +++ b/app/controllers/projects/google_cloud/configuration_controller.rb @@ -4,7 +4,6 @@ module Projects module GoogleCloud class ConfigurationController < Projects::GoogleCloud::BaseController def index - @google_cloud_path = project_google_cloud_configuration_path(project) js_data = { configurationUrl: project_google_cloud_configuration_path(project), deploymentsUrl: project_google_cloud_deployments_path(project), diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb index 711409e7550..7b1cf6e5ce1 100644 --- a/app/controllers/projects/google_cloud/databases_controller.rb +++ b/app/controllers/projects/google_cloud/databases_controller.rb @@ -4,7 +4,6 @@ module Projects module GoogleCloud class DatabasesController < Projects::GoogleCloud::BaseController def index - @google_cloud_path = project_google_cloud_configuration_path(project) js_data = { configurationUrl: project_google_cloud_configuration_path(project), deploymentsUrl: project_google_cloud_deployments_path(project), diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb index 4aa17b36fad..1ac4697a63f 100644 --- a/app/controllers/projects/google_cloud/deployments_controller.rb +++ b/app/controllers/projects/google_cloud/deployments_controller.rb @@ -4,7 +4,6 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base before_action :validate_gcp_token! def index - @google_cloud_path = project_google_cloud_configuration_path(project) js_data = { configurationUrl: project_google_cloud_configuration_path(project), deploymentsUrl: project_google_cloud_deployments_path(project), @@ -40,9 +39,9 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base redirect_to project_new_merge_request_path(project, merge_request: cloud_run_mr_params) end end - rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error - track_event('deployments#cloud_run', 'error_gcp', error) - flash[:warning] = _('Google Cloud Error - %{error}') % { error: error } + rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e + track_event('deployments#cloud_run', 'error_gcp', e) + flash[:warning] = _('Google Cloud Error - %{error}') % { error: e } redirect_to project_google_cloud_deployments_path(project) end diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb index 3fbe9a96284..39f33624804 100644 --- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb +++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb @@ -9,13 +9,7 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC GCP_REGION_CI_VAR_KEY = 'GCP_REGION' def index - @google_cloud_path = project_google_cloud_configuration_path(project) - params = { per_page: 50 } - branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) - tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true) - refs = (branches + tags).map(&:name) js_data = { - screen: 'gcp_regions_form', availableRegions: AVAILABLE_REGIONS, refs: refs, cancelPath: project_google_cloud_configuration_path(project) diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb index dbd83be19db..7f25054177e 100644 --- a/app/controllers/projects/google_cloud/service_accounts_controller.rb +++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb @@ -4,22 +4,12 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: before_action :validate_gcp_token! def index - @google_cloud_path = project_google_cloud_configuration_path(project) - google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) - gcp_projects = google_api_client.list_projects - if gcp_projects.empty? - @js_data = { screen: 'no_gcp_projects' }.to_json track_event('service_accounts#index', 'error_form', 'no_gcp_projects') flash[:warning] = _('No Google Cloud projects - You need at least one Google Cloud project') redirect_to project_google_cloud_configuration_path(project) else - params = { per_page: 50 } - branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) - tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true) - refs = (branches + tags).map(&:name) js_data = { - screen: 'service_accounts_form', gcpProjects: gcp_projects, refs: refs, cancelPath: project_google_cloud_configuration_path(project) @@ -28,9 +18,9 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: track_event('service_accounts#index', 'success', js_data) end - rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error - track_event('service_accounts#index', 'error_gcp', error) - flash[:warning] = _('Google Cloud Error - %{error}') % { error: error } + rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e + track_event('service_accounts#index', 'error_gcp', e) + flash[:warning] = _('Google Cloud Error - %{error}') % { error: e } redirect_to project_google_cloud_configuration_path(project) end @@ -47,9 +37,9 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: track_event('service_accounts#create', 'success', response) redirect_to project_google_cloud_configuration_path(project), notice: response.message - rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error - track_event('service_accounts#create', 'error_gcp', error) - flash[:warning] = _('Google Cloud Error - %{error}') % { error: error } + rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e + track_event('service_accounts#create', 'error_gcp', e) + flash[:warning] = _('Google Cloud Error - %{error}') % { error: e } redirect_to project_google_cloud_configuration_path(project) end end diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index f9fa8046962..36b52533e78 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -9,7 +9,7 @@ class Projects::IncidentsController < Projects::ApplicationController before_action do push_frontend_feature_flag(:incident_timeline, @project) push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) - push_frontend_feature_flag(:work_items_mvc_2) + push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:work_items_hierarchy, @project) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index f1c9e2b2653..d19db2b11ab 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -44,12 +44,16 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:incident_timeline, project) end + before_action only: [:index, :show] do + push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) + end + before_action only: :show do push_frontend_feature_flag(:issue_assignees_widget, project) push_frontend_feature_flag(:realtime_labels, project) - push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) - push_frontend_feature_flag(:work_items_mvc_2) + push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:work_items_hierarchy, project) + push_force_frontend_feature_flag(:work_items_create_from_markdown, project&.work_items_create_from_markdown_feature_flag_enabled?) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] @@ -239,12 +243,12 @@ class Projects::IssuesController < Projects::ApplicationController end def import_csv - if uploader = UploadService.new(project, params[:file]).execute - ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker + result = Issues::PrepareImportCsvService.new(project, current_user, file: params[:file]).execute - flash[:notice] = _("Your issues are being imported. Once finished, you'll get a confirmation email.") + if result.success? + flash[:notice] = result.message else - flash[:alert] = _("File upload error.") + flash[:alert] = result.message end redirect_to project_issues_path(project) diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index ad59f421c06..7878ace5015 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -18,7 +18,7 @@ class Projects::JobsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize before_action :verify_proxy_request!, only: :proxy_websocket_authorize - before_action :push_job_log_search, only: [:show] + before_action :push_job_log_jump_to_failures, only: [:show] before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase] layout 'project' @@ -249,7 +249,7 @@ class Projects::JobsController < Projects::ApplicationController ::Gitlab::Workhorse.channel_websocket(service) end - def push_job_log_search - push_frontend_feature_flag(:job_log_search, @project) + def push_job_log_jump_to_failures + push_frontend_feature_flag(:job_log_jump_to_failures, @project) end end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 0dcc2bc3181..279fd4c457e 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -48,20 +48,24 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic allow_tree_conflicts: display_merge_conflicts_in_diff? } - if diff_options_hash[:paths].blank? - # NOTE: Any variables that would affect the resulting json needs to be added to the cache_context to avoid stale cache issues. - cache_context = [ - current_user&.cache_key, - unfoldable_positions.map(&:to_h), - diff_view, - params[:w], - params[:expanded], - params[:page], - params[:per_page], - options[:merge_ref_head_diff], - options[:allow_tree_conflicts] - ] + # NOTE: Any variables that would affect the resulting json needs to be added to the cache_context to avoid stale cache issues. + cache_context = [ + current_user&.cache_key, + unfoldable_positions.map(&:to_h), + diff_view, + params[:w], + params[:expanded], + params[:page], + params[:per_page], + options[:merge_ref_head_diff], + options[:allow_tree_conflicts] + ] + + if Feature.enabled?(:etag_merge_request_diff_batches, @merge_request.project) + return unless stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs]) + end + if diff_options_hash[:paths].blank? render_cached( diffs, with: PaginatedDiffSerializer.new(current_user: current_user), diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb index db7557674b2..ff6b6bfaf27 100644 --- a/app/controllers/projects/merge_requests/drafts_controller.rb +++ b/app/controllers/projects/merge_requests/drafts_controller.rb @@ -72,9 +72,9 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli strong_memoize(:draft_note) do draft_notes.find(params[:id]) end - rescue ActiveRecord::RecordNotFound => ex + rescue ActiveRecord::RecordNotFound => e # draft_note is allowed to be nil in #publish - raise ex unless allow_nil + raise e unless allow_nil end def draft_notes diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a2f018c013b..870c57fd6f3 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -34,16 +34,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action only: [:show] do push_frontend_feature_flag(:merge_request_widget_graphql, project) push_frontend_feature_flag(:core_security_mr_widget_counts, project) - push_frontend_feature_flag(:restructured_mr_widget, project) push_frontend_feature_flag(:refactor_mr_widgets_extensions, project) push_frontend_feature_flag(:refactor_code_quality_extension, project) push_frontend_feature_flag(:refactor_mr_widget_test_summary, project) - push_frontend_feature_flag(:rebase_without_ci_ui, project) push_frontend_feature_flag(:issue_assignees_widget, @project) push_frontend_feature_flag(:realtime_labels, project) push_frontend_feature_flag(:refactor_security_extension, @project) push_frontend_feature_flag(:refactor_code_quality_inline_findings, project) - push_frontend_feature_flag(:mr_attention_requests, current_user) push_frontend_feature_flag(:moved_mr_sidebar, project) push_frontend_feature_flag(:paginated_mr_discussions, project) push_frontend_feature_flag(:mr_review_submit_comment, project) @@ -367,7 +364,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def rebase - @merge_request.rebase_async(current_user.id) + @merge_request.rebase_async(current_user.id, skip_ci: Gitlab::Utils.to_boolean(merge_params[:skip_ci], default: false)) head :ok rescue MergeRequest::RebaseLockTimeout => e diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 744e45a0f9c..cfb67b7b4ff 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -92,8 +92,8 @@ class Projects::MilestonesController < Projects::ApplicationController render json: { url: project_milestones_path(project) } end end - rescue Milestones::PromoteService::PromoteMilestoneError => error - redirect_to milestone, alert: error.message + rescue Milestones::PromoteService::PromoteMilestoneError => e + redirect_to milestone, alert: e.message end def flash_notice_for(milestone, group) diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb index bcb6b574d5a..acbd26cbdf6 100644 --- a/app/controllers/projects/mirrors_controller.rb +++ b/app/controllers/projects/mirrors_controller.rb @@ -58,8 +58,8 @@ class Projects::MirrorsController < Projects::ApplicationController else render json: lookup end - rescue ArgumentError => err - render json: { message: err.message }, status: :bad_request + rescue ArgumentError => e + render json: { message: e.message }, status: :bad_request end private diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 4bd33882eee..0e990b64cd6 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -10,11 +10,29 @@ class Projects::PagesController < Projects::ApplicationController feature_category :pages - # rubocop: disable CodeReuse/ActiveRecord + def new + @pipeline_wizard_data = { + project_path: @project.full_path, + default_branch: @project.repository.root_ref, + redirect_to_when_done: project_pages_path(@project) + } + end + def show + unless @project.pages_enabled? + render :disabled + return + end + + if @project.pages_show_onboarding? + redirect_to action: 'new' + return + end + + # rubocop: disable CodeReuse/ActiveRecord @domains = @project.pages_domains.order(:domain).present(current_user: current_user) + # rubocop: enable CodeReuse/ActiveRecord end - # rubocop: enable CodeReuse/ActiveRecord def destroy ::Pages::DeleteService.new(@project, current_user).execute diff --git a/app/controllers/projects/pipelines/stages_controller.rb b/app/controllers/projects/pipelines/stages_controller.rb index 0447bbf29e7..c94d468cf2e 100644 --- a/app/controllers/projects/pipelines/stages_controller.rb +++ b/app/controllers/projects/pipelines/stages_controller.rb @@ -4,6 +4,7 @@ module Projects module Pipelines class StagesController < Projects::Pipelines::ApplicationController before_action :authorize_update_pipeline! + before_action :stage, only: [:play_manual] urgency :low, [ :play_manual @@ -26,7 +27,7 @@ module Projects private def stage - @pipeline_stage ||= pipeline.find_stage_by_name!(params[:stage_name]) + @stage ||= pipeline.stage(params[:stage_name]).presence || render_404 end end end diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index 9fc75fff807..33ce37ef4fb 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -35,8 +35,8 @@ class Projects::RepositoriesController < Projects::ApplicationController return if archive_not_modified? send_git_archive @repository, **repo_params - rescue StandardError => ex - logger.error("#{self.class.name}: #{ex}") + rescue StandardError => e + logger.error("#{self.class.name}: #{e}") git_not_found! end diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb index 34ce8df202b..5946c43b134 100644 --- a/app/controllers/projects/runner_projects_controller.rb +++ b/app/controllers/projects/runner_projects_controller.rb @@ -15,7 +15,7 @@ class Projects::RunnerProjectsController < Projects::ApplicationController path = project_runners_path(project) - if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute + if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute.success? redirect_to path, notice: s_('Runners|Runner assigned to project.') else assign_to_messages = @runner.errors.messages[:assign_to] diff --git a/app/controllers/projects/settings/integration_hook_logs_controller.rb b/app/controllers/projects/settings/integration_hook_logs_controller.rb index b3b5a292d42..1e42fbce4c4 100644 --- a/app/controllers/projects/settings/integration_hook_logs_controller.rb +++ b/app/controllers/projects/settings/integration_hook_logs_controller.rb @@ -20,7 +20,7 @@ module Projects override :hook def hook - @hook ||= integration.service_hook || not_found + @hook ||= integration.try(:service_hook) || not_found end end end diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb index cee9e9feb7b..03ef434456f 100644 --- a/app/controllers/projects/settings/integrations_controller.rb +++ b/app/controllers/projects/settings/integrations_controller.rb @@ -122,7 +122,7 @@ module Projects end def web_hook_logs - return unless integration.service_hook.present? + return unless integration.try(:service_hook).present? @web_hook_logs ||= integration.service_hook.web_hook_logs.recent.page(params[:page]) end diff --git a/app/controllers/projects/settings/packages_and_registries_controller.rb b/app/controllers/projects/settings/packages_and_registries_controller.rb index d3c08bef808..76c9cead360 100644 --- a/app/controllers/projects/settings/packages_and_registries_controller.rb +++ b/app/controllers/projects/settings/packages_and_registries_controller.rb @@ -14,11 +14,22 @@ module Projects def show end + def cleanup_tags + registry_settings_enabled! + + @hide_search_settings = true + end + private def packages_and_registries_settings_enabled! render_404 unless can?(current_user, :view_package_registry_project_settings, project) end + + def registry_settings_enabled! + render_404 unless Gitlab.config.registry.enabled && + can?(current_user, :admin_container_image, project) + end end end end diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb deleted file mode 100644 index adeadf2133e..00000000000 --- a/app/controllers/projects/tags/releases_controller.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -# TODO: remove this file together with FF https://gitlab.com/gitlab-org/gitlab/-/issues/366244 -# also delete view/routes -class Projects::Tags::ReleasesController < Projects::ApplicationController - # Authorize - before_action :require_non_empty_project - before_action :authorize_download_code! - before_action :authorize_push_code! - before_action :tag - before_action :release - - feature_category :release_evidence - urgency :low - - def edit - end - - def update - release.update(release_params) if release.persisted? || release_params[:description].present? - - redirect_to project_tag_path(@project, tag.name) - end - - private - - def tag - @tag ||= @repository.find_tag(params[:tag_id]) - end - - def release - @release ||= Releases::CreateService.new(project, current_user, tag: @tag.name) - .find_or_build_release - end - - def release_params - params.require(:release).permit(:description) - end -end diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index ce51cbb6677..fea2689db14 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -19,6 +19,7 @@ class Projects::TreeController < Projects::ApplicationController before_action do push_frontend_feature_flag(:lazy_load_commits, @project) push_frontend_feature_flag(:highlight_js, @project) + push_frontend_feature_flag(:file_line_blame, @project) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index e7bccf5a243..a8f062bd7c1 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -43,7 +43,7 @@ class Projects::VariablesController < Projects::ApplicationController end def variables_params - params.permit(variables_attributes: [*variable_params_attributes]) + params.permit(variables_attributes: Array(variable_params_attributes)) end def variable_params_attributes diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb index ba23af41bb0..b794785f285 100644 --- a/app/controllers/projects/work_items_controller.rb +++ b/app/controllers/projects/work_items_controller.rb @@ -3,7 +3,7 @@ class Projects::WorkItemsController < Projects::ApplicationController before_action do push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) - push_frontend_feature_flag(:work_items_mvc_2) + push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:work_items_hierarchy, project) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 37e472050a0..8a6bcb4b3fc 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -37,17 +37,18 @@ class ProjectsController < Projects::ApplicationController before_action do push_frontend_feature_flag(:lazy_load_commits, @project) push_frontend_feature_flag(:highlight_js, @project) + push_frontend_feature_flag(:file_line_blame, @project) push_frontend_feature_flag(:increase_page_size_exponentially, @project) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies) push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) - push_frontend_feature_flag(:work_items_mvc_2) + push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:package_registry_access_level) push_frontend_feature_flag(:work_items_hierarchy, @project) end before_action only: :edit do - push_frontend_feature_flag(:enforce_auth_checks_on_uploads, @project) + push_frontend_feature_flag(:split_operations_visibility_permissions, @project) end layout :determine_layout @@ -197,8 +198,8 @@ class ProjectsController < Projects::ApplicationController flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name } redirect_to dashboard_projects_path, status: :found - rescue Projects::DestroyService::DestroyError => ex - redirect_to edit_project_path(@project), status: :found, alert: ex.message + rescue Projects::DestroyService::DestroyError => e + redirect_to edit_project_path(@project), status: :found, alert: e.message end def new_issuable_address @@ -231,10 +232,10 @@ class ProjectsController < Projects::ApplicationController project_path(@project), notice: _("Housekeeping successfully started") ) - rescue ::Repositories::HousekeepingService::LeaseTaken => ex + rescue ::Repositories::HousekeepingService::LeaseTaken => e redirect_to( edit_project_path(@project, anchor: 'js-project-advanced-settings'), - alert: ex.to_s + alert: e.to_s ) end @@ -245,10 +246,10 @@ class ProjectsController < Projects::ApplicationController edit_project_path(@project, anchor: 'js-export-project'), notice: _("Project export started. A download link will be sent by email and made available on this page.") ) - rescue Project::ExportLimitExceeded => ex + rescue Project::ExportLimitExceeded => e redirect_to( edit_project_path(@project, anchor: 'js-export-project'), - alert: ex.to_s + alert: e.to_s ) end @@ -420,10 +421,19 @@ class ProjectsController < Projects::ApplicationController pages_access_level metrics_dashboard_access_level analytics_access_level - operations_access_level security_and_compliance_access_level container_registry_access_level - ] + ] + operations_feature_attributes + end + + def operations_feature_attributes + if Feature.enabled?(:split_operations_visibility_permissions, project) + %i[ + environments_access_level feature_flags_access_level releases_access_level + ] + else + %i[operations_access_level] + end end def project_setting_attributes diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index bb16c2d2098..33d2c482795 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -21,6 +21,7 @@ class RegistrationsController < Devise::RegistrationsController before_action only: [:new] do push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops) + push_frontend_feature_flag(:trial_email_validation, type: :development) end feature_category :authentication_and_authorization diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb index 24520a187e3..8d7ba3e38c0 100644 --- a/app/controllers/repositories/git_http_client_controller.rb +++ b/app/controllers/repositories/git_http_client_controller.rb @@ -107,7 +107,7 @@ module Repositories render plain: "HTTP Basic: Access denied\n" \ "You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \ "You can generate one at #{profile_personal_access_tokens_url}", - status: :unauthorized + status: :unauthorized end def repository diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb index 7deda473b9d..83973d07a17 100644 --- a/app/controllers/repositories/lfs_api_controller.rb +++ b/app/controllers/repositories/lfs_api_controller.rb @@ -173,12 +173,12 @@ module Repositories LfsObjectsProject.link_to_project!(lfs_object, project) Gitlab::AppJsonLogger.info(message: "LFS object auto-linked to forked project", - lfs_object_oid: lfs_object.oid, - lfs_object_size: lfs_object.size, - source_project_id: project.fork_source.id, - source_project_path: project.fork_source.full_path, - target_project_id: project.project_id, - target_project_path: project.full_path) + lfs_object_oid: lfs_object.oid, + lfs_object_size: lfs_object.size, + source_project_id: project.fork_source.id, + source_project_path: project.fork_source.full_path, + target_project_id: project.project_id, + target_project_path: project.full_path) end end end diff --git a/app/controllers/repositories/lfs_locks_api_controller.rb b/app/controllers/repositories/lfs_locks_api_controller.rb index 1d091a5bfcd..f36126d67ff 100644 --- a/app/controllers/repositories/lfs_locks_api_controller.rb +++ b/app/controllers/repositories/lfs_locks_api_controller.rb @@ -38,8 +38,8 @@ module Repositories def render_json(data, process = true) render json: build_payload(data, process), - content_type: LfsRequest::CONTENT_TYPE, - status: @result[:http_status] + content_type: LfsRequest::CONTENT_TYPE, + status: @result[:http_status] end def build_payload(data, process) diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 7a7e63f5fc4..5843e13c7cd 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -7,10 +7,14 @@ class SearchController < ApplicationController include ProductAnalyticsTracking include SearchRateLimitable - RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete].freeze + RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete, :aggregations].freeze track_event :show, name: 'i_search_total', destinations: [:redis_hll, :snowplow] + def self.search_rate_limited_endpoints + %i[show count autocomplete] + end + around_action :allow_gitaly_ref_name_caching before_action :block_anonymous_global_searches, :check_scope_global_search_enabled, except: :opensearch @@ -19,7 +23,7 @@ class SearchController < ApplicationController search_term_present = params[:search].present? || params[:term].present? search_term_present && !params[:project_id].present? end - before_action :check_search_rate_limit!, only: [:show, :count, :autocomplete] + before_action :check_search_rate_limit!, only: search_rate_limited_endpoints rescue_from ActiveRecord::QueryCanceled, with: :render_timeout @@ -32,8 +36,6 @@ class SearchController < ApplicationController @project = search_service.project @group = search_service.group - return if params[:search].blank? - return unless search_term_valid? return if check_single_commit_result? @@ -53,7 +55,6 @@ class SearchController < ApplicationController @search_results = @search_service.search_results @search_objects = @search_service.search_objects @search_highlight = @search_service.search_highlight - @aggregations = @search_service.search_aggregations end increment_search_counters @@ -83,8 +84,9 @@ class SearchController < ApplicationController @project = search_service.project @ref = params[:project_ref] if params[:project_ref].present? + @filter = params[:filter] - render json: search_autocomplete_opts(term).to_json + render json: search_autocomplete_opts(term, filter: @filter).to_json end def opensearch @@ -98,6 +100,8 @@ class SearchController < ApplicationController end def search_term_valid? + return false if params[:search].blank? + unless search_service.valid_query_length? flash[:alert] = t('errors.messages.search_chars_too_long', count: Gitlab::Search::Params::SEARCH_CHAR_LIMIT) return false @@ -150,6 +154,7 @@ class SearchController < ApplicationController payload[:metadata]['meta.search.filters.state'] = params[:state] payload[:metadata]['meta.search.force_search_results'] = params[:force_search_results] payload[:metadata]['meta.search.project_ids'] = params[:project_ids] + payload[:metadata]['meta.search.filters.language'] = params[:language] payload[:metadata]['meta.search.type'] = @search_type if @search_type.present? payload[:metadata]['meta.search.level'] = @search_level if @search_level.present? payload[:metadata][:global_search_duration_s] = @global_search_duration_s if @global_search_duration_s.present? @@ -205,7 +210,7 @@ class SearchController < ApplicationController case action_name.to_sym when :count render json: {}, status: :request_timeout - when :autocomplete + when :autocomplete, :aggregations render json: [], status: :request_timeout else render status: :request_timeout diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 6195d152f00..fe3b8d9b8b4 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -215,11 +215,11 @@ class SessionsController < Devise::SessionsController def find_user strong_memoize(:find_user) do if session[:otp_user_id] && user_params[:login] - User.by_id_and_login(session[:otp_user_id], user_params[:login]).first + User.by_login(user_params[:login]).find_by_id(session[:otp_user_id]) elsif session[:otp_user_id] User.find(session[:otp_user_id]) elsif user_params[:login] - User.by_login(user_params[:login]) + User.find_by_login(user_params[:login]) end end end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 97bbb96eae6..09419a4589d 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -7,13 +7,13 @@ class UploadsController < ApplicationController UnknownUploadModelError = Class.new(StandardError) MODEL_CLASSES = { - "user" => User, - "project" => Project, - "note" => Note, - "group" => Group, - "appearance" => Appearance, + "user" => User, + "project" => Project, + "note" => Note, + "group" => Group, + "appearance" => Appearance, "personal_snippet" => PersonalSnippet, - "projects/topic" => Projects::Topic, + "projects/topic" => Projects::Topic, 'alert_management_metric_image' => ::AlertManagement::MetricImage, nil => PersonalSnippet }.freeze diff --git a/app/controllers/users/namespace_callouts_controller.rb b/app/controllers/users/namespace_callouts_controller.rb new file mode 100644 index 00000000000..d4876382dfe --- /dev/null +++ b/app/controllers/users/namespace_callouts_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Users + class NamespaceCalloutsController < Users::CalloutsController + private + + def callout + Users::DismissNamespaceCalloutService.new( + container: nil, current_user: current_user, params: callout_params + ).execute + end + + def callout_params + params.permit(:namespace_id).merge(feature_name: feature_name) + end + end +end diff --git a/app/controllers/users/project_callouts_controller.rb b/app/controllers/users/project_callouts_controller.rb new file mode 100644 index 00000000000..64d89630021 --- /dev/null +++ b/app/controllers/users/project_callouts_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Users + class ProjectCalloutsController < Users::CalloutsController + private + + def callout + Users::DismissProjectCalloutService.new( + container: nil, current_user: current_user, params: callout_params + ).execute + end + + def callout_params + params.permit(:project_id).merge(feature_name: feature_name) + end + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index eaf08cd421b..3c1a3534912 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -155,7 +155,11 @@ class UsersController < ApplicationController end def calendar_activities - @calendar_date = Date.parse(params[:date]) rescue Date.today + @calendar_date = begin + Date.parse(params[:date]) + rescue StandardError + Date.today + end @events = contributions_calendar.events_by_date(@calendar_date).map(&:present) render 'calendar_activities', layout: false diff --git a/app/events/groups/group_deleted_event.rb b/app/events/groups/group_deleted_event.rb new file mode 100644 index 00000000000..d89cce17be9 --- /dev/null +++ b/app/events/groups/group_deleted_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Groups + class GroupDeletedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'group_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' } + }, + 'required' => %w[group_id root_namespace_id] + } + end + end +end diff --git a/app/events/groups/group_path_changed_event.rb b/app/events/groups/group_path_changed_event.rb new file mode 100644 index 00000000000..e8d9b733a0a --- /dev/null +++ b/app/events/groups/group_path_changed_event.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Groups + class GroupPathChangedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'group_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' }, + 'old_path' => { 'type' => 'string' }, + 'new_path' => { 'type' => 'string' } + }, + 'required' => %w[group_id root_namespace_id old_path new_path] + } + end + end +end diff --git a/app/events/groups/group_transfered_event.rb b/app/events/groups/group_transfered_event.rb new file mode 100644 index 00000000000..da573892108 --- /dev/null +++ b/app/events/groups/group_transfered_event.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Groups + class GroupTransferedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'group_id' => { 'type' => 'integer' }, + 'old_root_namespace_id' => { 'type' => 'integer' }, + 'new_root_namespace_id' => { 'type' => 'integer' } + }, + 'required' => %w[group_id old_root_namespace_id new_root_namespace_id] + } + end + end +end diff --git a/app/events/merge_requests/approved_event.rb b/app/events/merge_requests/approved_event.rb new file mode 100644 index 00000000000..c68a002dcc3 --- /dev/null +++ b/app/events/merge_requests/approved_event.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module MergeRequests + class ApprovedEvent < Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'required' => %w[ + current_user_id + merge_request_id + ], + 'properties' => { + 'current_user_id' => { 'type' => 'integer' }, + 'merge_request_id' => { 'type' => 'integer' } + } + } + end + end +end diff --git a/app/events/projects/project_archived_event.rb b/app/events/projects/project_archived_event.rb new file mode 100644 index 00000000000..9ac83fd791b --- /dev/null +++ b/app/events/projects/project_archived_event.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Projects + class ProjectArchivedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'namespace_id' => { 'type' => 'integer' }, + 'root_namespace_id' => { 'type' => 'integer' } + }, + 'required' => %w[project_id namespace_id root_namespace_id] + } + end + end +end diff --git a/app/events/projects/project_transfered_event.rb b/app/events/projects/project_transfered_event.rb new file mode 100644 index 00000000000..14cc53daabb --- /dev/null +++ b/app/events/projects/project_transfered_event.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Projects + class ProjectTransferedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'old_namespace_id' => { 'type' => 'integer' }, + 'old_root_namespace_id' => { 'type' => 'integer' }, + 'new_namespace_id' => { 'type' => 'integer' }, + 'new_root_namespace_id' => { 'type' => 'integer' } + }, + 'required' => %w[ + project_id + old_namespace_id + old_root_namespace_id + new_namespace_id + new_root_namespace_id + ] + } + end + end +end diff --git a/app/experiments/security_reports_mr_widget_prompt_experiment.rb b/app/experiments/security_reports_mr_widget_prompt_experiment.rb index 51b81be672d..1bf3e15ba3b 100644 --- a/app/experiments/security_reports_mr_widget_prompt_experiment.rb +++ b/app/experiments/security_reports_mr_widget_prompt_experiment.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment - control { } - candidate { } + control {} + candidate {} def publish(_result = nil) super diff --git a/app/experiments/video_tutorials_continuous_onboarding_experiment.rb b/app/experiments/video_tutorials_continuous_onboarding_experiment.rb index 3cb676b25f2..2c5790f83d1 100644 --- a/app/experiments/video_tutorials_continuous_onboarding_experiment.rb +++ b/app/experiments/video_tutorials_continuous_onboarding_experiment.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true class VideoTutorialsContinuousOnboardingExperiment < ApplicationExperiment - control { } - candidate { } + control {} + candidate {} end diff --git a/app/finders/autocomplete/deploy_keys_with_write_access_finder.rb b/app/finders/autocomplete/deploy_keys_with_write_access_finder.rb new file mode 100644 index 00000000000..a123a0dc4f0 --- /dev/null +++ b/app/finders/autocomplete/deploy_keys_with_write_access_finder.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Autocomplete + # Finder for retrieving deploy keys to use for autocomplete data sources. + class DeployKeysWithWriteAccessFinder + attr_reader :current_user, :project + + def initialize(current_user, project) + @current_user = current_user + @project = project + end + + def execute + return DeployKey.none if project.nil? + + raise Gitlab::Access::AccessDeniedError unless current_user.can?(:admin_project, project) + + project.deploy_keys.merge(DeployKeysProject.with_write_access) + end + end +end diff --git a/app/finders/ci/daily_build_group_report_results_finder.rb b/app/finders/ci/daily_build_group_report_results_finder.rb index 33aefe29392..b93b7dbe0c5 100644 --- a/app/finders/ci/daily_build_group_report_results_finder.rb +++ b/app/finders/ci/daily_build_group_report_results_finder.rb @@ -82,14 +82,20 @@ module Ci end def start_date - start_date = Date.strptime(params[:start_date], DATE_FORMAT_ALLOWED) rescue REPORT_WINDOW.ago.to_date + start_date = begin + Date.strptime(params[:start_date], DATE_FORMAT_ALLOWED) + rescue StandardError + REPORT_WINDOW.ago.to_date + end # The start_date cannot be older than `end_date - 90 days` [start_date, end_date - REPORT_WINDOW].max end def end_date - Date.strptime(params[:end_date], DATE_FORMAT_ALLOWED) rescue Date.current + Date.strptime(params[:end_date], DATE_FORMAT_ALLOWED) + rescue StandardError + Date.current end end end diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 4f9244d9825..774947a35b7 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -69,10 +69,15 @@ module Ci end def filter_by_upgrade_status! - return unless @params.key?(:upgrade_status) - return unless Ci::RunnerVersion.statuses.key?(@params[:upgrade_status]) + upgrade_status = @params[:upgrade_status] - @runners = @runners.with_upgrade_status(@params[:upgrade_status]) + return unless upgrade_status + + unless Ci::RunnerVersion.statuses.key?(upgrade_status) + raise ArgumentError, "Invalid upgrade status value '#{upgrade_status}'" + end + + @runners = @runners.with_upgrade_status(upgrade_status) end def filter_by_runner_type! diff --git a/app/finders/crm/contacts_finder.rb b/app/finders/crm/contacts_finder.rb index 29f3d6f0f16..58ec4cf8a47 100644 --- a/app/finders/crm/contacts_finder.rb +++ b/app/finders/crm/contacts_finder.rb @@ -16,6 +16,11 @@ module Crm attr_reader :params, :current_user + def self.counts_by_state(current_user, params = {}) + params = params.merge(sort: nil) + new(current_user, params).execute.counts_by_state + end + def initialize(current_user, params = {}) @current_user = current_user @params = params @@ -28,11 +33,25 @@ module Crm contacts = by_ids(contacts) contacts = by_state(contacts) contacts = by_search(contacts) - contacts.sort_by_name + sort_contacts(contacts) end private + def sort_contacts(contacts) + return contacts.sort_by_name unless @params.key?(:sort) + return contacts if @params[:sort].nil? + + field = @params[:sort][:field] + direction = @params[:sort][:direction] + + if field == 'organization' + contacts.sort_by_organization(direction) + else + contacts.sort_by_field(field, direction) + end + end + def root_group strong_memoize(:root_group) do group = params[:group]&.root_ancestor diff --git a/app/finders/fork_targets_finder.rb b/app/finders/fork_targets_finder.rb index 0b5dfb16572..e129fde3748 100644 --- a/app/finders/fork_targets_finder.rb +++ b/app/finders/fork_targets_finder.rb @@ -6,17 +6,39 @@ class ForkTargetsFinder @user = user end - # rubocop: disable CodeReuse/ActiveRecord def execute(options = {}) - return ::Namespace.where(id: user.forkable_namespaces).sort_by_type unless options[:only_groups] + return previous_execute(options) unless Feature.enabled?(:searchable_fork_targets) - ::Group.where(id: user.manageable_groups(include_groups_with_developer_maintainer_access: true)) + items = fork_targets(options) + + by_search(items, options) end - # rubocop: enable CodeReuse/ActiveRecord private attr_reader :project, :user + + def by_search(items, options) + return items if options[:search].blank? + + items.search(options[:search]) + end + + def fork_targets(options) + if options[:only_groups] + user.manageable_groups(include_groups_with_developer_maintainer_access: true) + else + user.forkable_namespaces.sort_by_type + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def previous_execute(options = {}) + return ::Namespace.where(id: user.forkable_namespaces).sort_by_type unless options[:only_groups] + + ::Group.where(id: user.manageable_groups(include_groups_with_developer_maintainer_access: true)) + end + # rubocop: enable CodeReuse/ActiveRecord end ForkTargetsFinder.prepend_mod_with('ForkTargetsFinder') diff --git a/app/finders/groups/accepting_project_transfers_finder.rb b/app/finders/groups/accepting_project_transfers_finder.rb new file mode 100644 index 00000000000..09d3c430641 --- /dev/null +++ b/app/finders/groups/accepting_project_transfers_finder.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Groups + class AcceptingProjectTransfersFinder + def initialize(current_user) + @current_user = current_user + end + + def execute + if Feature.disabled?(:include_groups_from_group_shares_in_project_transfer_locations) + return current_user.manageable_groups + end + + groups_accepting_project_transfers = + [ + current_user.manageable_groups, + managable_groups_originating_from_group_shares + ] + + groups = ::Group.from_union(groups_accepting_project_transfers) + + groups.project_creation_allowed + end + + private + + attr_reader :current_user + + def managable_groups_originating_from_group_shares + GroupGroupLink + .with_owner_or_maintainer_access + .groups_accessible_via( + groups_that_user_has_owner_or_maintainer_access_via_direct_membership + .select(:id) + ) + end + + def groups_that_user_has_owner_or_maintainer_access_via_direct_membership + # Only maintainers or above in a group has access to transfer projects to that group + current_user.owned_or_maintainers_groups + end + end +end diff --git a/app/finders/groups/user_groups_finder.rb b/app/finders/groups/user_groups_finder.rb index 90367638dcf..bda8b7cc1e0 100644 --- a/app/finders/groups/user_groups_finder.rb +++ b/app/finders/groups/user_groups_finder.rb @@ -41,14 +41,14 @@ module Groups def by_search(items) return items if params[:search].blank? - items.search(params[:search]) + items.search(params[:search], include_parents: true) end def by_permission_scope if permission_scope_create_projects? target_user.manageable_groups(include_groups_with_developer_maintainer_access: true) elsif permission_scope_transfer_projects? - target_user.manageable_groups(include_groups_with_developer_maintainer_access: false) + Groups::AcceptingProjectTransfersFinder.new(target_user).execute # rubocop: disable CodeReuse/Finder else target_user.groups end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 47b2a460e6f..1088d53c9a0 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -46,7 +46,8 @@ class IssuableFinder requires_cross_project_access unless: -> { params.project? } - FULL_TEXT_SEARCH_TERM_REGEX = /\A[\p{ASCII}|\p{Latin}]+\z/.freeze + FULL_TEXT_SEARCH_TERM_PATTERN = '[\u0000-\u218F]*' + FULL_TEXT_SEARCH_TERM_REGEX = /\A#{FULL_TEXT_SEARCH_TERM_PATTERN}\z/.freeze NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze attr_accessor :current_user, :params diff --git a/app/finders/projects/topics_finder.rb b/app/finders/projects/topics_finder.rb index c26b166a786..fb0a77db548 100644 --- a/app/finders/projects/topics_finder.rb +++ b/app/finders/projects/topics_finder.rb @@ -13,6 +13,7 @@ module Projects def execute topics = Projects::Topic.order_by_non_private_projects_count + topics = by_without_projects(topics) by_search(topics) end @@ -25,5 +26,11 @@ module Projects topics.search(params[:search]).reorder_by_similarity(params[:search]) end + + def by_without_projects(topics) + return topics unless params[:without_projects] + + topics.without_assigned_projects + end end end diff --git a/app/finders/releases/group_releases_finder.rb b/app/finders/releases/group_releases_finder.rb index 8b1b0c552fd..08530f63ea6 100644 --- a/app/finders/releases/group_releases_finder.rb +++ b/app/finders/releases/group_releases_finder.rb @@ -32,7 +32,7 @@ module Releases def get_releases Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new( scope: releases_scope, - array_scope: Project.for_group_and_its_subgroups(parent).select(:id), + array_scope: Project.for_group_and_its_subgroups(parent).select(:id), array_mapping_scope: -> (project_id_expression) { Release.where(Release.arel_table[:project_id].eq(project_id_expression)) }, finder_query: -> (order_by, id_expression) { Release.where(Release.arel_table[:id].eq(id_expression)) } ) diff --git a/app/finders/repositories/changelog_tag_finder.rb b/app/finders/repositories/changelog_tag_finder.rb index 3c110e6c65d..7dd7404730f 100644 --- a/app/finders/repositories/changelog_tag_finder.rb +++ b/app/finders/repositories/changelog_tag_finder.rb @@ -37,14 +37,14 @@ module Repositories begin regex = Gitlab::UntrustedRegexp.new(@regex) - rescue RegexpError => ex + rescue RegexpError => e # The error messages produced by default are not very helpful, so we # raise a better one here. We raise the specific error here so its # message is displayed in the API (where we catch this specific # error). raise( Gitlab::Changelog::Error, - "The regular expression to use for finding the previous tag for a version is invalid: #{ex.message}" + "The regular expression to use for finding the previous tag for a version is invalid: #{e.message}" ) end diff --git a/app/finders/tags_finder.rb b/app/finders/tags_finder.rb index 16bba62f766..52b1fff4883 100644 --- a/app/finders/tags_finder.rb +++ b/app/finders/tags_finder.rb @@ -2,7 +2,7 @@ class TagsFinder < GitRefsFinder def execute(gitaly_pagination: false) - tags = if gitaly_pagination + tags = if gitaly_pagination && search.blank? repository.tags_sorted_by(sort, pagination_params) else repository.tags_sorted_by(sort) diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index b399f0490ee..c0e063a34d5 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -53,6 +53,7 @@ class GitlabSchema < GraphQL::Schema def get_type(type_name, context = GraphQL::Query::NullContext) type_name = Gitlab::GlobalId::Deprecations.apply_to_graphql_name(type_name) + type_name = Gitlab::Graphql::TypeNameDeprecations.apply_to_graphql_name(type_name) super(type_name, context) end @@ -163,6 +164,7 @@ class GitlabSchema < GraphQL::Schema def get_type(type_name) type_name = Gitlab::GlobalId::Deprecations.apply_to_graphql_name(type_name) + type_name = Gitlab::Graphql::TypeNameDeprecations.apply_to_graphql_name(type_name) super(type_name) end diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index 342cff83e90..b39875b83a9 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -16,4 +16,8 @@ module GraphqlTriggers def self.issuable_labels_updated(issuable) GitlabSchema.subscriptions.trigger('issuableLabelsUpdated', { issuable_id: issuable.to_gid }, issuable) end + + def self.issuable_dates_updated(issuable) + GitlabSchema.subscriptions.trigger('issuableDatesUpdated', { issuable_id: issuable.to_gid }, issuable) + end end diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb index 5da2731d562..a419a8df64e 100644 --- a/app/graphql/mutations/award_emojis/toggle.rb +++ b/app/graphql/mutations/award_emojis/toggle.rb @@ -5,9 +5,10 @@ module Mutations class Toggle < Base graphql_name 'AwardEmojiToggle' - field :toggled_on, GraphQL::Types::Boolean, null: false, - description: 'Indicates the status of the emoji. ' \ - 'True if the toggle awarded the emoji, and false if the toggle removed the emoji.' + field :toggled_on, GraphQL::Types::Boolean, + null: false, + description: 'Indicates the status of the emoji. ' \ + 'True if the toggle awarded the emoji, and false if the toggle removed the emoji.' def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) diff --git a/app/graphql/mutations/ci/job/retry.rb b/app/graphql/mutations/ci/job/retry.rb index 50e9c51c9e7..bfb9b902cc5 100644 --- a/app/graphql/mutations/ci/job/retry.rb +++ b/app/graphql/mutations/ci/job/retry.rb @@ -11,13 +11,20 @@ module Mutations null: true, description: 'Job after the mutation.' + argument :variables, [::Types::Ci::VariableInputType], + required: false, + default_value: [], + replace_null_with_default: true, + description: 'Variables to use when retrying a manual job.' + authorize :update_build - def resolve(id:) + def resolve(id:, variables:) job = authorized_find!(id: id) project = job.project + variables = variables.map(&:to_h) - response = ::Ci::RetryJobService.new(project, current_user).execute(job) + response = ::Ci::RetryJobService.new(project, current_user).execute(job, variables: variables) if response.success? { diff --git a/app/graphql/mutations/ci/pipeline/cancel.rb b/app/graphql/mutations/ci/pipeline/cancel.rb index 3ec6eee9f54..c52e3b4f4b8 100644 --- a/app/graphql/mutations/ci/pipeline/cancel.rb +++ b/app/graphql/mutations/ci/pipeline/cancel.rb @@ -13,7 +13,6 @@ module Mutations if pipeline.cancelable? pipeline.cancel_running - pipeline.cancel { success: true, errors: [] } else diff --git a/app/graphql/mutations/ci/runner/bulk_delete.rb b/app/graphql/mutations/ci/runner/bulk_delete.rb new file mode 100644 index 00000000000..4c1c2967799 --- /dev/null +++ b/app/graphql/mutations/ci/runner/bulk_delete.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Runner + class BulkDelete < BaseMutation + graphql_name 'BulkRunnerDelete' + + RunnerID = ::Types::GlobalIDType[::Ci::Runner] + + argument :ids, [RunnerID], + required: false, + description: 'IDs of the runners to delete.' + + field :deleted_count, + ::GraphQL::Types::Int, + null: true, + description: 'Number of records effectively deleted. ' \ + 'Only present if operation was performed synchronously.' + + field :deleted_ids, + [RunnerID], + null: true, + description: 'IDs of records effectively deleted. ' \ + 'Only present if operation was performed synchronously.' + + def resolve(**runner_attrs) + raise_resource_not_available_error! unless Ability.allowed?(current_user, :delete_runners) + + if ids = runner_attrs[:ids] + runners = find_all_runners_by_ids(model_ids_of(ids)) + + result = ::Ci::Runners::BulkDeleteRunnersService.new(runners: runners).execute + result.payload.slice(:deleted_count, :deleted_ids).merge(errors: []) + else + { errors: [] } + end + end + + private + + def model_ids_of(ids) + ids.map do |gid| + gid.model_id.to_i + end.compact + end + + def find_all_runners_by_ids(ids) + return ::Ci::Runner.none if ids.blank? + + ::Ci::Runner.id_in(ids) + end + end + end + end +end diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb index b6d8c20c40b..1c6cf6989bf 100644 --- a/app/graphql/mutations/ci/runner/update.rb +++ b/app/graphql/mutations/ci/runner/update.rb @@ -39,15 +39,17 @@ module Mutations required: false, description: 'Indicates the runner is not allowed to receive jobs.' - argument :locked, GraphQL::Types::Boolean, required: false, - description: 'Indicates the runner is locked.' + argument :locked, GraphQL::Types::Boolean, + required: false, + description: 'Indicates the runner is locked.' argument :run_untagged, GraphQL::Types::Boolean, required: false, description: 'Indicates the runner is able to run untagged jobs.' - argument :tag_list, [GraphQL::Types::String], required: false, - description: 'Tags associated with the runner.' + argument :tag_list, [GraphQL::Types::String], + required: false, + description: 'Tags associated with the runner.' field :runner, Types::Ci::RunnerType, diff --git a/app/graphql/mutations/ci/runners_registration_token/reset.rb b/app/graphql/mutations/ci/runners_registration_token/reset.rb index 8c49b682ab0..c9fe7ea47f0 100644 --- a/app/graphql/mutations/ci/runners_registration_token/reset.rb +++ b/app/graphql/mutations/ci/runners_registration_token/reset.rb @@ -49,7 +49,10 @@ module Mutations end def reset_token(scope) - ::Ci::Runners::ResetRegistrationTokenService.new(scope, current_user).execute if scope + return unless scope + + result = ::Ci::Runners::ResetRegistrationTokenService.new(scope, current_user).execute + result.payload[:new_registration_token] if result.success? end end end diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb index cbe1cfb4099..1f90f394521 100644 --- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb +++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb @@ -15,15 +15,21 @@ module Mutations argument :title, GraphQL::Types::String, required: false, description: copy_field_description(Types::WorkItemType, :title) + argument :confidential, GraphQL::Types::Boolean, + required: false, + description: 'Sets the work item confidentiality.' argument :description_widget, ::Types::WorkItems::Widgets::DescriptionInputType, required: false, description: 'Input for description widget.' - argument :weight_widget, ::Types::WorkItems::Widgets::WeightInputType, + argument :assignees_widget, ::Types::WorkItems::Widgets::AssigneesInputType, required: false, - description: 'Input for weight widget.' + description: 'Input for assignees widget.' argument :hierarchy_widget, ::Types::WorkItems::Widgets::HierarchyUpdateInputType, required: false, description: 'Input for hierarchy widget.' + argument :start_and_due_date_widget, ::Types::WorkItems::Widgets::StartAndDueDateUpdateInputType, + required: false, + description: 'Input for start and due date widget.' end end end diff --git a/app/graphql/mutations/container_repositories/destroy.rb b/app/graphql/mutations/container_repositories/destroy.rb index 1d8f7b22f88..2a45291be22 100644 --- a/app/graphql/mutations/container_repositories/destroy.rb +++ b/app/graphql/mutations/container_repositories/destroy.rb @@ -21,7 +21,9 @@ module Mutations container_repository = authorized_find!(id: id) container_repository.delete_scheduled! + # rubocop:disable CodeReuse/Worker DeleteContainerRepositoryWorker.perform_async(current_user.id, container_repository.id) + # rubocop:enable CodeReuse/Worker track_event(:delete_repository, :container) { diff --git a/app/graphql/mutations/design_management/move.rb b/app/graphql/mutations/design_management/move.rb index b19d9b4ce61..cf7b7a7b99b 100644 --- a/app/graphql/mutations/design_management/move.rb +++ b/app/graphql/mutations/design_management/move.rb @@ -7,18 +7,21 @@ module Mutations DesignID = ::Types::GlobalIDType[::DesignManagement::Design] - argument :id, DesignID, required: true, as: :current_design, - description: "ID of the design to move." + argument :id, DesignID, + required: true, as: :current_design, + description: "ID of the design to move." - argument :previous, DesignID, required: false, as: :previous_design, - description: "ID of the immediately preceding design." + argument :previous, DesignID, + required: false, as: :previous_design, + description: "ID of the immediately preceding design." - argument :next, DesignID, required: false, as: :next_design, - description: "ID of the immediately following design." + argument :next, DesignID, + required: false, as: :next_design, + description: "ID of the immediately following design." field :design_collection, Types::DesignManagement::DesignCollectionType, - null: true, - description: "Current state of the collection." + null: true, + description: "Current state of the collection." def resolve(**args) service = ::DesignManagement::MoveDesignsService.new(current_user, parameters(**args)) diff --git a/app/graphql/mutations/issues/move.rb b/app/graphql/mutations/issues/move.rb index fb22a2d891c..63bc9dabbf9 100644 --- a/app/graphql/mutations/issues/move.rb +++ b/app/graphql/mutations/issues/move.rb @@ -19,8 +19,8 @@ module Mutations begin moved_issue = ::Issues::MoveService.new(project: source_project, current_user: current_user).execute(issue, target_project) - rescue ::Issues::MoveService::MoveError => error - errors = error.message + rescue ::Issues::MoveService::MoveError => e + errors = e.message end { diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb index abfd6fec0bd..b795d66c16f 100644 --- a/app/graphql/mutations/issues/set_confidential.rb +++ b/app/graphql/mutations/issues/set_confidential.rb @@ -24,7 +24,7 @@ module Mutations check_spam_action_response!(issue) { - issue: issue, + issue: issue.reset, errors: errors_on_object(issue) } end diff --git a/app/graphql/mutations/issues/set_severity.rb b/app/graphql/mutations/issues/set_severity.rb index 872a0e7b33d..4a24bfd18ef 100644 --- a/app/graphql/mutations/issues/set_severity.rb +++ b/app/graphql/mutations/issues/set_severity.rb @@ -5,8 +5,9 @@ module Mutations class SetSeverity < Base graphql_name 'IssueSetSeverity' - argument :severity, Types::IssuableSeverityEnum, required: true, - description: 'Set the incident severity level.' + argument :severity, Types::IssuableSeverityEnum, + required: true, + description: 'Set the incident severity level.' authorize :admin_issue diff --git a/app/graphql/mutations/merge_requests/remove_attention_request.rb b/app/graphql/mutations/merge_requests/remove_attention_request.rb deleted file mode 100644 index 3b12b09528b..00000000000 --- a/app/graphql/mutations/merge_requests/remove_attention_request.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module MergeRequests - class RemoveAttentionRequest < Base - graphql_name 'MergeRequestRemoveAttentionRequest' - - argument :user_id, ::Types::GlobalIDType[::User], - loads: Types::UserType, - required: true, - description: <<~DESC - User ID of the user for attention request removal. - DESC - - def resolve(project_path:, iid:, user:) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled? - - merge_request = authorized_find!(project_path: project_path, iid: iid) - - result = ::MergeRequests::RemoveAttentionRequestedService.new( - project: merge_request.project, - current_user: current_user, - merge_request: merge_request, - user: user - ).execute - - { - merge_request: merge_request, - errors: Array(result[:message]) - } - end - - private - - def feature_enabled? - current_user&.mr_attention_requests_enabled? - end - end - end -end diff --git a/app/graphql/mutations/merge_requests/request_attention.rb b/app/graphql/mutations/merge_requests/request_attention.rb deleted file mode 100644 index 5f5565285f6..00000000000 --- a/app/graphql/mutations/merge_requests/request_attention.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module MergeRequests - class RequestAttention < Base - graphql_name 'MergeRequestRequestAttention' - - argument :user_id, ::Types::GlobalIDType[::User], - loads: Types::UserType, - required: true, - description: <<~DESC - User ID of the user to request attention. - DESC - - def resolve(project_path:, iid:, user:) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled? - - merge_request = authorized_find!(project_path: project_path, iid: iid) - - result = ::MergeRequests::RequestAttentionService.new( - project: merge_request.project, - current_user: current_user, - merge_request: merge_request, - user: user - ).execute - - { - merge_request: merge_request, - errors: Array(result[:message]) - } - end - - private - - def feature_enabled? - current_user&.mr_attention_requests_enabled? - end - end - end -end diff --git a/app/graphql/mutations/merge_requests/set_reviewers.rb b/app/graphql/mutations/merge_requests/set_reviewers.rb new file mode 100644 index 00000000000..8d3f8601597 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_reviewers.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetReviewers < Base + graphql_name 'MergeRequestSetReviewers' + + argument :reviewer_usernames, + [GraphQL::Types::String], + required: true, + description: 'Usernames of reviewers to assign. Replaces existing reviewers by default.' + + argument :operation_mode, + Types::MutationOperationModeEnum, + required: false, + default_value: Types::MutationOperationModeEnum.default_mode, + description: 'Operation to perform. Defaults to REPLACE.' + + def resolve(project_path:, iid:, reviewer_usernames:, operation_mode:) + resource = authorized_find!(project_path: project_path, iid: iid) + + ::MergeRequests::UpdateReviewersService.new( + project: resource.project, + current_user: current_user, + params: { reviewer_ids: reviewer_ids(resource, reviewer_usernames, operation_mode) } + ).execute(resource) + + { + resource.class.name.underscore.to_sym => resource, + errors: errors_on_object(resource) + } + end + + private + + def reviewer_ids(resource, usernames, mode) + new_reviewers = UsersFinder.new(current_user, username: usernames).execute.to_a + new_reviewer_ids = user_ids(new_reviewers) + + case mode + when 'REPLACE' then new_reviewer_ids + when 'APPEND' then user_ids(resource.reviewers) | new_reviewer_ids + when 'REMOVE' then user_ids(resource.reviewers) - new_reviewer_ids + end + end + + def user_ids(users) + users.map(&:id) + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/toggle_attention_requested.rb b/app/graphql/mutations/merge_requests/toggle_attention_requested.rb deleted file mode 100644 index 8913ca48531..00000000000 --- a/app/graphql/mutations/merge_requests/toggle_attention_requested.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module MergeRequests - class ToggleAttentionRequested < Base - graphql_name 'MergeRequestToggleAttentionRequested' - - argument :user_id, ::Types::GlobalIDType[::User], - loads: Types::UserType, - required: true, - description: <<~DESC - User ID for the user to toggle attention requested. - DESC - - def resolve(project_path:, iid:, user:) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless current_user&.mr_attention_requests_enabled? - - merge_request = authorized_find!(project_path: project_path, iid: iid) - - result = ::MergeRequests::ToggleAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, user: user).execute - - { - merge_request: merge_request, - errors: Array(result[:message]) - } - end - end - end -end diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb index 1b673204213..f48e62af767 100644 --- a/app/graphql/mutations/notes/create/base.rb +++ b/app/graphql/mutations/notes/create/base.rb @@ -21,7 +21,13 @@ module Mutations argument :confidential, GraphQL::Types::Boolean, required: false, - description: 'Confidentiality flag of a note. Default is false.' + description: 'Confidentiality flag of a note. Default is false.', + deprecated: { reason: :renamed, replacement: 'internal', milestone: '15.3' } + + argument :internal, + GraphQL::Types::Boolean, + required: false, + description: 'Internal flag for a note. Default is false.' def resolve(args) noteable = authorized_find!(id: args[:noteable_id]) @@ -49,7 +55,7 @@ module Mutations { noteable: noteable, note: args[:body], - confidential: args[:confidential] + internal: args[:internal] || args[:confidential] } end diff --git a/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb b/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb index e5bb5b6d573..3ccc90c16ae 100644 --- a/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb +++ b/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb @@ -7,14 +7,16 @@ module Mutations include FindsProject argument :project_path, GraphQL::Types::ID, - required: true, - description: 'Full path of the project.' + required: true, + description: 'Full path of the project.' - field :success_path, GraphQL::Types::String, null: true, - description: 'Redirect path to use when the response is successful.' + field :success_path, GraphQL::Types::String, + null: true, + description: 'Redirect path to use when the response is successful.' - field :branch, GraphQL::Types::String, null: true, - description: 'Branch that has the new/modified `.gitlab-ci.yml` file.' + field :branch, GraphQL::Types::String, + null: true, + description: 'Branch that has the new/modified `.gitlab-ci.yml` file.' authorize :push_code diff --git a/app/graphql/mutations/timelogs/base.rb b/app/graphql/mutations/timelogs/base.rb new file mode 100644 index 00000000000..9859f0e7d79 --- /dev/null +++ b/app/graphql/mutations/timelogs/base.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module Timelogs + class Base < Mutations::BaseMutation + field :timelog, + Types::TimelogType, + null: true, + description: 'Timelog.' + + private + + def response(result) + { timelog: result.payload[:timelog], errors: result.errors } + end + end + end +end diff --git a/app/graphql/mutations/timelogs/create.rb b/app/graphql/mutations/timelogs/create.rb new file mode 100644 index 00000000000..bab7508454e --- /dev/null +++ b/app/graphql/mutations/timelogs/create.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Mutations + module Timelogs + class Create < Base + graphql_name 'TimelogCreate' + + argument :time_spent, + GraphQL::Types::String, + required: true, + description: 'Amount of time spent.' + + argument :spent_at, + Types::DateType, + required: true, + description: 'When the time was spent.' + + argument :summary, + GraphQL::Types::String, + required: true, + description: 'Summary of time spent.' + + argument :issuable_id, + ::Types::GlobalIDType[::Issuable], + required: true, + description: 'Global ID of the issuable (Issue, WorkItem or MergeRequest).' + + authorize :create_timelog + + def resolve(issuable_id:, time_spent:, spent_at:, summary:, **args) + issuable = authorized_find!(id: issuable_id) + parsed_time_spent = Gitlab::TimeTrackingFormatter.parse(time_spent) + + result = ::Timelogs::CreateService.new( + issuable, parsed_time_spent, spent_at, summary, current_user + ).execute + + response(result) + end + + private + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: [::Issue, ::WorkItem, ::MergeRequest]).sync + end + end + end +end diff --git a/app/graphql/mutations/timelogs/delete.rb b/app/graphql/mutations/timelogs/delete.rb index 8fd41c27b88..61588d839a7 100644 --- a/app/graphql/mutations/timelogs/delete.rb +++ b/app/graphql/mutations/timelogs/delete.rb @@ -2,14 +2,9 @@ module Mutations module Timelogs - class Delete < Mutations::BaseMutation + class Delete < Base graphql_name 'TimelogDelete' - field :timelog, - Types::TimelogType, - null: true, - description: 'Deleted timelog.' - argument :id, ::Types::GlobalIDType[::Timelog], required: true, @@ -22,11 +17,13 @@ module Mutations result = ::Timelogs::DeleteService.new(timelog, current_user).execute # Return the result payload, not the loaded timelog, so that it returns null in case of unauthorized access - { timelog: result.payload, errors: result.errors } + response(result) end + private + def find_object(id:) - GitlabSchema.find_by_gid(id) + GitlabSchema.object_from_id(id, expected_type: ::Timelog).sync end end end diff --git a/app/graphql/mutations/uploads/delete.rb b/app/graphql/mutations/uploads/delete.rb new file mode 100644 index 00000000000..e2fb967cd2c --- /dev/null +++ b/app/graphql/mutations/uploads/delete.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Mutations + module Uploads + class Delete < BaseMutation + graphql_name 'UploadDelete' + description 'Deletes an upload.' + + include Mutations::ResolvesResourceParent + + authorize :destroy_upload + + argument :secret, GraphQL::Types::String, + required: true, + description: 'Secret part of upload path.' + + argument :filename, GraphQL::Types::String, + required: true, + description: 'Upload filename.' + + field :upload, Types::UploadType, + null: true, + description: 'Deleted upload.' + + def resolve(args) + parent = authorized_resource_parent_find!(args) + + result = ::Uploads::DestroyService.new(parent, current_user).execute(args[:secret], args[:filename]) + + { + upload: result[:status] == :success ? result[:upload] : nil, + errors: Array(result[:message]) + } + end + end + end +end diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb index 350153eaf19..ece00e04ed9 100644 --- a/app/graphql/mutations/work_items/create.rb +++ b/app/graphql/mutations/work_items/create.rb @@ -13,6 +13,9 @@ module Mutations authorize :create_work_item + argument :confidential, GraphQL::Types::Boolean, + required: false, + description: 'Sets the work item confidentiality.' argument :description, GraphQL::Types::String, required: false, description: copy_field_description(Types::WorkItemType, :description) diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb index 5d8c574877a..b4ed0a1a3ca 100644 --- a/app/graphql/mutations/work_items/update.rb +++ b/app/graphql/mutations/work_items/update.rb @@ -51,3 +51,5 @@ module Mutations end end end + +Mutations::WorkItems::Update.prepend_mod diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb index 1f486c47771..ec6ede58cf5 100644 --- a/app/graphql/resolvers/ci/config_resolver.rb +++ b/app/graphql/resolvers/ci/config_resolver.rb @@ -38,8 +38,8 @@ module Resolvers .validate(content, dry_run: dry_run) response(result) - rescue GRPC::InvalidArgument => error - Gitlab::ErrorTracking.track_and_raise_exception(error, sha: sha) + rescue GRPC::InvalidArgument => e + Gitlab::ErrorTracking.track_and_raise_exception(e, sha: sha) end private diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb index 64738608b60..b52a4cc0ab4 100644 --- a/app/graphql/resolvers/ci/runners_resolver.rb +++ b/app/graphql/resolvers/ci/runners_resolver.rb @@ -36,7 +36,7 @@ module Resolvers required: false, description: 'Sort order of results.' - argument :upgrade_status, ::Types::Ci::RunnerUpgradeStatusTypeEnum, + argument :upgrade_status, ::Types::Ci::RunnerUpgradeStatusEnum, required: false, description: 'Filter by upgrade status.' diff --git a/app/graphql/resolvers/ci/template_resolver.rb b/app/graphql/resolvers/ci/template_resolver.rb index 17f2668df11..f2531d877c7 100644 --- a/app/graphql/resolvers/ci/template_resolver.rb +++ b/app/graphql/resolvers/ci/template_resolver.rb @@ -5,8 +5,11 @@ module Resolvers class TemplateResolver < BaseResolver type Types::Ci::TemplateType, null: true - argument :name, GraphQL::Types::String, required: true, - description: 'Name of the CI/CD template to search for. Template must be formatted as `Name.gitlab-ci.yml`.' + argument :name, + GraphQL::Types::String, + required: true, + description: 'Name of the CI/CD template to search for. ' \ + 'Template must be formatted as `Name.gitlab-ci.yml`.' alias_method :project, :object diff --git a/app/graphql/resolvers/crm/contact_state_counts_resolver.rb b/app/graphql/resolvers/crm/contact_state_counts_resolver.rb new file mode 100644 index 00000000000..f696400d44e --- /dev/null +++ b/app/graphql/resolvers/crm/contact_state_counts_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module Crm + class ContactStateCountsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :read_crm_contact + + type Types::CustomerRelations::ContactStateCountsType, null: true + + argument :search, GraphQL::Types::String, + required: false, + description: 'Search term to find contacts with.' + + argument :state, Types::CustomerRelations::ContactStateEnum, + required: false, + description: 'State of the contacts to search for.' + + def resolve(**args) + CustomerRelations::ContactStateCounts.new(context[:current_user], object, args) + end + end + end +end diff --git a/app/graphql/resolvers/crm/contacts_resolver.rb b/app/graphql/resolvers/crm/contacts_resolver.rb index 58d0e2ce13d..a93942cf93b 100644 --- a/app/graphql/resolvers/crm/contacts_resolver.rb +++ b/app/graphql/resolvers/crm/contacts_resolver.rb @@ -10,6 +10,11 @@ module Resolvers type Types::CustomerRelations::ContactType, null: true + argument :sort, Types::CustomerRelations::ContactSortEnum, + description: 'Criteria to sort contacts by.', + required: false, + default_value: { field: 'last_name', direction: :asc } + argument :search, GraphQL::Types::String, required: false, description: 'Search term to find contacts with.' @@ -24,13 +29,25 @@ module Resolvers def resolve(**args) args[:ids] = resolve_ids(args.delete(:ids)) - - ::Crm::ContactsFinder.new(current_user, { group: group }.merge(args)).execute + args.delete(:state) if args[:state] == :all + + contacts = ::Crm::ContactsFinder.new(current_user, { group: group }.merge(args)).execute + if needs_offset?(args) + offset_pagination(contacts) + else + contacts + end end def group object.respond_to?(:sync) ? object.sync : object end + + private + + def needs_offset?(args) + args.key?(:sort) && args[:sort][:field] == 'organization' + end end end end diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb index 1823eb65d44..934c1ba2738 100644 --- a/app/graphql/resolvers/environments_resolver.rb +++ b/app/graphql/resolvers/environments_resolver.rb @@ -22,8 +22,8 @@ module Resolvers return unless project.present? Environments::EnvironmentsFinder.new(project, context[:current_user], args).execute - rescue Environments::EnvironmentsFinder::InvalidStatesError => exception - raise Gitlab::Graphql::Errors::ArgumentError, exception.message + rescue Environments::EnvironmentsFinder::InvalidStatesError => e + raise Gitlab::Graphql::Errors::ArgumentError, e.message end end end diff --git a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb index 27bba6c8144..187063bb8c3 100644 --- a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb +++ b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb @@ -14,7 +14,7 @@ module Resolvers response = ::ErrorTracking::IssueDetailsService.new( project, current_user, - { issue_id: id.model_id } + { issue_id: id.model_id, tracking_event: :error_tracking_view_details } ).execute issue = response[:issue] issue.gitlab_project = project if issue diff --git a/app/graphql/resolvers/group_milestones_resolver.rb b/app/graphql/resolvers/group_milestones_resolver.rb index 319ff9f68c4..9242be7f684 100644 --- a/app/graphql/resolvers/group_milestones_resolver.rb +++ b/app/graphql/resolvers/group_milestones_resolver.rb @@ -45,5 +45,9 @@ module Resolvers options: { include_subgroups: true } ).execute end + + def preloads + super.merge({ subgroup_milestone: :group }) + end end end diff --git a/app/graphql/resolvers/projects/fork_targets_resolver.rb b/app/graphql/resolvers/projects/fork_targets_resolver.rb new file mode 100644 index 00000000000..5e8be325d43 --- /dev/null +++ b/app/graphql/resolvers/projects/fork_targets_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class ForkTargetsResolver < BaseResolver + include ResolvesGroups + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::NamespaceType.connection_type, null: true + + authorize :fork_project + authorizes_object! + + alias_method :project, :object + + argument :search, GraphQL::Types::String, + required: false, + description: 'Search query for path or name.' + + private + + def resolve_groups(**args) + ForkTargetsFinder.new(project, current_user).execute(args) + end + end + end +end diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb index b846248458f..facf8ffe36f 100644 --- a/app/graphql/resolvers/projects_resolver.rb +++ b/app/graphql/resolvers/projects_resolver.rb @@ -25,8 +25,8 @@ module Resolvers description: 'Sort order of results.' argument :topics, type: [GraphQL::Types::String], - required: false, - description: 'Filters projects by topics.' + required: false, + description: 'Filters projects by topics.' def resolve(**args) ProjectsFinder diff --git a/app/graphql/resolvers/users_resolver.rb b/app/graphql/resolvers/users_resolver.rb index b0d704d09fc..90a6bd3e6b2 100644 --- a/app/graphql/resolvers/users_resolver.rb +++ b/app/graphql/resolvers/users_resolver.rb @@ -12,7 +12,7 @@ module Resolvers description: 'List of user Global IDs.' argument :usernames, [GraphQL::Types::String], required: false, - description: 'List of usernames.' + description: 'List of usernames.' argument :sort, Types::SortEnum, description: 'Sort users by this criteria.', diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb index 1bc74131b9e..055984db3cb 100644 --- a/app/graphql/resolvers/work_items_resolver.rb +++ b/app/graphql/resolvers/work_items_resolver.rb @@ -58,3 +58,5 @@ module Resolvers end end end + +Resolvers::WorkItemsResolver.prepend_mod_with('Resolvers::WorkItemsResolver') diff --git a/app/graphql/types/access_level_type.rb b/app/graphql/types/access_level_type.rb index 2d97f6b30e8..4a709aa4711 100644 --- a/app/graphql/types/access_level_type.rb +++ b/app/graphql/types/access_level_type.rb @@ -7,11 +7,11 @@ module Types description 'Represents the access level of a relationship between a User and object that it is related to' field :integer_value, GraphQL::Types::Int, null: true, - description: 'Integer representation of access level.', - method: :to_i + description: 'Integer representation of access level.', + method: :to_i field :string_value, Types::AccessLevelEnum, null: true, - description: 'String representation of access level.', - method: :to_i + description: 'String representation of access level.', + method: :to_i end end diff --git a/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb b/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb index 1fc47303d67..0da0a6bcd1a 100644 --- a/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb +++ b/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb @@ -13,12 +13,14 @@ module Types authorize :read_usage_trends_measurement field :recorded_at, Types::TimeType, null: true, - description: 'Time the measurement was recorded.' + description: 'Time the measurement was recorded.' field :count, GraphQL::Types::Int, null: false, - description: 'Object count.' + description: 'Object count.' - field :identifier, Types::Admin::Analytics::UsageTrends::MeasurementIdentifierEnum, null: false, + field :identifier, + Types::Admin::Analytics::UsageTrends::MeasurementIdentifierEnum, + null: false, description: 'Type of objects being measured.' end end diff --git a/app/graphql/types/alert_management/domain_filter_enum.rb b/app/graphql/types/alert_management/domain_filter_enum.rb index cd70cdd8ecf..86da345fd69 100644 --- a/app/graphql/types/alert_management/domain_filter_enum.rb +++ b/app/graphql/types/alert_management/domain_filter_enum.rb @@ -7,11 +7,12 @@ module Types description 'Filters the alerts based on given domain' value 'operations', description: 'Alerts for operations domain.' - value 'threat_monitoring', description: 'Alerts for threat monitoring domain.', - deprecated: { - reason: 'Network policies are deprecated and will be removed in GitLab 16.0', - milestone: '15.0' - } + value 'threat_monitoring', + description: 'Alerts for threat monitoring domain.', + deprecated: { + reason: 'Network policies are deprecated and will be removed in GitLab 16.0', + milestone: '15.0' + } end end end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 6aee9a5c052..1c43432594a 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -17,7 +17,7 @@ module Types @requires_argument = !!kwargs.delete(:requires_argument) @authorize = Array.wrap(kwargs.delete(:authorize)) kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity]) - @feature_flag = kwargs[:feature_flag] + @feature_flag = kwargs[:_deprecated_feature_flag] kwargs = check_feature_flag(kwargs) @deprecation = gitlab_deprecation(kwargs) after_connection_extensions = kwargs.delete(:late_extensions) || [] @@ -136,7 +136,7 @@ module Types end def check_feature_flag(args) - ff = args.delete(:feature_flag) + ff = args.delete(:_deprecated_feature_flag) return args unless ff.present? args[:description] = feature_documentation_message(ff, args[:description]) diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb index 7f4c49df429..2352a21bd87 100644 --- a/app/graphql/types/board_list_type.rb +++ b/app/graphql/types/board_list_type.rb @@ -15,19 +15,21 @@ module Types description: 'ID (global ID) of the list.' field :collapsed, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the list is collapsed for this user.' + description: 'Indicates if the list is collapsed for this user.' field :issues_count, GraphQL::Types::Int, null: true, - description: 'Count of issues in the list.' + description: 'Count of issues in the list.' field :label, Types::LabelType, null: true, - description: 'Label of the list.' + description: 'Label of the list.' field :list_type, GraphQL::Types::String, null: false, - description: 'Type of the list.' + description: 'Type of the list.' field :position, GraphQL::Types::Int, null: true, - description: 'Position of list within the board.' + description: 'Position of list within the board.' field :title, GraphQL::Types::String, null: false, - description: 'Title of the list.' + description: 'Title of the list.' - field :issues, ::Types::IssueType.connection_type, null: true, + field :issues, + ::Types::IssueType.connection_type, + null: true, description: 'Board issues.', late_extensions: [Gitlab::Graphql::Board::IssuesConnectionExtension], resolver: ::Resolvers::BoardListIssuesResolver diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb index 4ec9a8a9c63..00638988989 100644 --- a/app/graphql/types/board_type.rb +++ b/app/graphql/types/board_type.rb @@ -10,21 +10,21 @@ module Types present_using BoardPresenter field :id, type: GraphQL::Types::ID, null: false, - description: 'ID (global ID) of the board.' + description: 'ID (global ID) of the board.' field :name, type: GraphQL::Types::String, null: true, - description: 'Name of the board.' + description: 'Name of the board.' field :hide_backlog_list, type: GraphQL::Types::Boolean, null: true, - description: 'Whether or not backlog list is hidden.' + description: 'Whether or not backlog list is hidden.' field :hide_closed_list, type: GraphQL::Types::Boolean, null: true, - description: 'Whether or not closed list is hidden.' + description: 'Whether or not closed list is hidden.' field :created_at, Types::TimeType, null: false, - description: 'Timestamp of when the board was created.' + description: 'Timestamp of when the board was created.' field :updated_at, Types::TimeType, null: false, - description: 'Timestamp of when the board was last updated.' + description: 'Timestamp of when the board was last updated.' field :lists, Types::BoardListType.connection_type, @@ -34,10 +34,10 @@ module Types extras: [:lookahead] field :web_path, GraphQL::Types::String, null: false, - description: 'Web path of the board.' + description: 'Web path of the board.' field :web_url, GraphQL::Types::String, null: false, - description: 'Web URL of the board.' + description: 'Web URL of the board.' end end diff --git a/app/graphql/types/ci/analytics_type.rb b/app/graphql/types/ci/analytics_type.rb index a77b8026f86..6a55a6138ea 100644 --- a/app/graphql/types/ci/analytics_type.rb +++ b/app/graphql/types/ci/analytics_type.rb @@ -7,27 +7,27 @@ module Types graphql_name 'PipelineAnalytics' field :month_pipelines_labels, [GraphQL::Types::String], null: true, - description: 'Labels for the monthly pipeline count.' + description: 'Labels for the monthly pipeline count.' field :month_pipelines_successful, [GraphQL::Types::Int], null: true, - description: 'Total monthly successful pipeline count.' + description: 'Total monthly successful pipeline count.' field :month_pipelines_totals, [GraphQL::Types::Int], null: true, - description: 'Total monthly pipeline count.' + description: 'Total monthly pipeline count.' field :pipeline_times_labels, [GraphQL::Types::String], null: true, - description: 'Pipeline times labels.' + description: 'Pipeline times labels.' field :pipeline_times_values, [GraphQL::Types::Int], null: true, - description: 'Pipeline times.' + description: 'Pipeline times.' field :week_pipelines_labels, [GraphQL::Types::String], null: true, - description: 'Labels for the weekly pipeline count.' + description: 'Labels for the weekly pipeline count.' field :week_pipelines_successful, [GraphQL::Types::Int], null: true, - description: 'Total weekly successful pipeline count.' + description: 'Total weekly successful pipeline count.' field :week_pipelines_totals, [GraphQL::Types::Int], null: true, - description: 'Total weekly pipeline count.' + description: 'Total weekly pipeline count.' field :year_pipelines_labels, [GraphQL::Types::String], null: true, - description: 'Labels for the yearly pipeline count.' + description: 'Labels for the yearly pipeline count.' field :year_pipelines_successful, [GraphQL::Types::Int], null: true, - description: 'Total yearly successful pipeline count.' + description: 'Total yearly successful pipeline count.' field :year_pipelines_totals, [GraphQL::Types::Int], null: true, - description: 'Total yearly pipeline count.' + description: 'Total yearly pipeline count.' end end end diff --git a/app/graphql/types/ci/application_setting_type.rb b/app/graphql/types/ci/application_setting_type.rb index 2322778d159..53202c56f03 100644 --- a/app/graphql/types/ci/application_setting_type.rb +++ b/app/graphql/types/ci/application_setting_type.rb @@ -8,7 +8,7 @@ module Types authorize :read_application_setting field :keep_latest_artifact, GraphQL::Types::Boolean, null: true, - description: 'Whether to keep the latest jobs artifacts.' + description: 'Whether to keep the latest jobs artifacts.' end end end diff --git a/app/graphql/types/ci/build_need_type.rb b/app/graphql/types/ci/build_need_type.rb index b71d10c4c06..4ab711881fe 100644 --- a/app/graphql/types/ci/build_need_type.rb +++ b/app/graphql/types/ci/build_need_type.rb @@ -8,9 +8,9 @@ module Types graphql_name 'CiBuildNeed' field :id, GraphQL::Types::ID, null: false, - description: 'ID of the BuildNeed.' + description: 'ID of the BuildNeed.' field :name, GraphQL::Types::String, null: true, - description: 'Name of the job we need to complete.' + description: 'Name of the job we need to complete.' end end end diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb index e43af6f3e78..bec8c72e783 100644 --- a/app/graphql/types/ci/ci_cd_setting_type.rb +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -7,20 +7,22 @@ module Types authorize :admin_project - field :job_token_scope_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates CI job tokens generated in this project have restricted access to resources.', - method: :job_token_scope_enabled? + field :job_token_scope_enabled, + GraphQL::Types::Boolean, + null: true, + description: 'Indicates CI job tokens generated in this project have restricted access to resources.', + method: :job_token_scope_enabled? field :keep_latest_artifact, GraphQL::Types::Boolean, null: true, - description: 'Whether to keep the latest builds artifacts.', - method: :keep_latest_artifacts_available? + description: 'Whether to keep the latest builds artifacts.', + method: :keep_latest_artifacts_available? field :merge_pipelines_enabled, GraphQL::Types::Boolean, null: true, - description: 'Whether merge pipelines are enabled.', - method: :merge_pipelines_enabled? + description: 'Whether merge pipelines are enabled.', + method: :merge_pipelines_enabled? field :merge_trains_enabled, GraphQL::Types::Boolean, null: true, - description: 'Whether merge trains are enabled.', - method: :merge_trains_enabled? + description: 'Whether merge trains are enabled.', + method: :merge_trains_enabled? field :project, Types::ProjectType, null: true, - description: 'Project the CI/CD settings belong to.' + description: 'Project the CI/CD settings belong to.' end end end diff --git a/app/graphql/types/ci/config/config_type.rb b/app/graphql/types/ci/config/config_type.rb index a7a6927136d..6ccc62331df 100644 --- a/app/graphql/types/ci/config/config_type.rb +++ b/app/graphql/types/ci/config/config_type.rb @@ -8,17 +8,17 @@ module Types graphql_name 'CiConfig' field :errors, [GraphQL::Types::String], null: true, - description: 'Linting errors.' + description: 'Linting errors.' field :includes, [Types::Ci::Config::IncludeType], null: true, - description: 'List of included files.' + description: 'List of included files.' field :merged_yaml, GraphQL::Types::String, null: true, - description: 'Merged CI configuration YAML.' + description: 'Merged CI configuration YAML.' field :stages, Types::Ci::Config::StageType.connection_type, null: true, - description: 'Stages of the pipeline.' + description: 'Stages of the pipeline.' field :status, Types::Ci::Config::StatusEnum, null: true, - description: 'Status of linting, can be either valid or invalid.' + description: 'Status of linting, can be either valid or invalid.' field :warnings, [GraphQL::Types::String], null: true, - description: 'Linting warnings.' + description: 'Linting warnings.' end end end diff --git a/app/graphql/types/ci/config/group_type.rb b/app/graphql/types/ci/config/group_type.rb index 19076fe9c20..8c4a0c04a2a 100644 --- a/app/graphql/types/ci/config/group_type.rb +++ b/app/graphql/types/ci/config/group_type.rb @@ -8,11 +8,11 @@ module Types graphql_name 'CiConfigGroup' field :jobs, Types::Ci::Config::JobType.connection_type, null: true, - description: 'Jobs in group.' + description: 'Jobs in group.' field :name, GraphQL::Types::String, null: true, - description: 'Name of the job group.' + description: 'Name of the job group.' field :size, GraphQL::Types::Int, null: true, - description: 'Size of the job group.' + description: 'Size of the job group.' end end end diff --git a/app/graphql/types/ci/config/job_restriction_type.rb b/app/graphql/types/ci/config/job_restriction_type.rb index 8cf0e210def..bb9c03f7c1e 100644 --- a/app/graphql/types/ci/config/job_restriction_type.rb +++ b/app/graphql/types/ci/config/job_restriction_type.rb @@ -8,7 +8,7 @@ module Types graphql_name 'CiConfigJobRestriction' field :refs, [GraphQL::Types::String], null: true, - description: 'Git refs the job restriction applies to.' + description: 'Git refs the job restriction applies to.' end end end diff --git a/app/graphql/types/ci/config/job_type.rb b/app/graphql/types/ci/config/job_type.rb index 20279143635..fb92a37dee6 100644 --- a/app/graphql/types/ci/config/job_type.rb +++ b/app/graphql/types/ci/config/job_type.rb @@ -7,33 +7,41 @@ module Types class JobType < BaseObject graphql_name 'CiConfigJob' - field :after_script, [GraphQL::Types::String], null: true, + field :after_script, + [GraphQL::Types::String], + null: true, description: 'Override a set of commands that are executed after the job.' field :allow_failure, GraphQL::Types::Boolean, null: true, - description: 'Allow job to fail.' - field :before_script, [GraphQL::Types::String], null: true, + description: 'Allow job to fail.' + field :before_script, + [GraphQL::Types::String], + null: true, description: 'Override a set of commands that are executed before the job.' field :environment, GraphQL::Types::String, null: true, - description: 'Name of an environment to which the job deploys.' + description: 'Name of an environment to which the job deploys.' field :except, Types::Ci::Config::JobRestrictionType, null: true, - description: 'Limit when jobs are not created.' + description: 'Limit when jobs are not created.' field :group_name, GraphQL::Types::String, null: true, - description: 'Name of the job group.' + description: 'Name of the job group.' field :name, GraphQL::Types::String, null: true, - description: 'Name of the job.' - field :needs, Types::Ci::Config::NeedType.connection_type, null: true, + description: 'Name of the job.' + field :needs, + Types::Ci::Config::NeedType.connection_type, + null: true, description: 'Builds that must complete before the jobs run.' - field :only, Types::Ci::Config::JobRestrictionType, null: true, - description: 'Jobs are created when these conditions do not apply.' + field :only, + Types::Ci::Config::JobRestrictionType, + null: true, + description: 'Jobs are created when these conditions do not apply.' field :script, [GraphQL::Types::String], null: true, - description: 'Shell script that is executed by a runner.' + description: 'Shell script that is executed by a runner.' field :stage, GraphQL::Types::String, null: true, - description: 'Name of the job stage.' + description: 'Name of the job stage.' field :tags, [GraphQL::Types::String], null: true, - description: 'List of tags that are used to select a runner.' + description: 'List of tags that are used to select a runner.' field :when, GraphQL::Types::String, null: true, - description: 'When to run the job.', - resolver_method: :restrict_when_to_run_jobs + description: 'When to run the job.', + resolver_method: :restrict_when_to_run_jobs def restrict_when_to_run_jobs object[:when] diff --git a/app/graphql/types/ci/config/need_type.rb b/app/graphql/types/ci/config/need_type.rb index 6e9aea8eb64..dd262923246 100644 --- a/app/graphql/types/ci/config/need_type.rb +++ b/app/graphql/types/ci/config/need_type.rb @@ -8,7 +8,7 @@ module Types graphql_name 'CiConfigNeed' field :name, GraphQL::Types::String, null: true, - description: 'Name of the need.' + description: 'Name of the need.' end end end diff --git a/app/graphql/types/ci/config/stage_type.rb b/app/graphql/types/ci/config/stage_type.rb index 5b1163edac2..4dacba2f1ed 100644 --- a/app/graphql/types/ci/config/stage_type.rb +++ b/app/graphql/types/ci/config/stage_type.rb @@ -8,9 +8,9 @@ module Types graphql_name 'CiConfigStage' field :groups, Types::Ci::Config::GroupType.connection_type, null: true, - description: 'Groups of jobs for the stage.' + description: 'Groups of jobs for the stage.' field :name, GraphQL::Types::String, null: true, - description: 'Name of the stage.' + description: 'Name of the stage.' end end end diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index 3fab040cc0b..8bc50e974bb 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -6,31 +6,33 @@ module Types class DetailedStatusType < BaseObject graphql_name 'DetailedStatus' - field :action, Types::Ci::StatusActionType, null: true, + field :action, + Types::Ci::StatusActionType, + null: true, calls_gitaly: true, description: 'Action information for the status. This includes method, button title, icon, path, and title.' field :details_path, GraphQL::Types::String, null: true, - description: 'Path of the details for the status.' + description: 'Path of the details for the status.' field :favicon, GraphQL::Types::String, null: true, - description: 'Favicon of the status.' + description: 'Favicon of the status.' field :group, GraphQL::Types::String, null: true, - description: 'Group of the status.' + description: 'Group of the status.' field :has_details, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the status has further details.', - method: :has_details? + description: 'Indicates if the status has further details.', + method: :has_details? field :icon, GraphQL::Types::String, null: true, - description: 'Icon of the status.' + description: 'Icon of the status.' field :id, GraphQL::Types::String, null: false, - description: 'ID for a detailed status.', - extras: [:parent] + description: 'ID for a detailed status.', + extras: [:parent] field :label, GraphQL::Types::String, null: true, - calls_gitaly: true, - description: 'Label of the status.' + calls_gitaly: true, + description: 'Label of the status.' field :text, GraphQL::Types::String, null: true, - description: 'Text of the status.' + description: 'Text of the status.' field :tooltip, GraphQL::Types::String, null: true, - description: 'Tooltip associated with the status.', - method: :status_tooltip + description: 'Tooltip associated with the status.', + method: :status_tooltip def id(parent:) "#{object.id}-#{parent.id}" diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb index c3c73ef170c..f2150fa5e1a 100644 --- a/app/graphql/types/ci/group_type.rb +++ b/app/graphql/types/ci/group_type.rb @@ -7,15 +7,15 @@ module Types graphql_name 'CiGroup' field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the group.' + description: 'Detailed status of the group.' field :id, GraphQL::Types::String, null: false, - description: 'ID for a group.' + description: 'ID for a group.' field :jobs, Ci::JobType.connection_type, null: true, - description: 'Jobs in group.' + description: 'Jobs in group.' field :name, GraphQL::Types::String, null: true, - description: 'Name of the job group.' + description: 'Name of the job group.' field :size, GraphQL::Types::Int, null: true, - description: 'Size of the group.' + description: 'Size of the group.' def detailed_status object.detailed_status(context[:current_user]) diff --git a/app/graphql/types/ci/group_variable_type.rb b/app/graphql/types/ci/group_variable_type.rb new file mode 100644 index 00000000000..3322f741342 --- /dev/null +++ b/app/graphql/types/ci/group_variable_type.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class GroupVariableType < BaseObject + graphql_name 'CiGroupVariable' + description 'CI/CD variables for a group.' + + implements(VariableInterface) + + field :environment_scope, GraphQL::Types::String, + null: true, + description: 'Scope defining the environments that can use the variable.' + + field :protected, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the variable is protected.' + + field :masked, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the variable is masked.' + end + end +end diff --git a/app/graphql/types/ci/instance_variable_type.rb b/app/graphql/types/ci/instance_variable_type.rb new file mode 100644 index 00000000000..f564a2f59a0 --- /dev/null +++ b/app/graphql/types/ci/instance_variable_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class InstanceVariableType < BaseObject + graphql_name 'CiInstanceVariable' + description 'CI/CD variables for a GitLab instance.' + + implements(VariableInterface) + + field :environment_scope, GraphQL::Types::String, + null: true, + deprecated: { + reason: 'No longer used, only available for GroupVariableType and ProjectVariableType', + milestone: '15.3' + }, + description: 'Scope defining the environments that can use the variable.' + + field :protected, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the variable is protected.' + + field :masked, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the variable is masked.' + + def environment_scope + nil + end + end + end +end diff --git a/app/graphql/types/ci/job_artifact_type.rb b/app/graphql/types/ci/job_artifact_type.rb index 69bb5325dba..a6ab445702c 100644 --- a/app/graphql/types/ci/job_artifact_type.rb +++ b/app/graphql/types/ci/job_artifact_type.rb @@ -7,14 +7,14 @@ module Types graphql_name 'CiJobArtifact' field :download_path, GraphQL::Types::String, null: true, - description: "URL for downloading the artifact's file." + description: "URL for downloading the artifact's file." field :file_type, ::Types::Ci::JobArtifactFileTypeEnum, null: true, - description: 'File type of the artifact.' + description: 'File type of the artifact.' field :name, GraphQL::Types::String, null: true, - description: 'File name of the artifact.', - method: :filename + description: 'File name of the artifact.', + method: :filename def download_path ::Gitlab::Routing.url_helpers.download_project_job_artifacts_path( diff --git a/app/graphql/types/ci/job_token_scope_type.rb b/app/graphql/types/ci/job_token_scope_type.rb index 9f48298e1d3..37c0af944a7 100644 --- a/app/graphql/types/ci/job_token_scope_type.rb +++ b/app/graphql/types/ci/job_token_scope_type.rb @@ -7,9 +7,11 @@ module Types class JobTokenScopeType < BaseObject graphql_name 'CiJobTokenScopeType' - field :projects, Types::ProjectType.connection_type, null: false, - description: 'Allow list of projects that can be accessed by CI Job tokens created by this project.', - method: :all_projects + field :projects, + Types::ProjectType.connection_type, + null: false, + description: 'Allow list of projects that can be accessed by CI Job tokens created by this project.', + method: :all_projects end end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 42b55f47f92..4ea9a016e74 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -12,39 +12,39 @@ module Types expose_permissions Types::PermissionTypes::Ci::Job field :allow_failure, ::GraphQL::Types::Boolean, null: false, - description: 'Whether the job is allowed to fail.' + description: 'Whether the job is allowed to fail.' field :duration, GraphQL::Types::Int, null: true, - description: 'Duration of the job in seconds.' + description: 'Duration of the job in seconds.' field :id, ::Types::GlobalIDType[::CommitStatus].as('JobID'), null: true, - description: 'ID of the job.' + description: 'ID of the job.' field :kind, type: ::Types::Ci::JobKindEnum, null: false, - description: 'Indicates the type of job.' + description: 'Indicates the type of job.' field :name, GraphQL::Types::String, null: true, - description: 'Name of the job.' + description: 'Name of the job.' field :needs, BuildNeedType.connection_type, null: true, - description: 'References to builds that must complete before the jobs run.' + description: 'References to builds that must complete before the jobs run.' field :pipeline, Types::Ci::PipelineType, null: true, - description: 'Pipeline the job belongs to.' + description: 'Pipeline the job belongs to.' field :stage, Types::Ci::StageType, null: true, - description: 'Stage of the job.' + description: 'Stage of the job.' field :status, type: ::Types::Ci::JobStatusEnum, null: true, description: "Status of the job." field :tags, [GraphQL::Types::String], null: true, - description: 'Tags for the current job.' + description: 'Tags for the current job.' # Life-cycle timestamps: field :created_at, Types::TimeType, null: false, - description: "When the job was created." + description: "When the job was created." field :finished_at, Types::TimeType, null: true, - description: 'When a job has finished running.' + description: 'When a job has finished running.' field :queued_at, Types::TimeType, null: true, - description: 'When the job was enqueued and marked as pending.' + description: 'When the job was enqueued and marked as pending.' field :scheduled_at, Types::TimeType, null: true, - description: 'Schedule for the build.' + description: 'Schedule for the build.' field :started_at, Types::TimeType, null: true, - description: 'When the job was started.' + description: 'When the job was started.' # Life-cycle durations: field :queued_duration, @@ -53,45 +53,45 @@ module Types description: 'How long the job was enqueued before starting.' field :active, GraphQL::Types::Boolean, null: false, method: :active?, - description: 'Indicates the job is active.' + description: 'Indicates the job is active.' field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true, - description: 'Artifacts generated by the job.' + description: 'Artifacts generated by the job.' field :cancelable, GraphQL::Types::Boolean, null: false, method: :cancelable?, - description: 'Indicates the job can be canceled.' + description: 'Indicates the job can be canceled.' field :commit_path, GraphQL::Types::String, null: true, - description: 'Path to the commit that triggered the job.' + description: 'Path to the commit that triggered the job.' field :coverage, GraphQL::Types::Float, null: true, - description: 'Coverage level of the job.' + description: 'Coverage level of the job.' field :created_by_tag, GraphQL::Types::Boolean, null: false, - description: 'Whether the job was created by a tag.', method: :tag? + description: 'Whether the job was created by a tag.', method: :tag? field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the job.' + description: 'Detailed status of the job.' field :downstream_pipeline, Types::Ci::PipelineType, null: true, - description: 'Downstream pipeline for a bridge.' + description: 'Downstream pipeline for a bridge.' field :manual_job, GraphQL::Types::Boolean, null: true, - description: 'Whether the job has a manual action.' - field :manual_variables, VariableType.connection_type, null: true, - description: 'Variables added to a manual job when the job is triggered.' + description: 'Whether the job has a manual action.' + field :manual_variables, ManualVariableType.connection_type, null: true, + description: 'Variables added to a manual job when the job is triggered.' field :playable, GraphQL::Types::Boolean, null: false, method: :playable?, - description: 'Indicates the job can be played.' + description: 'Indicates the job can be played.' field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true, - description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.' + description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.' field :ref_name, GraphQL::Types::String, null: true, - description: 'Ref name of the job.' + description: 'Ref name of the job.' field :ref_path, GraphQL::Types::String, null: true, - description: 'Path to the ref.' + description: 'Path to the ref.' field :retried, GraphQL::Types::Boolean, null: true, - description: 'Indicates that the job has been retried.' + description: 'Indicates that the job has been retried.' field :retryable, GraphQL::Types::Boolean, null: false, method: :retryable?, - description: 'Indicates the job can be retried.' + description: 'Indicates the job can be retried.' field :scheduling_type, GraphQL::Types::String, null: true, - description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.' + description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.' field :short_sha, type: GraphQL::Types::String, null: false, - description: 'Short SHA1 ID of the commit.' + description: 'Short SHA1 ID of the commit.' field :stuck, GraphQL::Types::Boolean, null: false, method: :stuck?, - description: 'Indicates the job is stuck.' + description: 'Indicates the job is stuck.' field :triggered, GraphQL::Types::Boolean, null: true, - description: 'Whether the job was triggered.' + description: 'Whether the job was triggered.' def kind return ::Ci::Build unless [::Ci::Build, ::Ci::Bridge].include?(object.class) @@ -194,7 +194,7 @@ module Types end def manual_variables - if object.manual? && object.respond_to?(:job_variables) + if object.action? && object.respond_to?(:job_variables) object.job_variables else [] diff --git a/app/graphql/types/ci/manual_variable_type.rb b/app/graphql/types/ci/manual_variable_type.rb new file mode 100644 index 00000000000..d6f59c1d249 --- /dev/null +++ b/app/graphql/types/ci/manual_variable_type.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class ManualVariableType < BaseObject + graphql_name 'CiManualVariable' + description 'CI/CD variables given to a manual job.' + + implements(VariableInterface) + + field :environment_scope, GraphQL::Types::String, + null: true, + deprecated: { + reason: 'No longer used, only available for GroupVariableType and ProjectVariableType', + milestone: '15.3' + }, + description: 'Scope defining the environments that can use the variable.' + + def environment_scope + nil + end + end + end +end diff --git a/app/graphql/types/ci/pipeline_message_type.rb b/app/graphql/types/ci/pipeline_message_type.rb index 7edea1901a1..35164b0894a 100644 --- a/app/graphql/types/ci/pipeline_message_type.rb +++ b/app/graphql/types/ci/pipeline_message_type.rb @@ -7,10 +7,10 @@ module Types graphql_name 'PipelineMessage' field :id, GraphQL::Types::ID, null: false, - description: 'ID of the pipeline message.' + description: 'ID of the pipeline message.' field :content, GraphQL::Types::String, null: false, - description: 'Content of the pipeline message.' + description: 'Content of the pipeline message.' end end end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index 60418fec6c5..4a523f2edd9 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -13,14 +13,14 @@ module Types expose_permissions Types::PermissionTypes::Ci::Pipeline field :id, GraphQL::Types::ID, null: false, - description: 'ID of the pipeline.' + description: 'ID of the pipeline.' field :iid, GraphQL::Types::String, null: false, - description: 'Internal ID of the pipeline.' + description: 'Internal ID of the pipeline.' field :sha, GraphQL::Types::String, null: true, - method: :sha, - description: "SHA of the pipeline's commit." do + method: :sha, + description: "SHA of the pipeline's commit." do argument :format, type: Types::ShaFormatEnum, required: false, @@ -28,46 +28,46 @@ module Types end field :before_sha, GraphQL::Types::String, null: true, - description: 'Base SHA of the source branch.' + description: 'Base SHA of the source branch.' field :complete, GraphQL::Types::Boolean, null: false, method: :complete?, - description: 'Indicates if a pipeline is complete.' + description: 'Indicates if a pipeline is complete.' field :status, PipelineStatusEnum, null: false, - description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})" + description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})" field :warnings, GraphQL::Types::Boolean, null: false, method: :has_warnings?, - description: "Indicates if a pipeline has warnings." + description: "Indicates if a pipeline has warnings." field :detailed_status, Types::Ci::DetailedStatusType, null: false, - description: 'Detailed status of the pipeline.' + description: 'Detailed status of the pipeline.' field :config_source, PipelineConfigSourceEnum, null: true, - description: "Configuration source of the pipeline (#{::Enums::Ci::Pipeline.config_sources.keys.join(', ').upcase})" + description: "Configuration source of the pipeline (#{::Enums::Ci::Pipeline.config_sources.keys.join(', ').upcase})" field :duration, GraphQL::Types::Int, null: true, - description: 'Duration of the pipeline in seconds.' + description: 'Duration of the pipeline in seconds.' field :queued_duration, Types::DurationType, null: true, - description: 'How long the pipeline was queued before starting.' + description: 'How long the pipeline was queued before starting.' field :coverage, GraphQL::Types::Float, null: true, - description: 'Coverage percentage.' + description: 'Coverage percentage.' field :created_at, Types::TimeType, null: false, - description: "Timestamp of the pipeline's creation." + description: "Timestamp of the pipeline's creation." field :updated_at, Types::TimeType, null: false, - description: "Timestamp of the pipeline's last activity." + description: "Timestamp of the pipeline's last activity." field :started_at, Types::TimeType, null: true, - description: 'Timestamp when the pipeline was started.' + description: 'Timestamp when the pipeline was started.' field :finished_at, Types::TimeType, null: true, - description: "Timestamp of the pipeline's completion." + description: "Timestamp of the pipeline's completion." field :committed_at, Types::TimeType, null: true, - description: "Timestamp of the pipeline's commit." + description: "Timestamp of the pipeline's commit." field :stages, type: Types::Ci::StageType.connection_type, @@ -126,32 +126,32 @@ module Types description: 'Job where pipeline was triggered from.' field :downstream, Types::Ci::PipelineType.connection_type, null: true, - description: 'Pipelines this pipeline will trigger.', - method: :triggered_pipelines_with_preloads + description: 'Pipelines this pipeline will trigger.', + method: :triggered_pipelines_with_preloads field :upstream, Types::Ci::PipelineType, null: true, - description: 'Pipeline that triggered the pipeline.', - method: :triggered_by_pipeline + description: 'Pipeline that triggered the pipeline.', + method: :triggered_by_pipeline field :path, GraphQL::Types::String, null: true, - description: "Relative path to the pipeline's page." + description: "Relative path to the pipeline's page." field :commit, Types::CommitType, null: true, - description: "Git commit of the pipeline.", - calls_gitaly: true + description: "Git commit of the pipeline.", + calls_gitaly: true field :commit_path, GraphQL::Types::String, null: true, - description: 'Path to the commit that triggered the pipeline.' + description: 'Path to the commit that triggered the pipeline.' field :project, Types::ProjectType, null: true, - description: 'Project the pipeline belongs to.' + description: 'Project the pipeline belongs to.' field :active, GraphQL::Types::Boolean, null: false, method: :active?, - description: 'Indicates if the pipeline is active.' + description: 'Indicates if the pipeline is active.' field :uses_needs, GraphQL::Types::Boolean, null: true, - method: :uses_needs?, - description: 'Indicates if the pipeline has jobs with `needs` dependencies.' + method: :uses_needs?, + description: 'Indicates if the pipeline has jobs with `needs` dependencies.' field :test_report_summary, Types::Ci::TestReportSummaryType, @@ -166,17 +166,17 @@ module Types resolver: Resolvers::Ci::TestSuiteResolver field :ref, GraphQL::Types::String, null: true, - description: 'Reference to the branch from which the pipeline was triggered.' + description: 'Reference to the branch from which the pipeline was triggered.' field :ref_path, GraphQL::Types::String, null: true, - description: 'Reference path to the branch from which the pipeline was triggered.', - method: :source_ref_path + description: 'Reference path to the branch from which the pipeline was triggered.', + method: :source_ref_path field :warning_messages, [Types::Ci::PipelineMessageType], null: true, - description: 'Pipeline warning messages.' + description: 'Pipeline warning messages.' field :merge_request_event_type, Types::Ci::PipelineMergeRequestEventTypeEnum, null: true, - description: "Event type of the pipeline associated with a merge request." + description: "Event type of the pipeline associated with a merge request." def detailed_status object.detailed_status(current_user) @@ -200,7 +200,7 @@ module Types if id pipeline.statuses.id_in(id.model_id) else - pipeline.statuses.by_name(name) + pipeline.latest_statuses.by_name(name) end.take # rubocop: disable CodeReuse/ActiveRecord end diff --git a/app/graphql/types/ci/project_variable_type.rb b/app/graphql/types/ci/project_variable_type.rb new file mode 100644 index 00000000000..625bb7fd4b1 --- /dev/null +++ b/app/graphql/types/ci/project_variable_type.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class ProjectVariableType < BaseObject + graphql_name 'CiProjectVariable' + description 'CI/CD variables for a project.' + + implements(VariableInterface) + + field :environment_scope, GraphQL::Types::String, + null: true, + description: 'Scope defining the environments that can use the variable.' + + field :protected, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the variable is protected.' + + field :masked, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the variable is masked.' + end + end +end diff --git a/app/graphql/types/ci/recent_failures_type.rb b/app/graphql/types/ci/recent_failures_type.rb index f56b0939086..0892cb2735c 100644 --- a/app/graphql/types/ci/recent_failures_type.rb +++ b/app/graphql/types/ci/recent_failures_type.rb @@ -10,10 +10,10 @@ module Types connection_type_class(Types::CountableConnectionType) field :count, GraphQL::Types::Int, null: true, - description: 'Number of times the test case has failed in the past 14 days.' + description: 'Number of times the test case has failed in the past 14 days.' field :base_branch, GraphQL::Types::String, null: true, - description: 'Name of the base branch of the project.' + description: 'Name of the base branch of the project.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/runner_architecture_type.rb b/app/graphql/types/ci/runner_architecture_type.rb index eb576cf09ce..8b0558fc6a6 100644 --- a/app/graphql/types/ci/runner_architecture_type.rb +++ b/app/graphql/types/ci/runner_architecture_type.rb @@ -6,10 +6,12 @@ module Types class RunnerArchitectureType < BaseObject graphql_name 'RunnerArchitecture' - field :download_location, GraphQL::Types::String, null: false, - description: 'Download location for the runner for the platform architecture.' + field :download_location, + GraphQL::Types::String, + null: false, + description: 'Download location for the runner for the platform architecture.' field :name, GraphQL::Types::String, null: false, - description: 'Name of the runner platform architecture.' + description: 'Name of the runner platform architecture.' end end end diff --git a/app/graphql/types/ci/runner_platform_type.rb b/app/graphql/types/ci/runner_platform_type.rb index 3c893615b20..1e481cc08bf 100644 --- a/app/graphql/types/ci/runner_platform_type.rb +++ b/app/graphql/types/ci/runner_platform_type.rb @@ -6,12 +6,14 @@ module Types class RunnerPlatformType < BaseObject graphql_name 'RunnerPlatform' - field :architectures, Types::Ci::RunnerArchitectureType.connection_type, null: true, - description: 'Runner architectures supported for the platform.' + field :architectures, + Types::Ci::RunnerArchitectureType.connection_type, + null: true, + description: 'Runner architectures supported for the platform.' field :human_readable_name, GraphQL::Types::String, null: false, - description: 'Human readable name of the runner platform.' + description: 'Human readable name of the runner platform.' field :name, GraphQL::Types::String, null: false, - description: 'Name slug of the runner platform.' + description: 'Name slug of the runner platform.' end end end diff --git a/app/graphql/types/ci/runner_setup_type.rb b/app/graphql/types/ci/runner_setup_type.rb index b6b020db40e..5328ac8f21f 100644 --- a/app/graphql/types/ci/runner_setup_type.rb +++ b/app/graphql/types/ci/runner_setup_type.rb @@ -7,9 +7,9 @@ module Types graphql_name 'RunnerSetup' field :install_instructions, GraphQL::Types::String, null: false, - description: 'Instructions for installing the runner on the specified architecture.' + description: 'Instructions for installing the runner on the specified architecture.' field :register_instructions, GraphQL::Types::String, null: true, - description: 'Instructions for registering the runner. The actual registration tokens are not included in the commands. Instead, a placeholder `$REGISTRATION_TOKEN` is shown.' + description: 'Instructions for registering the runner. The actual registration tokens are not included in the commands. Instead, a placeholder `$REGISTRATION_TOKEN` is shown.' end end end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index ac5ffd39407..0afb61d2b64 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -17,77 +17,77 @@ module Types alias_method :runner, :object field :access_level, ::Types::Ci::RunnerAccessLevelEnum, null: false, - description: 'Access level of the runner.' + description: 'Access level of the runner.' field :active, GraphQL::Types::Boolean, null: false, - description: 'Indicates the runner is allowed to receive jobs.', - deprecated: { reason: 'Use paused', milestone: '14.8' } + description: 'Indicates the runner is allowed to receive jobs.', + deprecated: { reason: 'Use paused', milestone: '14.8' } field :admin_url, GraphQL::Types::String, null: true, - description: 'Admin URL of the runner. Only available for administrators.' + description: 'Admin URL of the runner. Only available for administrators.' field :contacted_at, Types::TimeType, null: true, - description: 'Timestamp of last contact from this runner.', - method: :contacted_at + description: 'Timestamp of last contact from this runner.', + method: :contacted_at field :created_at, Types::TimeType, null: true, - description: 'Timestamp of creation of this runner.' + description: 'Timestamp of creation of this runner.' field :description, GraphQL::Types::String, null: true, - description: 'Description of the runner.' + description: 'Description of the runner.' field :edit_admin_url, GraphQL::Types::String, null: true, - description: 'Admin form URL of the runner. Only available for administrators.' + description: 'Admin form URL of the runner. Only available for administrators.' field :executor_name, GraphQL::Types::String, null: true, - description: 'Executor last advertised by the runner.', - method: :executor_name + description: 'Executor last advertised by the runner.', + method: :executor_name field :platform_name, GraphQL::Types::String, null: true, - description: 'Platform provided by the runner.', - method: :platform + description: 'Platform provided by the runner.', + method: :platform field :architecture_name, GraphQL::Types::String, null: true, - description: 'Architecture provided by the the runner.', - method: :architecture + description: 'Architecture provided by the the runner.', + method: :architecture field :maintenance_note, GraphQL::Types::String, null: true, - description: 'Runner\'s maintenance notes.' + description: 'Runner\'s maintenance notes.' field :groups, ::Types::GroupType.connection_type, null: true, - description: 'Groups the runner is associated with. For group runners only.' + description: 'Groups the runner is associated with. For group runners only.' field :id, ::Types::GlobalIDType[::Ci::Runner], null: false, - description: 'ID of the runner.' + description: 'ID of the runner.' field :ip_address, GraphQL::Types::String, null: true, - description: 'IP address of the runner.' + description: 'IP address of the runner.' 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)." + description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)." field :jobs, ::Types::Ci::JobType.connection_type, null: true, - description: 'Jobs assigned to the runner.', - authorize: :read_builds, - resolver: ::Resolvers::Ci::RunnerJobsResolver + description: 'Jobs assigned to the runner.', + authorize: :read_builds, + resolver: ::Resolvers::Ci::RunnerJobsResolver field :locked, GraphQL::Types::Boolean, null: true, - description: 'Indicates the runner is locked.' + description: 'Indicates the runner is locked.' field :maximum_timeout, GraphQL::Types::Int, null: true, - description: 'Maximum timeout (in seconds) for jobs processed by the runner.' + description: 'Maximum timeout (in seconds) for jobs processed by the runner.' field :paused, GraphQL::Types::Boolean, null: false, - description: 'Indicates the runner is paused and not available to run jobs.' + description: 'Indicates the runner is paused and not available to run jobs.' field :project_count, GraphQL::Types::Int, null: true, - description: 'Number of projects that the runner is associated with.' + description: 'Number of projects that the runner is associated with.' field :projects, ::Types::ProjectType.connection_type, null: true, - description: 'Projects the runner is associated with. For project runners only.' + description: 'Projects the runner is associated with. For project runners only.' field :revision, GraphQL::Types::String, null: true, - description: 'Revision of the runner.' + description: 'Revision of the runner.' field :run_untagged, GraphQL::Types::Boolean, null: false, - description: 'Indicates the runner is able to run untagged jobs.' + description: 'Indicates the runner is able to run untagged jobs.' field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false, - description: 'Type of the runner.' + description: 'Type of the runner.' field :short_sha, GraphQL::Types::String, null: true, - description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.) + description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.) field :status, Types::Ci::RunnerStatusEnum, null: false, description: 'Status of the runner.', resolver: ::Resolvers::Ci::RunnerStatusResolver # TODO: Remove :resolver in %17.0 field :tag_list, [GraphQL::Types::String], null: true, - description: 'Tags associated with the runner.' + description: 'Tags associated with the runner.' field :token_expires_at, Types::TimeType, null: true, - description: 'Runner token expiration time.', - method: :token_expires_at + description: 'Runner token expiration time.', + method: :token_expires_at field :version, GraphQL::Types::String, null: true, - description: 'Version of the runner.' + description: 'Version of the runner.' field :owner_project, ::Types::ProjectType, null: true, - description: 'Project that owns the runner. For project runners only.', - resolver: ::Resolvers::Ci::RunnerOwnerProjectResolver + description: 'Project that owns the runner. For project runners only.', + resolver: ::Resolvers::Ci::RunnerOwnerProjectResolver markdown_field :maintenance_note_html, null: true diff --git a/app/graphql/types/ci/runner_upgrade_status_type_enum.rb b/app/graphql/types/ci/runner_upgrade_status_enum.rb index 8e32eee5e6e..34a931c8f79 100644 --- a/app/graphql/types/ci/runner_upgrade_status_type_enum.rb +++ b/app/graphql/types/ci/runner_upgrade_status_enum.rb @@ -2,8 +2,8 @@ module Types module Ci - class RunnerUpgradeStatusTypeEnum < BaseEnum - graphql_name 'CiRunnerUpgradeStatusType' + class RunnerUpgradeStatusEnum < BaseEnum + graphql_name 'CiRunnerUpgradeStatus' ::Ci::RunnerVersion::STATUS_DESCRIPTIONS.each do |status, description| status_name_src = diff --git a/app/graphql/types/ci/runner_web_url_edge.rb b/app/graphql/types/ci/runner_web_url_edge.rb index 7dfcd1f3510..9255e59267c 100644 --- a/app/graphql/types/ci/runner_web_url_edge.rb +++ b/app/graphql/types/ci/runner_web_url_edge.rb @@ -5,11 +5,11 @@ module Types # rubocop: disable Graphql/AuthorizeTypes class RunnerWebUrlEdge < ::Types::BaseEdge field :edit_url, GraphQL::Types::String, null: true, - description: 'Web URL of the runner edit page. The value depends on where you put this field in the query. You can use it for projects or groups.', - extras: [:parent] + description: 'Web URL of the runner edit page. The value depends on where you put this field in the query. You can use it for projects or groups.', + extras: [:parent] 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] + 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 diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb index dcb3092d15a..c0f3d1db57b 100644 --- a/app/graphql/types/ci/stage_type.rb +++ b/app/graphql/types/ci/stage_type.rb @@ -7,16 +7,16 @@ module Types authorize :read_build field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the stage.' + description: 'Detailed status of the stage.' field :groups, type: Ci::GroupType.connection_type, null: true, - extras: [:lookahead], - description: 'Group of jobs for the stage.' + extras: [:lookahead], + description: 'Group of jobs for the stage.' field :id, GraphQL::Types::ID, null: false, - description: 'ID of the stage.' + description: 'ID of the stage.' field :jobs, Types::Ci::JobType.connection_type, null: true, - description: 'Jobs for the stage.' + description: 'Jobs for the stage.' field :name, type: GraphQL::Types::String, null: true, - description: 'Name of the stage.' + description: 'Name of the stage.' field :status, GraphQL::Types::String, null: true, description: 'Status of the pipeline stage.' diff --git a/app/graphql/types/ci/status_action_type.rb b/app/graphql/types/ci/status_action_type.rb index c0f61cf49f2..45773be49e2 100644 --- a/app/graphql/types/ci/status_action_type.rb +++ b/app/graphql/types/ci/status_action_type.rb @@ -6,19 +6,19 @@ module Types graphql_name 'StatusAction' field :button_title, GraphQL::Types::String, null: true, - description: 'Title for the button, for example: Retry this job.' + description: 'Title for the button, for example: Retry this job.' field :icon, GraphQL::Types::String, null: true, - description: 'Icon used in the action button.' + description: 'Icon used in the action button.' field :id, GraphQL::Types::String, null: false, - description: 'ID for a status action.', - extras: [:parent] + description: 'ID for a status action.', + extras: [:parent] field :method, GraphQL::Types::String, null: true, - description: 'Method for the action, for example: :post.', - resolver_method: :action_method + description: 'Method for the action, for example: :post.', + resolver_method: :action_method field :path, GraphQL::Types::String, null: true, - description: 'Path for the action.' + description: 'Path for the action.' field :title, GraphQL::Types::String, null: true, - description: 'Title for the action, for example: Retry.' + description: 'Title for the action, for example: Retry.' def id(parent:) # parent is a SimpleDelegator diff --git a/app/graphql/types/ci/template_type.rb b/app/graphql/types/ci/template_type.rb index 4f1ec6436de..91e10c619c8 100644 --- a/app/graphql/types/ci/template_type.rb +++ b/app/graphql/types/ci/template_type.rb @@ -8,9 +8,9 @@ module Types description 'GitLab CI/CD configuration template.' field :content, GraphQL::Types::String, null: false, - description: 'Contents of the CI template.' + description: 'Contents of the CI template.' field :name, GraphQL::Types::String, null: false, - description: 'Name of the CI template.' + description: 'Name of the CI template.' end end end diff --git a/app/graphql/types/ci/test_case_type.rb b/app/graphql/types/ci/test_case_type.rb index 6e5f55aa3ed..f88923215eb 100644 --- a/app/graphql/types/ci/test_case_type.rb +++ b/app/graphql/types/ci/test_case_type.rb @@ -9,32 +9,36 @@ module Types connection_type_class(Types::CountableConnectionType) - field :status, Types::Ci::TestCaseStatusEnum, null: true, - description: "Status of the test case (#{::Gitlab::Ci::Reports::TestCase::STATUS_TYPES.join(', ')})." + field :status, + Types::Ci::TestCaseStatusEnum, + null: true, + description: "Status of the test case (#{::Gitlab::Ci::Reports::TestCase::STATUS_TYPES.join(', ')})." field :name, GraphQL::Types::String, null: true, - description: 'Name of the test case.' + description: 'Name of the test case.' field :classname, GraphQL::Types::String, null: true, - description: 'Classname of the test case.' + description: 'Classname of the test case.' field :execution_time, GraphQL::Types::Float, null: true, - description: 'Test case execution time in seconds.' + description: 'Test case execution time in seconds.' field :file, GraphQL::Types::String, null: true, - description: 'Path to the file of the test case.' + description: 'Path to the file of the test case.' field :attachment_url, GraphQL::Types::String, null: true, - description: 'URL of the test case attachment file.' + description: 'URL of the test case attachment file.' field :system_output, GraphQL::Types::String, null: true, - description: 'System output of the test case.' + description: 'System output of the test case.' field :stack_trace, GraphQL::Types::String, null: true, - description: 'Stack trace of the test case.' + description: 'Stack trace of the test case.' - field :recent_failures, Types::Ci::RecentFailuresType, null: true, - description: 'Recent failure history of the test case on the base branch.' + field :recent_failures, + Types::Ci::RecentFailuresType, + null: true, + description: 'Recent failure history of the test case on the base branch.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/test_report_summary_type.rb b/app/graphql/types/ci/test_report_summary_type.rb index 87207c8a765..e3577f4fa8f 100644 --- a/app/graphql/types/ci/test_report_summary_type.rb +++ b/app/graphql/types/ci/test_report_summary_type.rb @@ -9,10 +9,12 @@ module Types description 'Test report for a pipeline' field :total, Types::Ci::TestReportTotalType, null: false, - description: 'Total report statistics for a pipeline test report.' + description: 'Total report statistics for a pipeline test report.' - field :test_suites, Types::Ci::TestSuiteSummaryType.connection_type, null: false, - description: 'Test suites belonging to a pipeline test report.' + field :test_suites, + Types::Ci::TestSuiteSummaryType.connection_type, + null: false, + description: 'Test suites belonging to a pipeline test report.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/test_report_total_type.rb b/app/graphql/types/ci/test_report_total_type.rb index 48aea1257c5..54959d5173f 100644 --- a/app/graphql/types/ci/test_report_total_type.rb +++ b/app/graphql/types/ci/test_report_total_type.rb @@ -8,25 +8,25 @@ module Types description 'Total test report statistics.' field :time, GraphQL::Types::Float, null: true, - description: 'Total duration of the tests.' + description: 'Total duration of the tests.' field :count, GraphQL::Types::Int, null: true, - description: 'Total number of the test cases.' + description: 'Total number of the test cases.' field :success, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that succeeded.' + description: 'Total number of test cases that succeeded.' field :failed, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that failed.' + description: 'Total number of test cases that failed.' field :skipped, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that were skipped.' + description: 'Total number of test cases that were skipped.' field :error, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that had an error.' + description: 'Total number of test cases that had an error.' field :suite_error, GraphQL::Types::String, null: true, - description: 'Test suite error message.' + description: 'Test suite error message.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/test_suite_summary_type.rb b/app/graphql/types/ci/test_suite_summary_type.rb index ec7b852213b..8801501c8d4 100644 --- a/app/graphql/types/ci/test_suite_summary_type.rb +++ b/app/graphql/types/ci/test_suite_summary_type.rb @@ -10,31 +10,37 @@ module Types connection_type_class(Types::CountableConnectionType) field :name, GraphQL::Types::String, null: true, - description: 'Name of the test suite.' + description: 'Name of the test suite.' field :total_time, GraphQL::Types::Float, null: true, - description: 'Total duration of the tests in the test suite.' + description: 'Total duration of the tests in the test suite.' field :total_count, GraphQL::Types::Int, null: true, - description: 'Total number of the test cases in the test suite.' + description: 'Total number of the test cases in the test suite.' - field :success_count, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that succeeded in the test suite.' + field :success_count, + GraphQL::Types::Int, + null: true, + description: 'Total number of test cases that succeeded in the test suite.' - field :failed_count, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that failed in the test suite.' + field :failed_count, + GraphQL::Types::Int, + null: true, + description: 'Total number of test cases that failed in the test suite.' - field :skipped_count, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that were skipped in the test suite.' + field :skipped_count, + GraphQL::Types::Int, + null: true, + description: 'Total number of test cases that were skipped in the test suite.' field :error_count, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that had an error.' + description: 'Total number of test cases that had an error.' field :suite_error, GraphQL::Types::String, null: true, - description: 'Test suite error message.' + description: 'Test suite error message.' field :build_ids, [GraphQL::Types::ID], null: true, - description: 'IDs of the builds used to run the test suite.' + description: 'IDs of the builds used to run the test suite.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/test_suite_type.rb b/app/graphql/types/ci/test_suite_type.rb index 7ce479632cc..8845338ed6d 100644 --- a/app/graphql/types/ci/test_suite_type.rb +++ b/app/graphql/types/ci/test_suite_type.rb @@ -10,31 +10,36 @@ module Types connection_type_class(Types::CountableConnectionType) field :name, GraphQL::Types::String, null: true, - description: 'Name of the test suite.' + description: 'Name of the test suite.' field :total_time, GraphQL::Types::Float, null: true, - description: 'Total duration of the tests in the test suite.' + description: 'Total duration of the tests in the test suite.' field :total_count, GraphQL::Types::Int, null: true, - description: 'Total number of the test cases in the test suite.' + description: 'Total number of the test cases in the test suite.' - field :success_count, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that succeeded in the test suite.' + field :success_count, + GraphQL::Types::Int, + null: true, + description: 'Total number of test cases that succeeded in the test suite.' - field :failed_count, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that failed in the test suite.' + field :failed_count, + GraphQL::Types::Int, + null: true, + description: 'Total number of test cases that failed in the test suite.' - field :skipped_count, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that were skipped in the test suite.' + field :skipped_count, + GraphQL::Types::Int, null: true, + description: 'Total number of test cases that were skipped in the test suite.' field :error_count, GraphQL::Types::Int, null: true, - description: 'Total number of test cases that had an error.' + description: 'Total number of test cases that had an error.' field :suite_error, GraphQL::Types::String, null: true, - description: 'Test suite error message.' + description: 'Test suite error message.' field :test_cases, Types::Ci::TestCaseType.connection_type, null: true, - description: 'Test cases in the test suite.' + description: 'Test cases in the test suite.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/variable_input_type.rb b/app/graphql/types/ci/variable_input_type.rb new file mode 100644 index 00000000000..193ca6ffe4e --- /dev/null +++ b/app/graphql/types/ci/variable_input_type.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Ci + class VariableInputType < BaseInputObject + graphql_name 'CiVariableInput' + description 'Attributes for defining a CI/CD variable.' + + argument :key, GraphQL::Types::String, description: 'Name of the variable.' + argument :value, GraphQL::Types::String, description: 'Value of the variable.' + end + end +end diff --git a/app/graphql/types/ci/variable_interface.rb b/app/graphql/types/ci/variable_interface.rb new file mode 100644 index 00000000000..82c9ba7121c --- /dev/null +++ b/app/graphql/types/ci/variable_interface.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + module Ci + module VariableInterface + include Types::BaseInterface + + graphql_name 'CiVariable' + + field :id, GraphQL::Types::ID, + null: false, + description: 'ID of the variable.' + + field :key, GraphQL::Types::String, + null: true, + description: 'Name of the variable.' + + field :value, GraphQL::Types::String, + null: true, + description: 'Value of the variable.' + + field :variable_type, ::Types::Ci::VariableTypeEnum, + null: true, + description: 'Type of the variable.' + + field :raw, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the variable is raw.' + end + end +end diff --git a/app/graphql/types/ci/variable_type.rb b/app/graphql/types/ci/variable_type.rb deleted file mode 100644 index 63f89b6d207..00000000000 --- a/app/graphql/types/ci/variable_type.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Types - module Ci - # rubocop: disable Graphql/AuthorizeTypes - class VariableType < BaseObject - graphql_name 'CiVariable' - - field :id, GraphQL::Types::ID, null: false, - description: 'ID of the variable.' - - field :key, GraphQL::Types::String, null: true, - description: 'Name of the variable.' - - field :value, GraphQL::Types::String, null: true, - description: 'Value of the variable.' - - field :variable_type, ::Types::Ci::VariableTypeEnum, null: true, - description: 'Type of the variable.' - - field :protected, GraphQL::Types::Boolean, null: true, - description: 'Indicates whether the variable is protected.' - - field :masked, GraphQL::Types::Boolean, null: true, - description: 'Indicates whether the variable is masked.' - - field :raw, GraphQL::Types::Boolean, null: true, - description: 'Indicates whether the variable is raw.' - - field :environment_scope, GraphQL::Types::String, null: true, - description: 'Scope defining the environments in which the variable can be used.' - - def environment_scope - if object.respond_to?(:environment_scope) - object.environment_scope - end - end - end - end -end diff --git a/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb b/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb index 68b9a63d8dc..3344693bf46 100644 --- a/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb +++ b/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb @@ -8,10 +8,10 @@ module Types description 'Represents the analyzers entity in SAST CI configuration' argument :name, GraphQL::Types::String, required: true, - description: 'Name of analyzer.' + description: 'Name of analyzer.' argument :enabled, GraphQL::Types::Boolean, required: true, - description: 'State of the analyzer.' + description: 'State of the analyzer.' argument :variables, [::Types::CiConfiguration::Sast::EntityInputType], description: 'List of variables for the analyzer.', diff --git a/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb b/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb index 9fdc7c1b000..de160756c8c 100644 --- a/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb +++ b/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb @@ -9,19 +9,21 @@ module Types description 'Represents an analyzer entity in SAST CI configuration' field :name, GraphQL::Types::String, null: true, - description: 'Name of the analyzer.' + description: 'Name of the analyzer.' field :label, GraphQL::Types::String, null: true, - description: 'Analyzer label used in the config UI.' + description: 'Analyzer label used in the config UI.' field :enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates whether an analyzer is enabled.' + description: 'Indicates whether an analyzer is enabled.' field :description, GraphQL::Types::String, null: true, - description: 'Analyzer description that is displayed on the form.' + description: 'Analyzer description that is displayed on the form.' - field :variables, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true, - description: 'List of supported variables.' + field :variables, + ::Types::CiConfiguration::Sast::EntityType.connection_type, + null: true, + description: 'List of supported variables.' end end end diff --git a/app/graphql/types/ci_configuration/sast/entity_input_type.rb b/app/graphql/types/ci_configuration/sast/entity_input_type.rb index f0e3c07d71f..095097fe7b5 100644 --- a/app/graphql/types/ci_configuration/sast/entity_input_type.rb +++ b/app/graphql/types/ci_configuration/sast/entity_input_type.rb @@ -8,13 +8,13 @@ module Types description 'Represents an entity in SAST CI configuration' argument :field, GraphQL::Types::String, required: true, - description: 'CI keyword of entity.' + description: 'CI keyword of entity.' argument :default_value, GraphQL::Types::String, required: true, - description: 'Default value that is used if value is empty.' + description: 'Default value that is used if value is empty.' argument :value, GraphQL::Types::String, required: true, - description: 'Current value of the entity.' + description: 'Current value of the entity.' end end end diff --git a/app/graphql/types/ci_configuration/sast/entity_type.rb b/app/graphql/types/ci_configuration/sast/entity_type.rb index 41b8575d99a..91e80fdd9f8 100644 --- a/app/graphql/types/ci_configuration/sast/entity_type.rb +++ b/app/graphql/types/ci_configuration/sast/entity_type.rb @@ -9,28 +9,30 @@ module Types description 'Represents an entity in SAST CI configuration' field :field, GraphQL::Types::String, null: true, - description: 'CI keyword of entity.' + description: 'CI keyword of entity.' field :label, GraphQL::Types::String, null: true, - description: 'Label for entity used in the form.' + description: 'Label for entity used in the form.' field :type, GraphQL::Types::String, null: true, - description: 'Type of the field value.' + description: 'Type of the field value.' - field :options, ::Types::CiConfiguration::Sast::OptionsEntityType.connection_type, null: true, - description: 'Different possible values of the field.' + field :options, + ::Types::CiConfiguration::Sast::OptionsEntityType.connection_type, + null: true, + description: 'Different possible values of the field.' field :default_value, GraphQL::Types::String, null: true, - description: 'Default value that is used if value is empty.' + description: 'Default value that is used if value is empty.' field :description, GraphQL::Types::String, null: true, - description: 'Entity description that is displayed on the form.' + description: 'Entity description that is displayed on the form.' field :value, GraphQL::Types::String, null: true, - description: 'Current value of the entity.' + description: 'Current value of the entity.' field :size, ::Types::CiConfiguration::Sast::UiComponentSizeEnum, null: true, - description: 'Size of the UI component.' + description: 'Size of the UI component.' end end end diff --git a/app/graphql/types/ci_configuration/sast/options_entity_type.rb b/app/graphql/types/ci_configuration/sast/options_entity_type.rb index 5f365807cfe..2de84adf685 100644 --- a/app/graphql/types/ci_configuration/sast/options_entity_type.rb +++ b/app/graphql/types/ci_configuration/sast/options_entity_type.rb @@ -9,10 +9,10 @@ module Types description 'Represents an entity for options in SAST CI configuration' field :label, GraphQL::Types::String, null: true, - description: 'Label of option entity.' + description: 'Label of option entity.' field :value, GraphQL::Types::String, null: true, - description: 'Value of option entity.' + description: 'Value of option entity.' end end end diff --git a/app/graphql/types/ci_configuration/sast/type.rb b/app/graphql/types/ci_configuration/sast/type.rb index 35d11584ac7..edfdf296929 100644 --- a/app/graphql/types/ci_configuration/sast/type.rb +++ b/app/graphql/types/ci_configuration/sast/type.rb @@ -8,14 +8,20 @@ module Types graphql_name 'SastCiConfiguration' description 'Represents a CI configuration of SAST' - field :global, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true, - description: 'List of global entities related to SAST configuration.' + field :global, + ::Types::CiConfiguration::Sast::EntityType.connection_type, + null: true, + description: 'List of global entities related to SAST configuration.' - field :pipeline, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true, - description: 'List of pipeline entities related to SAST configuration.' + field :pipeline, + ::Types::CiConfiguration::Sast::EntityType.connection_type, + null: true, + description: 'List of pipeline entities related to SAST configuration.' - field :analyzers, ::Types::CiConfiguration::Sast::AnalyzersEntityType.connection_type, null: true, - description: 'List of analyzers entities attached to SAST configuration.' + field :analyzers, + ::Types::CiConfiguration::Sast::AnalyzersEntityType.connection_type, + null: true, + description: 'List of analyzers entities attached to SAST configuration.' end end end diff --git a/app/graphql/types/commit_action_type.rb b/app/graphql/types/commit_action_type.rb index 1aa3a4e7ee1..9e808bd3174 100644 --- a/app/graphql/types/commit_action_type.rb +++ b/app/graphql/types/commit_action_type.rb @@ -3,18 +3,18 @@ module Types class CommitActionType < BaseInputObject argument :action, type: Types::CommitActionModeEnum, required: true, - description: 'Action to perform: create, delete, move, update, or chmod.' + description: 'Action to perform: create, delete, move, update, or chmod.' argument :content, type: GraphQL::Types::String, required: false, - description: 'Content of the file.' + description: 'Content of the file.' argument :encoding, type: Types::CommitEncodingEnum, required: false, - description: 'Encoding of the file. Default is text.' + description: 'Encoding of the file. Default is text.' argument :execute_filemode, type: GraphQL::Types::Boolean, required: false, - description: 'Enables/disables the execute flag on the file.' + description: 'Enables/disables the execute flag on the file.' argument :file_path, type: GraphQL::Types::String, required: true, - description: 'Full path to the file.' + description: 'Full path to the file.' argument :last_commit_id, type: GraphQL::Types::String, required: false, - description: 'Last known file commit ID.' + description: 'Last known file commit ID.' argument :previous_path, type: GraphQL::Types::String, required: false, - description: 'Original full path to the file being moved.' + description: 'Original full path to the file being moved.' end end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index c3a6d6f7faa..dfb02f29fb7 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -11,48 +11,48 @@ module Types implements(Types::TodoableInterface) field :id, type: GraphQL::Types::ID, null: false, - description: 'ID (global ID) of the commit.' + description: 'ID (global ID) of the commit.' field :sha, type: GraphQL::Types::String, null: false, - description: 'SHA1 ID of the commit.' + description: 'SHA1 ID of the commit.' field :short_id, type: GraphQL::Types::String, null: false, - description: 'Short SHA1 ID of the commit.' + description: 'Short SHA1 ID of the commit.' field :title, type: GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'Title of the commit message.' + description: 'Title of the commit message.' field :full_title, type: GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'Full title of the commit message.' + description: 'Full title of the commit message.' field :description, type: GraphQL::Types::String, null: true, - description: 'Description of the commit message.' + description: 'Description of the commit message.' field :message, type: GraphQL::Types::String, null: true, - description: 'Raw commit message.' + description: 'Raw commit message.' field :authored_date, type: Types::TimeType, null: true, - description: 'Timestamp of when the commit was authored.' + description: 'Timestamp of when the commit was authored.' field :web_url, type: GraphQL::Types::String, null: false, - description: 'Web URL of the commit.' + description: 'Web URL of the commit.' field :web_path, type: GraphQL::Types::String, null: false, - description: 'Web path of the commit.' + description: 'Web path of the commit.' field :signature_html, type: GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'Rendered HTML of the commit signature.' + description: 'Rendered HTML of the commit signature.' field :author_email, type: GraphQL::Types::String, null: true, - description: "Commit author's email." + description: "Commit author's email." field :author_gravatar, type: GraphQL::Types::String, null: true, - description: 'Commit authors gravatar.' + description: 'Commit authors gravatar.' field :author_name, type: GraphQL::Types::String, null: true, - description: 'Commit authors name.' + description: 'Commit authors name.' # models/commit lazy loads the author by email field :author, type: Types::UserType, null: true, - description: 'Author of the commit.' + description: 'Author of the commit.' field :pipelines, null: true, diff --git a/app/graphql/types/concerns/gitlab_style_deprecations.rb b/app/graphql/types/concerns/gitlab_style_deprecations.rb index cd8e393b235..e404f1fcad9 100644 --- a/app/graphql/types/concerns/gitlab_style_deprecations.rb +++ b/app/graphql/types/concerns/gitlab_style_deprecations.rb @@ -14,7 +14,10 @@ module GitlabStyleDeprecations 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-schema-items' end - deprecation = ::Gitlab::Graphql::Deprecation.parse(kwargs.delete(:deprecated)) + # GitLab allows items to be marked as "alpha", which leverages GraphQL deprecations. + deprecation_args = kwargs.extract!(:alpha, :deprecated) + + deprecation = ::Gitlab::Graphql::Deprecation.parse(**deprecation_args) return unless deprecation raise ArgumentError, "Bad deprecation. #{deprecation.errors.full_messages.to_sentence}" unless deprecation.valid? diff --git a/app/graphql/types/countable_connection_type.rb b/app/graphql/types/countable_connection_type.rb index 0f24964daa6..4c216ceceb6 100644 --- a/app/graphql/types/countable_connection_type.rb +++ b/app/graphql/types/countable_connection_type.rb @@ -4,7 +4,7 @@ module Types # rubocop: disable Graphql/AuthorizeTypes class CountableConnectionType < GraphQL::Types::Relay::BaseConnection field :count, GraphQL::Types::Int, null: false, - description: 'Total count of collection.' + description: 'Total count of collection.' def count # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/graphql/types/customer_relations/contact_sort_enum.rb b/app/graphql/types/customer_relations/contact_sort_enum.rb new file mode 100644 index 00000000000..221dedacb6a --- /dev/null +++ b/app/graphql/types/customer_relations/contact_sort_enum.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module CustomerRelations + class ContactSortEnum < SortEnum + graphql_name 'ContactSort' + description 'Values for sorting contacts' + + sortable_fields = ['First name', 'Last name', 'Email', 'Phone', 'Description', 'Organization'] + + sortable_fields.each do |field| + value "#{field.upcase.tr(' ', '_')}_ASC", + value: { field: field.downcase.tr(' ', '_'), direction: :asc }, + description: "#{field} by ascending order." + value "#{field.upcase.tr(' ', '_')}_DESC", + value: { field: field.downcase.tr(' ', '_'), direction: :desc }, + description: "#{field} by descending order." + end + end + end +end diff --git a/app/graphql/types/customer_relations/contact_state_counts_type.rb b/app/graphql/types/customer_relations/contact_state_counts_type.rb new file mode 100644 index 00000000000..96230f8a952 --- /dev/null +++ b/app/graphql/types/customer_relations/contact_state_counts_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module CustomerRelations + class ContactStateCountsType < Types::BaseObject + graphql_name 'ContactStateCounts' + description 'Represents the total number of contacts for the represented states.' + + authorize :read_crm_contact + + def self.available_contact_states + @available_contact_states ||= ::CustomerRelations::Contact.states.keys.push('all') + end + + available_contact_states.each do |state| + field state, + GraphQL::Types::Int, + null: true, + description: "Number of contacts with state `#{state.upcase}`" + end + end + end +end diff --git a/app/graphql/types/customer_relations/contact_state_enum.rb b/app/graphql/types/customer_relations/contact_state_enum.rb index 445d2a41401..1e5cae8528f 100644 --- a/app/graphql/types/customer_relations/contact_state_enum.rb +++ b/app/graphql/types/customer_relations/contact_state_enum.rb @@ -5,12 +5,16 @@ module Types class ContactStateEnum < BaseEnum graphql_name 'CustomerRelationsContactState' + value 'all', + description: "All available contacts.", + value: :all + value 'active', - description: "Active contact.", + description: "Active contacts.", value: :active value 'inactive', - description: "Inactive contact.", + description: "Inactive contacts.", value: :inactive end end diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb index 91978aa37b0..38864075288 100644 --- a/app/graphql/types/design_management/design_collection_type.rb +++ b/app/graphql/types/design_management/design_collection_type.rb @@ -9,9 +9,9 @@ module Types authorize :read_design field :issue, Types::IssueType, null: false, - description: 'Issue associated with the design collection.' + description: 'Issue associated with the design collection.' field :project, Types::ProjectType, null: false, - description: 'Project associated with the design collection.' + description: 'Project associated with the design collection.' field :designs, Types::DesignManagement::DesignType.connection_type, diff --git a/app/graphql/types/design_management/design_fields.rb b/app/graphql/types/design_management/design_fields.rb index c3a35cfe1ad..f9e9b270a36 100644 --- a/app/graphql/types/design_management/design_fields.rb +++ b/app/graphql/types/design_management/design_fields.rb @@ -13,7 +13,10 @@ module Types field :filename, GraphQL::Types::String, null: false, description: 'Filename of the design.' field :full_path, GraphQL::Types::String, null: false, description: 'Full path to the design file.' field :image, GraphQL::Types::String, null: false, extras: [:parent], description: 'URL of the full-sized image.' - field :image_v432x230, GraphQL::Types::String, null: true, extras: [:parent], + field :image_v432x230, + GraphQL::Types::String, + null: true, + extras: [:parent], description: 'The URL of the design resized to fit within the bounds of 432x230. ' \ 'This will be `null` if the image has not been generated' field :diff_refs, Types::DiffRefsType, diff --git a/app/graphql/types/design_management/version_type.rb b/app/graphql/types/design_management/version_type.rb index cfd2b887dc3..2cbe50afae6 100644 --- a/app/graphql/types/design_management/version_type.rb +++ b/app/graphql/types/design_management/version_type.rb @@ -12,9 +12,9 @@ module Types authorize :read_design field :id, GraphQL::Types::ID, null: false, - description: 'ID of the design version.' + description: 'ID of the design version.' field :sha, GraphQL::Types::ID, null: false, - description: 'SHA of the design version.' + description: 'SHA of the design version.' field :designs, ::Types::DesignManagement::DesignType.connection_type, @@ -35,7 +35,7 @@ module Types field :author, Types::UserType, null: false, description: 'Author of the version.' field :created_at, Types::TimeType, null: false, - description: 'Timestamp of when the version was created.' + description: 'Timestamp of when the version was created.' end end end diff --git a/app/graphql/types/diff_paths_input_type.rb b/app/graphql/types/diff_paths_input_type.rb index c5c75105fda..94e86285b86 100644 --- a/app/graphql/types/diff_paths_input_type.rb +++ b/app/graphql/types/diff_paths_input_type.rb @@ -3,8 +3,8 @@ module Types class DiffPathsInputType < BaseInputObject argument :new_path, GraphQL::Types::String, required: false, - description: 'Path of the file on the HEAD SHA.' + description: 'Path of the file on the HEAD SHA.' argument :old_path, GraphQL::Types::String, required: false, - description: 'Path of the file on the start SHA.' + description: 'Path of the file on the start SHA.' end end diff --git a/app/graphql/types/diff_refs_type.rb b/app/graphql/types/diff_refs_type.rb index a03d72a4dc2..6caf2eb87e6 100644 --- a/app/graphql/types/diff_refs_type.rb +++ b/app/graphql/types/diff_refs_type.rb @@ -7,11 +7,11 @@ module Types graphql_name 'DiffRefs' field :base_sha, GraphQL::Types::String, null: true, - description: 'Merge base of the branch the comment was made on.' + description: 'Merge base of the branch the comment was made on.' field :head_sha, GraphQL::Types::String, null: false, - description: 'SHA of the HEAD at the time the comment was made.' + description: 'SHA of the HEAD at the time the comment was made.' field :start_sha, GraphQL::Types::String, null: false, - description: 'SHA of the branch being compared against.' + description: 'SHA of the branch being compared against.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/diff_stats_summary_type.rb b/app/graphql/types/diff_stats_summary_type.rb index 95705ddecf3..44b92789408 100644 --- a/app/graphql/types/diff_stats_summary_type.rb +++ b/app/graphql/types/diff_stats_summary_type.rb @@ -9,13 +9,13 @@ module Types description 'Aggregated summary of changes' field :additions, GraphQL::Types::Int, null: false, - description: 'Number of lines added.' + description: 'Number of lines added.' field :changes, GraphQL::Types::Int, null: false, - description: 'Number of lines changed.' + description: 'Number of lines changed.' field :deletions, GraphQL::Types::Int, null: false, - description: 'Number of lines deleted.' + description: 'Number of lines deleted.' field :file_count, GraphQL::Types::Int, null: false, - description: 'Number of files changed.' + description: 'Number of files changed.' def changes object[:additions] + object[:deletions] diff --git a/app/graphql/types/diff_stats_type.rb b/app/graphql/types/diff_stats_type.rb index da366fec8c3..a6b7f8e9084 100644 --- a/app/graphql/types/diff_stats_type.rb +++ b/app/graphql/types/diff_stats_type.rb @@ -9,11 +9,11 @@ module Types description 'Changes to a single file' field :additions, GraphQL::Types::Int, null: false, - description: 'Number of lines added to this file.' + description: 'Number of lines added to this file.' field :deletions, GraphQL::Types::Int, null: false, - description: 'Number of lines deleted from this file.' + description: 'Number of lines deleted from this file.' field :path, GraphQL::Types::String, null: false, - description: 'File path, relative to repository root.' + description: 'File path, relative to repository root.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index aba83f559fa..2a7076cc3c9 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -10,20 +10,20 @@ module Types authorize :read_environment field :name, GraphQL::Types::String, null: false, - description: 'Human-readable name of the environment.' + description: 'Human-readable name of the environment.' field :id, GraphQL::Types::ID, null: false, - description: 'ID of the environment.' + description: 'ID of the environment.' field :state, GraphQL::Types::String, null: false, - description: 'State of the environment, for example: available/stopped.' + description: 'State of the environment, for example: available/stopped.' field :path, GraphQL::Types::String, null: false, - description: 'Path to the environment.' + description: 'Path to the environment.' field :metrics_dashboard, Types::Metrics::DashboardType, null: true, - description: 'Metrics dashboard schema for the environment.', - resolver: Resolvers::Metrics::DashboardResolver + description: 'Metrics dashboard schema for the environment.', + resolver: Resolvers::Metrics::DashboardResolver field :latest_opened_most_severe_alert, Types::AlertManagement::AlertType, diff --git a/app/graphql/types/evidence_type.rb b/app/graphql/types/evidence_type.rb index ed644a4b2c6..0d73a935e50 100644 --- a/app/graphql/types/evidence_type.rb +++ b/app/graphql/types/evidence_type.rb @@ -10,12 +10,12 @@ module Types present_using Releases::EvidencePresenter field :collected_at, Types::TimeType, null: true, - description: 'Timestamp when the evidence was collected.' + description: 'Timestamp when the evidence was collected.' field :filepath, GraphQL::Types::String, null: true, - description: 'URL from where the evidence can be downloaded.' + description: 'URL from where the evidence can be downloaded.' field :id, GraphQL::Types::ID, null: false, - description: 'ID of the evidence.' + description: 'ID of the evidence.' field :sha, GraphQL::Types::String, null: true, - description: 'SHA1 ID of the evidence hash.' + description: 'SHA1 ID of the evidence hash.' end end diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb index 145a5a22460..a71c2fb0e6c 100644 --- a/app/graphql/types/global_id_type.rb +++ b/app/graphql/types/global_id_type.rb @@ -50,7 +50,7 @@ module Types #{ if deprecation = Gitlab::GlobalId::Deprecations.deprecation_by(model_name) 'The older format `"' + - ::Gitlab::GlobalId.build(model_name: deprecation.old_model_name, id: 1).to_s + + ::Gitlab::GlobalId.build(model_name: deprecation.old_name, id: 1).to_s + '"` was deprecated in ' + deprecation.milestone + '.' end} diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb index 2bbc0d34db6..982ba803603 100644 --- a/app/graphql/types/grafana_integration_type.rb +++ b/app/graphql/types/grafana_integration_type.rb @@ -7,14 +7,14 @@ module Types authorize :admin_operations field :created_at, Types::TimeType, null: false, - description: 'Timestamp of the issue\'s creation.' + description: 'Timestamp of the issue\'s creation.' field :enabled, GraphQL::Types::Boolean, null: false, - description: 'Indicates whether Grafana integration is enabled.' + description: 'Indicates whether Grafana integration is enabled.' field :grafana_url, GraphQL::Types::String, null: false, - description: 'URL for the Grafana host for the Grafana integration.' + description: 'URL for the Grafana host for the Grafana integration.' field :id, GraphQL::Types::ID, null: false, - description: 'Internal ID of the Grafana integration.' + description: 'Internal ID of the Grafana integration.' field :updated_at, Types::TimeType, null: false, - description: 'Timestamp of the issue\'s last activity.' + description: 'Timestamp of the issue\'s last activity.' end end diff --git a/app/graphql/types/group_invitation_type.rb b/app/graphql/types/group_invitation_type.rb index 48281dcfd9f..2b874e23c64 100644 --- a/app/graphql/types/group_invitation_type.rb +++ b/app/graphql/types/group_invitation_type.rb @@ -11,7 +11,7 @@ module Types implements InvitationInterface field :group, Types::GroupType, null: true, - description: 'Group that a User is invited to.' + description: 'Group that a User is invited to.' def group Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find diff --git a/app/graphql/types/group_member_type.rb b/app/graphql/types/group_member_type.rb index c4582f31bec..2745853c9bb 100644 --- a/app/graphql/types/group_member_type.rb +++ b/app/graphql/types/group_member_type.rb @@ -11,7 +11,7 @@ module Types implements MemberInterface field :group, Types::GroupType, null: true, - description: 'Group that a User is a member of.' + description: 'Group that a User is a member of.' field :notification_email, resolver: Resolvers::GroupMembers::NotificationEmailResolver, diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 52e9f808066..235a2bc2a34 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -22,7 +22,7 @@ module Types type: Types::CustomEmojiType.connection_type, null: true, description: 'Custom emoji within this namespace.', - feature_flag: :custom_emoji + _deprecated_feature_flag: :custom_emoji field :share_with_group_lock, type: GraphQL::Types::Boolean, @@ -85,6 +85,7 @@ module Types field :milestones, description: 'Milestones of the group.', + extras: [:lookahead], resolver: Resolvers::GroupMilestonesResolver field :boards, @@ -183,10 +184,10 @@ module Types resolver: Resolvers::GroupLabelsResolver field :timelogs, ::Types::TimelogType.connection_type, null: false, - description: 'Time logged on issues and merge requests in the group and its subgroups.', - extras: [:lookahead], - complexity: 5, - resolver: ::Resolvers::TimelogResolver + description: 'Time logged on issues and merge requests in the group and its subgroups.', + extras: [:lookahead], + complexity: 5, + resolver: ::Resolvers::TimelogResolver field :descendant_groups, Types::GroupType.connection_type, null: true, @@ -195,7 +196,7 @@ module Types resolver: Resolvers::GroupsResolver field :ci_variables, - Types::Ci::VariableType.connection_type, + Types::Ci::GroupVariableType.connection_type, null: true, description: "List of the group's CI/CD variables.", authorize: :admin_group, @@ -216,6 +217,12 @@ module Types description: "Find contacts of this group.", resolver: Resolvers::Crm::ContactsResolver + field :contact_state_counts, + Types::CustomerRelations::ContactStateCountsType, + null: true, + description: 'Counts of contacts by state for the group.', + resolver: Resolvers::Crm::ContactStateCountsResolver + field :work_item_types, Types::WorkItems::TypeType.connection_type, resolver: Resolvers::WorkItems::TypesResolver, description: 'Work item types available to the group.' \ diff --git a/app/graphql/types/invitation_interface.rb b/app/graphql/types/invitation_interface.rb index 1f0746d7726..bbecf5b5f54 100644 --- a/app/graphql/types/invitation_interface.rb +++ b/app/graphql/types/invitation_interface.rb @@ -5,25 +5,25 @@ module Types include BaseInterface field :email, GraphQL::Types::String, null: false, - description: 'Email of the member to invite.' + description: 'Email of the member to invite.' field :access_level, Types::AccessLevelType, null: true, - description: 'GitLab::Access level.' + description: 'GitLab::Access level.' field :created_by, Types::UserType, null: true, - description: 'User that authorized membership.' + description: 'User that authorized membership.' field :created_at, Types::TimeType, null: true, - description: 'Date and time the membership was created.' + description: 'Date and time the membership was created.' field :updated_at, Types::TimeType, null: true, - description: 'Date and time the membership was last updated.' + description: 'Date and time the membership was last updated.' field :expires_at, Types::TimeType, null: true, - description: 'Date and time the membership expires.' + description: 'Date and time the membership expires.' field :user, Types::UserType, null: true, - description: 'User that is associated with the member object.' + description: 'User that is associated with the member object.' definition_methods do def resolve_type(object, context) diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 58729b34fc7..d897f3cde48 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -17,101 +17,101 @@ module Types present_using IssuePresenter field :description, GraphQL::Types::String, null: true, - description: 'Description of the issue.' + description: 'Description of the issue.' field :id, GraphQL::Types::ID, null: false, - description: "ID of the issue." + description: "ID of the issue." field :iid, GraphQL::Types::ID, null: false, - description: "Internal ID of the issue." + description: "Internal ID of the issue." field :state, IssueStateEnum, null: false, - description: 'State of the issue.' + description: 'State of the issue.' field :title, GraphQL::Types::String, null: false, - description: 'Title of the issue.' + description: 'Title of the issue.' field :reference, GraphQL::Types::String, null: false, - description: 'Internal reference of the issue. Returned in shortened format by default.', - method: :to_reference do + description: 'Internal reference of the issue. Returned in shortened format by default.', + method: :to_reference do argument :full, GraphQL::Types::Boolean, required: false, default_value: false, - description: 'Boolean option specifying whether the reference should be returned in full.' + description: 'Boolean option specifying whether the reference should be returned in full.' end field :author, Types::UserType, null: false, - description: 'User that created the issue.' + description: 'User that created the issue.' field :assignees, Types::UserType.connection_type, null: true, - description: 'Assignees of the issue.' + description: 'Assignees of the issue.' field :updated_by, Types::UserType, null: true, - description: 'User that last updated the issue.' + description: 'User that last updated the issue.' field :labels, Types::LabelType.connection_type, null: true, - description: 'Labels of the issue.' + description: 'Labels of the issue.' field :milestone, Types::MilestoneType, null: true, - description: 'Milestone of the issue.' + description: 'Milestone of the issue.' field :confidential, GraphQL::Types::Boolean, null: false, - description: 'Indicates the issue is confidential.' + description: 'Indicates the issue is confidential.' field :discussion_locked, GraphQL::Types::Boolean, null: false, - description: 'Indicates discussion is locked on the issue.' + description: 'Indicates discussion is locked on the issue.' field :due_date, Types::TimeType, null: true, - description: 'Due date of the issue.' + description: 'Due date of the issue.' field :hidden, GraphQL::Types::Boolean, null: true, resolver_method: :hidden?, - description: 'Indicates the issue is hidden because the author has been banned. ' \ + description: 'Indicates the issue is hidden because the author has been banned. ' \ 'Will always return `null` if `ban_user_feature_flag` feature flag is disabled.' field :downvotes, GraphQL::Types::Int, null: false, - description: 'Number of downvotes the issue has received.' + description: 'Number of downvotes the issue has received.' field :merge_requests_count, GraphQL::Types::Int, null: false, - description: 'Number of merge requests that close the issue on merge.', - resolver: Resolvers::MergeRequestsCountResolver + description: 'Number of merge requests that close the issue on merge.', + resolver: Resolvers::MergeRequestsCountResolver field :relative_position, GraphQL::Types::Int, null: true, - description: 'Relative position of the issue (used for positioning in epic tree and issue boards).' + description: 'Relative position of the issue (used for positioning in epic tree and issue boards).' field :upvotes, GraphQL::Types::Int, null: false, - description: 'Number of upvotes the issue has received.' + description: 'Number of upvotes the issue has received.' field :user_discussions_count, GraphQL::Types::Int, null: false, - description: 'Number of user discussions in the issue.', - resolver: Resolvers::UserDiscussionsCountResolver + description: 'Number of user discussions in the issue.', + resolver: Resolvers::UserDiscussionsCountResolver field :user_notes_count, GraphQL::Types::Int, null: false, - description: 'Number of user notes of the issue.', - resolver: Resolvers::UserNotesCountResolver + description: 'Number of user notes of the issue.', + resolver: Resolvers::UserNotesCountResolver field :web_path, GraphQL::Types::String, null: false, method: :issue_path, - description: 'Web path of the issue.' + description: 'Web path of the issue.' field :web_url, GraphQL::Types::String, null: false, - description: 'Web URL of the issue.' + description: 'Web URL of the issue.' field :emails_disabled, GraphQL::Types::Boolean, null: false, - method: :project_emails_disabled?, - description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.' + method: :project_emails_disabled?, + description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.' field :human_time_estimate, GraphQL::Types::String, null: true, - description: 'Human-readable time estimate of the issue.' + description: 'Human-readable time estimate of the issue.' field :human_total_time_spent, GraphQL::Types::String, null: true, - description: 'Human-readable total time reported as spent on the issue.' + description: 'Human-readable total time reported as spent on the issue.' field :participants, Types::UserType.connection_type, null: true, complexity: 5, - description: 'List of participants in the issue.', - resolver: Resolvers::Users::ParticipantsResolver + description: 'List of participants in the issue.', + resolver: Resolvers::Users::ParticipantsResolver field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5, - description: 'Indicates the currently logged in user is subscribed to the issue.' + description: 'Indicates the currently logged in user is subscribed to the issue.' field :time_estimate, GraphQL::Types::Int, null: false, - description: 'Time estimate of the issue.' + description: 'Time estimate of the issue.' field :total_time_spent, GraphQL::Types::Int, null: false, - description: 'Total time reported as spent on the issue.' + description: 'Total time reported as spent on the issue.' field :closed_at, Types::TimeType, null: true, - description: 'Timestamp of when the issue was closed.' + description: 'Timestamp of when the issue was closed.' field :created_at, Types::TimeType, null: false, - description: 'Timestamp of when the issue was created.' + description: 'Timestamp of when the issue was created.' field :updated_at, Types::TimeType, null: false, - description: 'Timestamp of when the issue was last updated.' + description: 'Timestamp of when the issue was last updated.' field :task_completion_status, Types::TaskCompletionStatus, null: false, - description: 'Task completion status of the issue.' + description: 'Task completion status of the issue.' field :design_collection, Types::DesignManagement::DesignCollectionType, null: true, - description: 'Collection of design images associated with this issue.' + description: 'Collection of design images associated with this issue.' field :type, Types::IssueTypeEnum, null: true, - method: :issue_type, - description: 'Type of the issue.' + method: :issue_type, + description: 'Type of the issue.' field :alert_management_alert, Types::AlertManagement::AlertType, @@ -119,31 +119,31 @@ module Types description: 'Alert associated to this issue.' field :severity, Types::IssuableSeverityEnum, null: true, - description: 'Severity level of the incident.' + description: 'Severity level of the incident.' field :moved, GraphQL::Types::Boolean, method: :moved?, null: true, - description: 'Indicates if issue got moved from other project.' + description: 'Indicates if issue got moved from other project.' field :moved_to, Types::IssueType, null: true, - description: 'Updated Issue after it got moved to another project.' + description: 'Updated Issue after it got moved to another project.' field :closed_as_duplicate_of, Types::IssueType, null: true, - description: 'Issue this issue was closed as a duplicate of.' + description: 'Issue this issue was closed as a duplicate of.' field :create_note_email, GraphQL::Types::String, null: true, - description: 'User specific email address for the issue.' + description: 'User specific email address for the issue.' field :timelogs, Types::TimelogType.connection_type, null: false, - description: 'Timelogs on the issue.' + description: 'Timelogs on the issue.' field :project_id, GraphQL::Types::Int, null: false, method: :project_id, - description: 'ID of the issue project.' + description: 'ID of the issue project.' field :customer_relations_contacts, Types::CustomerRelations::ContactType.connection_type, null: true, - description: 'Customer relations contacts of the issue.' + description: 'Customer relations contacts of the issue.' field :escalation_status, Types::IncidentManagement::EscalationStatusEnum, null: true, - description: 'Escalation status of the issue.' + description: 'Escalation status of the issue.' markdown_field :title_html, null: true markdown_field :description_html, null: true diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb index bc21b802179..1044c2ceea4 100644 --- a/app/graphql/types/issue_type_enum.rb +++ b/app/graphql/types/issue_type_enum.rb @@ -11,6 +11,6 @@ module Types value 'TASK', value: 'task', description: 'Task issue type. Available only when feature flag `work_items` is enabled.', - deprecated: { milestone: '15.2', reason: :alpha } + alpha: { milestone: '15.2' } end end diff --git a/app/graphql/types/jira_import_type.rb b/app/graphql/types/jira_import_type.rb index 8477f0b97f0..bcbecff1ad8 100644 --- a/app/graphql/types/jira_import_type.rb +++ b/app/graphql/types/jira_import_type.rb @@ -7,19 +7,19 @@ module Types graphql_name 'JiraImport' field :created_at, Types::TimeType, null: true, - description: 'Timestamp of when the Jira import was created.' + description: 'Timestamp of when the Jira import was created.' field :failed_to_import_count, GraphQL::Types::Int, null: false, - description: 'Count of issues that failed to import.' + description: 'Count of issues that failed to import.' field :imported_issues_count, GraphQL::Types::Int, null: false, - description: 'Count of issues that were successfully imported.' + description: 'Count of issues that were successfully imported.' field :jira_project_key, GraphQL::Types::String, null: false, - description: 'Project key for the imported Jira project.' + description: 'Project key for the imported Jira project.' field :scheduled_at, Types::TimeType, null: true, - description: 'Timestamp of when the Jira import was scheduled.' + description: 'Timestamp of when the Jira import was scheduled.' field :scheduled_by, Types::UserType, null: true, - description: 'User that started the Jira import.' + description: 'User that started the Jira import.' field :total_issue_count, GraphQL::Types::Int, null: false, - description: 'Total count of issues that were attempted to import.' + description: 'Total count of issues that were attempted to import.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/jira_user_type.rb b/app/graphql/types/jira_user_type.rb index aba05385ece..aa070d2c4c9 100644 --- a/app/graphql/types/jira_user_type.rb +++ b/app/graphql/types/jira_user_type.rb @@ -7,16 +7,18 @@ module Types graphql_name 'JiraUser' field :gitlab_id, GraphQL::Types::Int, null: true, - description: 'ID of the matched GitLab user.' + description: 'ID of the matched GitLab user.' field :gitlab_name, GraphQL::Types::String, null: true, - description: 'Name of the matched GitLab user.' + description: 'Name of the matched GitLab user.' field :gitlab_username, GraphQL::Types::String, null: true, - description: 'Username of the matched GitLab user.' + description: 'Username of the matched GitLab user.' field :jira_account_id, GraphQL::Types::String, null: false, - description: 'Account ID of the Jira user.' + description: 'Account ID of the Jira user.' field :jira_display_name, GraphQL::Types::String, null: false, - description: 'Display name of the Jira user.' - field :jira_email, GraphQL::Types::String, null: true, + description: 'Display name of the Jira user.' + field :jira_email, + GraphQL::Types::String, + null: true, description: 'Email of the Jira user, returned only for users with public emails.' end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index b5b3e20bcbc..05b703e60af 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -9,19 +9,21 @@ module Types authorize :read_label field :color, GraphQL::Types::String, null: false, - description: 'Background color of the label.' + description: 'Background color of the label.' field :created_at, Types::TimeType, null: false, - description: 'When this label was created.' - field :description, GraphQL::Types::String, null: true, + description: 'When this label was created.' + field :description, + GraphQL::Types::String, + null: true, description: 'Description of the label (Markdown rendered as HTML for caching).' field :id, GraphQL::Types::ID, null: false, - description: 'Label ID.' + description: 'Label ID.' field :text_color, GraphQL::Types::String, null: false, - description: 'Text color of the label.' + description: 'Text color of the label.' field :title, GraphQL::Types::String, null: false, - description: 'Content of the label.' + description: 'Content of the label.' field :updated_at, Types::TimeType, null: false, - description: 'When this label was last updated.' + description: 'When this label was last updated.' markdown_field :description_html, null: true end diff --git a/app/graphql/types/member_interface.rb b/app/graphql/types/member_interface.rb index 67d0e18b522..edadbcddfb3 100644 --- a/app/graphql/types/member_interface.rb +++ b/app/graphql/types/member_interface.rb @@ -5,25 +5,25 @@ module Types include BaseInterface field :id, GraphQL::Types::ID, null: false, - description: 'ID of the member.' + description: 'ID of the member.' field :access_level, Types::AccessLevelType, null: true, - description: 'GitLab::Access level.' + description: 'GitLab::Access level.' field :created_by, Types::UserType, null: true, - description: 'User that authorized membership.' + description: 'User that authorized membership.' field :created_at, Types::TimeType, null: true, - description: 'Date and time the membership was created.' + description: 'Date and time the membership was created.' field :updated_at, Types::TimeType, null: true, - description: 'Date and time the membership was last updated.' + description: 'Date and time the membership was last updated.' field :expires_at, Types::TimeType, null: true, - description: 'Date and time the membership expires.' + description: 'Date and time the membership expires.' field :user, Types::UserType, null: true, - description: 'User that is associated with the member object.' + description: 'User that is associated with the member object.' field :merge_request_interaction, Types::UserMergeRequestInteractionType, null: true, diff --git a/app/graphql/types/merge_request_connection_type.rb b/app/graphql/types/merge_request_connection_type.rb index 9596c812c69..f77b66954c1 100644 --- a/app/graphql/types/merge_request_connection_type.rb +++ b/app/graphql/types/merge_request_connection_type.rb @@ -3,7 +3,9 @@ module Types # rubocop: disable Graphql/AuthorizeTypes class MergeRequestConnectionType < Types::CountableConnectionType - field :total_time_to_merge, GraphQL::Types::Float, null: true, + field :total_time_to_merge, + GraphQL::Types::Float, + null: true, description: 'Total sum of time to merge, in seconds, for the collection of merge requests.' # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index cc3df474bef..d88653f2f8c 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -17,93 +17,98 @@ module Types present_using MergeRequestPresenter field :created_at, Types::TimeType, null: false, - description: 'Timestamp of when the merge request was created.' + description: 'Timestamp of when the merge request was created.' field :description, GraphQL::Types::String, null: true, - description: 'Description of the merge request (Markdown rendered as HTML for caching).' + description: 'Description of the merge request (Markdown rendered as HTML for caching).' field :diff_head_sha, GraphQL::Types::String, null: true, - description: 'Diff head SHA of the merge request.' + description: 'Diff head SHA of the merge request.' field :diff_refs, Types::DiffRefsType, null: true, - description: 'References of the base SHA, the head SHA, and the start SHA for this merge request.' + description: 'References of the base SHA, the head SHA, and the start SHA for this merge request.' field :diff_stats, [Types::DiffStatsType], null: true, calls_gitaly: true, - description: 'Details about which files were changed in this merge request.' do + description: 'Details about which files were changed in this merge request.' do argument :path, GraphQL::Types::String, required: false, description: 'Specific file path.' end field :draft, GraphQL::Types::Boolean, method: :draft?, null: false, - description: 'Indicates if the merge request is a draft.' + description: 'Indicates if the merge request is a draft.' field :id, GraphQL::Types::ID, null: false, - description: 'ID of the merge request.' + description: 'ID of the merge request.' field :iid, GraphQL::Types::String, null: false, - description: 'Internal ID of the merge request.' + description: 'Internal ID of the merge request.' field :merge_when_pipeline_succeeds, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).' + description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).' field :merged_at, Types::TimeType, null: true, complexity: 5, - description: 'Timestamp of when the merge request was merged, null if not merged.' + description: 'Timestamp of when the merge request was merged, null if not merged.' field :project, Types::ProjectType, null: false, - description: 'Alias for target_project.' + description: 'Alias for target_project.' field :project_id, GraphQL::Types::Int, null: false, method: :target_project_id, - description: 'ID of the merge request project.' + description: 'ID of the merge request project.' field :source_branch, GraphQL::Types::String, null: false, - description: 'Source branch of the merge request.' + description: 'Source branch of the merge request.' field :source_branch_protected, GraphQL::Types::Boolean, null: false, calls_gitaly: true, - description: 'Indicates if the source branch is protected.' + description: 'Indicates if the source branch is protected.' field :source_project, Types::ProjectType, null: true, - description: 'Source project of the merge request.' + description: 'Source project of the merge request.' field :source_project_id, GraphQL::Types::Int, null: true, - description: 'ID of the merge request source project.' + description: 'ID of the merge request source project.' field :state, MergeRequestStateEnum, null: false, - description: 'State of the merge request.' + description: 'State of the merge request.' field :target_branch, GraphQL::Types::String, null: false, - description: 'Target branch of the merge request.' + description: 'Target branch of the merge request.' field :target_project, Types::ProjectType, null: false, - description: 'Target project of the merge request.' + description: 'Target project of the merge request.' field :target_project_id, GraphQL::Types::Int, null: false, - description: 'ID of the merge request target project.' + description: 'ID of the merge request target project.' field :title, GraphQL::Types::String, null: false, - description: 'Title of the merge request.' + description: 'Title of the merge request.' field :updated_at, Types::TimeType, null: false, - description: 'Timestamp of when the merge request was last updated.' + description: 'Timestamp of when the merge request was last updated.' field :allow_collaboration, GraphQL::Types::Boolean, null: true, - description: 'Indicates if members of the target project can push to the fork.' + description: 'Indicates if members of the target project can push to the fork.' field :default_merge_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'Default merge commit message of the merge request.' + description: 'Default merge commit message of the merge request.' field :default_squash_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'Default squash commit message of the merge request.' + description: 'Default squash commit message of the merge request.' field :diff_stats_summary, Types::DiffStatsSummaryType, null: true, calls_gitaly: true, - description: 'Summary of which files were changed in this merge request.' + description: 'Summary of which files were changed in this merge request.' field :diverged_from_target_branch, GraphQL::Types::Boolean, null: false, calls_gitaly: true, method: :diverged_from_target_branch?, description: 'Indicates if the source branch is behind the target branch.' field :downvotes, GraphQL::Types::Int, null: false, - description: 'Number of downvotes for the merge request.' + description: 'Number of downvotes for the merge request.' field :force_remove_source_branch, GraphQL::Types::Boolean, method: :force_remove_source_branch?, null: true, - description: 'Indicates if the project settings will lead to source branch deletion after merge.' + description: 'Indicates if the project settings will lead to source branch deletion after merge.' field :in_progress_merge_commit_sha, GraphQL::Types::String, null: true, - description: 'Commit SHA of the merge request if merge is in progress.' + description: 'Commit SHA of the merge request if merge is in progress.' field :merge_commit_sha, GraphQL::Types::String, null: true, - description: 'SHA of the merge request commit (set once merged).' + description: 'SHA of the merge request commit (set once merged).' field :merge_error, GraphQL::Types::String, null: true, - description: 'Error message due to a merge error.' + description: 'Error message due to a merge error.' field :merge_ongoing, GraphQL::Types::Boolean, method: :merge_ongoing?, null: false, - description: 'Indicates if a merge is currently occurring.' + description: 'Indicates if a merge is currently occurring.' field :merge_status, GraphQL::Types::String, method: :public_merge_status, null: true, - description: 'Status of the merge request.', - deprecated: { reason: :renamed, replacement: 'MergeRequest.mergeStatusEnum', milestone: '14.0' } + description: 'Status of the merge request.', + deprecated: { reason: :renamed, replacement: 'MergeRequest.mergeStatusEnum', milestone: '14.0' } field :merge_status_enum, ::Types::MergeRequests::MergeStatusEnum, method: :public_merge_status, null: true, description: 'Merge status of the merge request.' + + field :detailed_merge_status, ::Types::MergeRequests::DetailedMergeStatusEnum, method: :detailed_merge_status, null: true, + calls_gitaly: true, + description: 'Detailed merge status of the merge request.', alpha: { milestone: '15.3' } + field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true, - calls_gitaly: true, - description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.' + calls_gitaly: true, + description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.' field :rebase_commit_sha, GraphQL::Types::String, null: true, - description: 'Rebase commit SHA of the merge request.' + description: 'Rebase commit SHA of the merge request.' field :rebase_in_progress, GraphQL::Types::Boolean, method: :rebase_in_progress?, null: false, calls_gitaly: true, - description: 'Indicates if there is a rebase currently in progress for the merge request.' + description: 'Indicates if there is a rebase currently in progress for the merge request.' field :should_be_rebased, GraphQL::Types::Boolean, method: :should_be_rebased?, null: false, calls_gitaly: true, - description: 'Indicates if the merge request will be rebased.' + description: 'Indicates if the merge request will be rebased.' field :should_remove_source_branch, GraphQL::Types::Boolean, method: :should_remove_source_branch?, null: true, - description: 'Indicates if the source branch of the merge request will be deleted after merge.' + description: 'Indicates if the source branch of the merge request will be deleted after merge.' field :source_branch_exists, GraphQL::Types::Boolean, null: false, calls_gitaly: true, method: :source_branch_exists?, @@ -113,18 +118,18 @@ module Types method: :target_branch_exists?, description: 'Indicates if the target branch of the merge request exists.' field :upvotes, GraphQL::Types::Int, null: false, - description: 'Number of upvotes for the merge request.' + description: 'Number of upvotes for the merge request.' field :user_discussions_count, GraphQL::Types::Int, null: true, - description: 'Number of user discussions in the merge request.', - resolver: Resolvers::UserDiscussionsCountResolver + description: 'Number of user discussions in the merge request.', + resolver: Resolvers::UserDiscussionsCountResolver field :user_notes_count, GraphQL::Types::Int, null: true, - description: 'User notes count of the merge request.', - resolver: Resolvers::UserNotesCountResolver + description: 'User notes count of the merge request.', + resolver: Resolvers::UserNotesCountResolver field :web_url, GraphQL::Types::String, null: true, - description: 'Web URL of the merge request.' + description: 'Web URL of the merge request.' field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline, - description: 'Pipeline running on the branch HEAD of the merge request.' + description: 'Pipeline running on the branch HEAD of the merge request.' field :pipelines, null: true, description: 'Pipelines for the merge request. Note: for performance reasons, no more than the most recent 500 pipelines will be returned.', @@ -136,72 +141,72 @@ module Types complexity: 5, description: 'Assignees of the merge request.' field :author, Types::MergeRequests::AuthorType, null: true, - description: 'User who created this merge request.' + description: 'User who created this merge request.' field :discussion_locked, GraphQL::Types::Boolean, description: 'Indicates if comments on the merge request are locked to members only.', null: false field :human_time_estimate, GraphQL::Types::String, null: true, - description: 'Human-readable time estimate of the merge request.' + description: 'Human-readable time estimate of the merge request.' field :human_total_time_spent, GraphQL::Types::String, null: true, - description: 'Human-readable total time reported as spent on the merge request.' + description: 'Human-readable total time reported as spent on the merge request.' field :labels, Types::LabelType.connection_type, null: true, complexity: 5, - description: 'Labels of the merge request.' + description: 'Labels of the merge request.' field :milestone, Types::MilestoneType, null: true, - description: 'Milestone of the merge request.' + description: 'Milestone of the merge request.' field :participants, Types::MergeRequests::ParticipantType.connection_type, null: true, complexity: 15, - description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.', - resolver: Resolvers::Users::ParticipantsResolver + description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.', + resolver: Resolvers::Users::ParticipantsResolver field :reference, GraphQL::Types::String, null: false, method: :to_reference, - description: 'Internal reference of the merge request. Returned in shortened format by default.' do + description: 'Internal reference of the merge request. Returned in shortened format by default.' do argument :full, GraphQL::Types::Boolean, required: false, default_value: false, - description: 'Boolean option specifying whether the reference should be returned in full.' + description: 'Boolean option specifying whether the reference should be returned in full.' end field :auto_merge_enabled, GraphQL::Types::Boolean, null: false, - description: 'Indicates if auto merge is enabled for the merge request.' + description: 'Indicates if auto merge is enabled for the merge request.' field :commit_count, GraphQL::Types::Int, null: true, method: :commits_count, - description: 'Number of commits in the merge request.' + description: 'Number of commits in the merge request.' field :conflicts, GraphQL::Types::Boolean, null: false, method: :cannot_be_merged?, - description: 'Indicates if the merge request has conflicts.' + description: 'Indicates if the merge request has conflicts.' field :reviewers, type: Types::MergeRequests::ReviewerType.connection_type, null: true, complexity: 5, description: 'Users from whom a review has been requested.' field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5, - description: 'Indicates if the currently logged in user is subscribed to this merge request.' + description: 'Indicates if the currently logged in user is subscribed to this merge request.' field :task_completion_status, Types::TaskCompletionStatus, null: false, - description: Types::TaskCompletionStatus.description + description: Types::TaskCompletionStatus.description field :time_estimate, GraphQL::Types::Int, null: false, - description: 'Time estimate of the merge request.' + description: 'Time estimate of the merge request.' field :total_time_spent, GraphQL::Types::Int, null: false, - description: 'Total time reported as spent on the merge request.' + description: 'Total time reported as spent on the merge request.' field :approved_by, Types::UserType.connection_type, null: true, - description: 'Users who approved the merge request.', method: :approved_by_users + description: 'Users who approved the merge request.', method: :approved_by_users field :auto_merge_strategy, GraphQL::Types::String, null: true, - description: 'Selected auto merge strategy.' + description: 'Selected auto merge strategy.' field :available_auto_merge_strategies, [GraphQL::Types::String], null: true, calls_gitaly: true, - description: 'Array of available auto merge strategies.' + description: 'Array of available auto merge strategies.' field :commits, Types::CommitType.connection_type, null: true, - calls_gitaly: true, description: 'Merge request commits.' + calls_gitaly: true, description: 'Merge request commits.' field :committers, Types::UserType.connection_type, null: true, complexity: 5, - calls_gitaly: true, description: 'Users who have added commits to the merge request.' + calls_gitaly: true, description: 'Users who have added commits to the merge request.' field :commits_without_merge_commits, Types::CommitType.connection_type, null: true, - calls_gitaly: true, description: 'Merge request commits excluding merge commits.' + calls_gitaly: true, description: 'Merge request commits excluding merge commits.' field :has_ci, GraphQL::Types::Boolean, null: false, method: :has_ci?, - description: 'Indicates if the merge request has CI.' + description: 'Indicates if the merge request has CI.' field :merge_user, Types::UserType, null: true, - description: 'User who merged this merge request or set it to merge when pipeline succeeds.' + description: 'User who merged this merge request or set it to merge when pipeline succeeds.' field :mergeable, GraphQL::Types::Boolean, null: false, method: :mergeable?, calls_gitaly: true, - description: 'Indicates if the merge request is mergeable.' + description: 'Indicates if the merge request is mergeable.' field :security_auto_fix, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the merge request is created by @GitLab-Security-Bot.' + description: 'Indicates if the merge request is created by @GitLab-Security-Bot.' field :squash, GraphQL::Types::Boolean, null: false, - description: 'Indicates if squash on merge is enabled.' + description: 'Indicates if squash on merge is enabled.' field :squash_on_merge, GraphQL::Types::Boolean, null: false, method: :squash_on_merge?, - description: 'Indicates if squash on merge is enabled.' + description: 'Indicates if squash on merge is enabled.' field :timelogs, Types::TimelogType.connection_type, null: false, - description: 'Timelogs on the merge request.' + description: 'Timelogs on the merge request.' markdown_field :title_html, null: true markdown_field :description_html, null: true diff --git a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb new file mode 100644 index 00000000000..58104159303 --- /dev/null +++ b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module MergeRequests + class DetailedMergeStatusEnum < BaseEnum + graphql_name 'DetailedMergeStatus' + description 'Detailed representation of whether a GitLab merge request can be merged.' + + value 'UNCHECKED', + value: :unchecked, + description: 'Merge status has not been checked.' + value 'CHECKING', + value: :checking, + description: 'Currently checking for mergeability.' + value 'MERGEABLE', + value: :mergeable, + description: 'Branch can be merged.' + value 'BROKEN_STATUS', + value: :broken_status, + description: 'Can not merge the source into the target branch, potential conflict.' + value 'CI_MUST_PASS', + value: :ci_must_pass, + description: 'Pipeline must succeed before merging.' + value 'DISCUSSIONS_NOT_RESOLVED', + value: :discussions_not_resolved, + description: 'Discussions must be resolved before merging.' + value 'DRAFT_STATUS', + value: :draft_status, + description: 'Merge request must not be draft before merging.' + value 'NOT_OPEN', + value: :not_open, + description: 'Merge request must be open before merging.' + value 'NOT_APPROVED', + value: :not_approved, + description: 'Merge request must be approved before merging.' + value 'BLOCKED_STATUS', + value: :merge_request_blocked, + description: 'Merge request is blocked by another merge request.' + value 'POLICIES_DENIED', + value: :policies_denied, + description: 'There are denied policies for the merge request.' + end + end +end diff --git a/app/graphql/types/metadata/kas_type.rb b/app/graphql/types/metadata/kas_type.rb index 6a8d54b6c7d..e29635aedcf 100644 --- a/app/graphql/types/metadata/kas_type.rb +++ b/app/graphql/types/metadata/kas_type.rb @@ -8,11 +8,11 @@ module Types authorize :read_instance_metadata field :enabled, GraphQL::Types::Boolean, null: false, - description: 'Indicates whether the Kubernetes Agent Server is enabled.' + description: 'Indicates whether the Kubernetes Agent Server is enabled.' field :external_url, GraphQL::Types::String, null: true, - description: 'URL used by the Agents to communicate with KAS.' + description: 'URL used by the Agents to communicate with KAS.' field :version, GraphQL::Types::String, null: true, - description: 'KAS version.' + description: 'KAS version.' end end end diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb index 6fb141a50c9..b00fcfd38ad 100644 --- a/app/graphql/types/metadata_type.rb +++ b/app/graphql/types/metadata_type.rb @@ -7,10 +7,10 @@ module Types authorize :read_instance_metadata field :kas, ::Types::Metadata::KasType, null: false, - description: 'Metadata about KAS.' + description: 'Metadata about KAS.' field :revision, GraphQL::Types::String, null: false, - description: 'Revision.' + description: 'Revision.' field :version, GraphQL::Types::String, null: false, - description: 'Version.' + description: 'Version.' end end diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb index 04cac55894e..5570b904d79 100644 --- a/app/graphql/types/metrics/dashboard_type.rb +++ b/app/graphql/types/metrics/dashboard_type.rb @@ -8,12 +8,16 @@ module Types graphql_name 'MetricsDashboard' field :path, GraphQL::Types::String, null: true, - description: 'Path to a file with the dashboard definition.' + description: 'Path to a file with the dashboard definition.' - field :schema_validation_warnings, [GraphQL::Types::String], null: true, + field :schema_validation_warnings, + [GraphQL::Types::String], + null: true, description: 'Dashboard schema validation warnings.' - field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true, + field :annotations, + Types::Metrics::Dashboards::AnnotationType.connection_type, + null: true, description: 'Annotations added to the dashboard.', resolver: Resolvers::Metrics::Dashboards::AnnotationResolver diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb index 0621cf4d674..ec479078272 100644 --- a/app/graphql/types/metrics/dashboards/annotation_type.rb +++ b/app/graphql/types/metrics/dashboards/annotation_type.rb @@ -8,20 +8,22 @@ module Types authorize :read_metrics_dashboard_annotation field :description, GraphQL::Types::String, null: true, - description: 'Description of the annotation.' + description: 'Description of the annotation.' field :id, GraphQL::Types::ID, null: false, - description: 'ID of the annotation.' + description: 'ID of the annotation.' - field :panel_id, GraphQL::Types::String, null: true, + field :panel_id, + GraphQL::Types::String, + null: true, description: 'ID of a dashboard panel to which the annotation should be scoped.', method: :panel_xid field :starting_at, Types::TimeType, null: true, - description: 'Timestamp marking start of annotated time span.' + description: 'Timestamp marking start of annotated time span.' field :ending_at, Types::TimeType, null: true, - description: 'Timestamp marking end of annotated time span.' + description: 'Timestamp marking end of annotated time span.' end end end diff --git a/app/graphql/types/milestone_stats_type.rb b/app/graphql/types/milestone_stats_type.rb index 6d8b7deb8e7..36448c4987b 100644 --- a/app/graphql/types/milestone_stats_type.rb +++ b/app/graphql/types/milestone_stats_type.rb @@ -7,10 +7,14 @@ module Types authorize :read_milestone - field :total_issues_count, GraphQL::Types::Int, null: true, + field :total_issues_count, + GraphQL::Types::Int, + null: true, description: 'Total number of issues associated with the milestone.' - field :closed_issues_count, GraphQL::Types::Int, null: true, + field :closed_issues_count, + GraphQL::Types::Int, + null: true, description: 'Number of closed issues associated with the milestone.' end end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index 7741fd723f0..447528917f0 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -12,52 +12,52 @@ module Types alias_method :milestone, :object field :id, GraphQL::Types::ID, null: false, - description: 'ID of the milestone.' + description: 'ID of the milestone.' field :iid, GraphQL::Types::ID, null: false, - description: "Internal ID of the milestone." + description: "Internal ID of the milestone." field :title, GraphQL::Types::String, null: false, - description: 'Title of the milestone.' + description: 'Title of the milestone.' field :description, GraphQL::Types::String, null: true, - description: 'Description of the milestone.' + description: 'Description of the milestone.' field :state, Types::MilestoneStateEnum, null: false, - description: 'State of the milestone.' + description: 'State of the milestone.' field :expired, GraphQL::Types::Boolean, null: false, - description: 'Expired state of the milestone (a milestone is expired when the due date is past the current date). Defaults to `false` when due date has not been set.' + description: 'Expired state of the milestone (a milestone is expired when the due date is past the current date). Defaults to `false` when due date has not been set.' field :web_path, GraphQL::Types::String, null: false, method: :milestone_path, - description: 'Web path of the milestone.' + description: 'Web path of the milestone.' field :due_date, Types::TimeType, null: true, - description: 'Timestamp of the milestone due date.' + description: 'Timestamp of the milestone due date.' field :start_date, Types::TimeType, null: true, - description: 'Timestamp of the milestone start date.' + description: 'Timestamp of the milestone start date.' field :created_at, Types::TimeType, null: false, - description: 'Timestamp of milestone creation.' + description: 'Timestamp of milestone creation.' field :updated_at, Types::TimeType, null: false, - description: 'Timestamp of last milestone update.' + description: 'Timestamp of last milestone update.' field :project_milestone, GraphQL::Types::Boolean, null: false, - description: 'Indicates if milestone is at project level.', - method: :project_milestone? + description: 'Indicates if milestone is at project level.', + method: :project_milestone? field :group_milestone, GraphQL::Types::Boolean, null: false, - description: 'Indicates if milestone is at group level.', - method: :group_milestone? + description: 'Indicates if milestone is at group level.', + method: :group_milestone? field :subgroup_milestone, GraphQL::Types::Boolean, null: false, - description: 'Indicates if milestone is at subgroup level.', - method: :subgroup_milestone? + description: 'Indicates if milestone is at subgroup level.', + method: :subgroup_milestone? field :stats, Types::MilestoneStatsType, null: true, - description: 'Milestone statistics.' + description: 'Milestone statistics.' field :releases, ::Types::ReleaseType.connection_type, null: true, diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 46ab3f3f432..499c2e786bf 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -37,8 +37,8 @@ module Types mount_mutation Mutations::Clusters::AgentTokens::Create mount_mutation Mutations::Clusters::AgentTokens::Revoke 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::CustomEmoji::Create, _deprecated_feature_flag: :custom_emoji + mount_mutation Mutations::CustomEmoji::Destroy, _deprecated_feature_flag: :custom_emoji mount_mutation Mutations::CustomerRelations::Contacts::Create mount_mutation Mutations::CustomerRelations::Contacts::Update mount_mutation Mutations::CustomerRelations::Organizations::Create @@ -72,10 +72,8 @@ module Types mount_mutation Mutations::MergeRequests::SetSubscription mount_mutation Mutations::MergeRequests::SetDraft, calls_gitaly: true mount_mutation Mutations::MergeRequests::SetAssignees + mount_mutation Mutations::MergeRequests::SetReviewers mount_mutation Mutations::MergeRequests::ReviewerRereview - mount_mutation Mutations::MergeRequests::RequestAttention - mount_mutation Mutations::MergeRequests::RemoveAttentionRequest - mount_mutation Mutations::MergeRequests::ToggleAttentionRequested mount_mutation Mutations::Metrics::Dashboard::Annotations::Create mount_mutation Mutations::Metrics::Dashboard::Annotations::Delete mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true @@ -94,6 +92,7 @@ module Types mount_mutation Mutations::Terraform::State::Delete mount_mutation Mutations::Terraform::State::Lock mount_mutation Mutations::Terraform::State::Unlock + mount_mutation Mutations::Timelogs::Create mount_mutation Mutations::Timelogs::Delete mount_mutation Mutations::Todos::Create mount_mutation Mutations::Todos::MarkDone @@ -129,6 +128,7 @@ module Types mount_mutation Mutations::Ci::JobTokenScope::RemoveProject mount_mutation Mutations::Ci::Runner::Update mount_mutation Mutations::Ci::Runner::Delete + mount_mutation Mutations::Ci::Runner::BulkDelete, alpha: { milestone: '15.3' } mount_mutation Mutations::Ci::RunnersRegistrationToken::Reset mount_mutation Mutations::Namespace::PackageSettings::Update mount_mutation Mutations::Groups::Update @@ -139,17 +139,18 @@ module Types mount_mutation Mutations::Packages::DestroyFiles mount_mutation Mutations::Packages::Cleanup::Policy::Update mount_mutation Mutations::Echo - mount_mutation Mutations::WorkItems::Create, deprecated: { milestone: '15.1', reason: :alpha } - mount_mutation Mutations::WorkItems::CreateFromTask, deprecated: { milestone: '15.1', reason: :alpha } - mount_mutation Mutations::WorkItems::Delete, deprecated: { milestone: '15.1', reason: :alpha } - mount_mutation Mutations::WorkItems::DeleteTask, deprecated: { milestone: '15.1', reason: :alpha } - mount_mutation Mutations::WorkItems::Update, deprecated: { milestone: '15.1', reason: :alpha } - mount_mutation Mutations::WorkItems::UpdateWidgets, deprecated: { milestone: '15.1', reason: :alpha } - mount_mutation Mutations::WorkItems::UpdateTask, deprecated: { milestone: '15.1', reason: :alpha } + mount_mutation Mutations::WorkItems::Create, alpha: { milestone: '15.1' } + mount_mutation Mutations::WorkItems::CreateFromTask, alpha: { milestone: '15.1' } + mount_mutation Mutations::WorkItems::Delete, alpha: { milestone: '15.1' } + mount_mutation Mutations::WorkItems::DeleteTask, alpha: { milestone: '15.1' } + mount_mutation Mutations::WorkItems::Update, alpha: { milestone: '15.1' } + mount_mutation Mutations::WorkItems::UpdateWidgets, alpha: { milestone: '15.1' } + mount_mutation Mutations::WorkItems::UpdateTask, alpha: { milestone: '15.1' } mount_mutation Mutations::SavedReplies::Create mount_mutation Mutations::SavedReplies::Update mount_mutation Mutations::Pages::MarkOnboardingComplete mount_mutation Mutations::SavedReplies::Destroy + mount_mutation Mutations::Uploads::Delete end end diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index de6a078c6ef..0f634e7c2d3 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -7,38 +7,45 @@ module Types authorize :read_namespace field :id, GraphQL::Types::ID, null: false, - description: 'ID of the namespace.' + description: 'ID of the namespace.' field :full_name, GraphQL::Types::String, null: false, - description: 'Full name of the namespace.' + description: 'Full name of the namespace.' field :full_path, GraphQL::Types::ID, null: false, - description: 'Full path of the namespace.' + description: 'Full path of the namespace.' field :name, GraphQL::Types::String, null: false, - description: 'Name of the namespace.' + description: 'Name of the namespace.' field :path, GraphQL::Types::String, null: false, - description: 'Path of the namespace.' + description: 'Path of the namespace.' - field :cross_project_pipeline_available, GraphQL::Types::Boolean, null: false, + field :cross_project_pipeline_available, + GraphQL::Types::Boolean, + null: false, resolver_method: :cross_project_pipeline_available?, description: 'Indicates if the cross_project_pipeline feature is available for the namespace.' field :description, GraphQL::Types::String, null: true, - description: 'Description of the namespace.' + description: 'Description of the namespace.' - field :lfs_enabled, GraphQL::Types::Boolean, null: true, method: :lfs_enabled?, + field :lfs_enabled, + GraphQL::Types::Boolean, + null: true, + method: :lfs_enabled?, description: 'Indicates if Large File Storage (LFS) is enabled for namespace.' - field :request_access_enabled, GraphQL::Types::Boolean, null: true, + field :request_access_enabled, + GraphQL::Types::Boolean, + null: true, description: 'Indicates if users can request access to namespace.' field :visibility, GraphQL::Types::String, null: true, - description: 'Visibility of the namespace.' + description: 'Visibility of the namespace.' field :root_storage_statistics, Types::RootStorageStatisticsType, null: true, description: 'Aggregated storage statistics of the namespace. Only available for root namespaces.' field :projects, Types::ProjectType.connection_type, null: false, - description: 'Projects within this namespace.', - resolver: ::Resolvers::NamespaceProjectsResolver + description: 'Projects within this namespace.', + resolver: ::Resolvers::NamespaceProjectsResolver field :package_settings, Types::Namespace::PackageSettingsType, @@ -50,8 +57,18 @@ module Types null: true, description: "Shared runners availability for the namespace and its descendants." + field :timelog_categories, + Types::TimeTracking::TimelogCategoryType.connection_type, + null: true, + description: "Timelog categories for the namespace.", + alpha: { milestone: '15.3' } + markdown_field :description_html, null: true + def timelog_categories + object.timelog_categories if Feature.enabled?(:timelog_categories) + end + def cross_project_pipeline_available? object.licensed_feature_available?(:cross_project_pipelines) end diff --git a/app/graphql/types/notes/diff_image_position_input_type.rb b/app/graphql/types/notes/diff_image_position_input_type.rb index d535dea2e07..8c82f4fec2e 100644 --- a/app/graphql/types/notes/diff_image_position_input_type.rb +++ b/app/graphql/types/notes/diff_image_position_input_type.rb @@ -5,13 +5,21 @@ module Types class DiffImagePositionInputType < DiffPositionBaseInputType graphql_name 'DiffImagePositionInput' - argument :height, GraphQL::Types::Int, required: true, + argument :height, + GraphQL::Types::Int, + required: true, description: copy_field_description(Types::Notes::DiffPositionType, :height) - argument :width, GraphQL::Types::Int, required: true, + argument :width, + GraphQL::Types::Int, + required: true, description: copy_field_description(Types::Notes::DiffPositionType, :width) - argument :x, GraphQL::Types::Int, required: true, + argument :x, + GraphQL::Types::Int, + required: true, description: copy_field_description(Types::Notes::DiffPositionType, :x) - argument :y, GraphQL::Types::Int, required: true, + argument :y, + GraphQL::Types::Int, + required: true, description: copy_field_description(Types::Notes::DiffPositionType, :y) end end diff --git a/app/graphql/types/notes/diff_position_base_input_type.rb b/app/graphql/types/notes/diff_position_base_input_type.rb index 2780dbab573..433cd442235 100644 --- a/app/graphql/types/notes/diff_position_base_input_type.rb +++ b/app/graphql/types/notes/diff_position_base_input_type.rb @@ -4,11 +4,11 @@ module Types module Notes class DiffPositionBaseInputType < BaseInputObject argument :base_sha, GraphQL::Types::String, required: false, - description: copy_field_description(Types::DiffRefsType, :base_sha) + description: copy_field_description(Types::DiffRefsType, :base_sha) argument :head_sha, GraphQL::Types::String, required: true, - description: copy_field_description(Types::DiffRefsType, :head_sha) + description: copy_field_description(Types::DiffRefsType, :head_sha) argument :start_sha, GraphQL::Types::String, required: true, - description: copy_field_description(Types::DiffRefsType, :start_sha) + description: copy_field_description(Types::DiffRefsType, :start_sha) argument :paths, Types::DiffPathsInputType, diff --git a/app/graphql/types/notes/diff_position_input_type.rb b/app/graphql/types/notes/diff_position_input_type.rb index ccde4188f29..5823a4f19cc 100644 --- a/app/graphql/types/notes/diff_position_input_type.rb +++ b/app/graphql/types/notes/diff_position_input_type.rb @@ -6,9 +6,9 @@ module Types graphql_name 'DiffPositionInput' argument :new_line, GraphQL::Types::Int, required: false, - description: "#{copy_field_description(Types::Notes::DiffPositionType, :new_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field." + description: "#{copy_field_description(Types::Notes::DiffPositionType, :new_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field." argument :old_line, GraphQL::Types::Int, required: false, - description: "#{copy_field_description(Types::Notes::DiffPositionType, :old_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field." + description: "#{copy_field_description(Types::Notes::DiffPositionType, :old_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field." end end end diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb index 531bd0edac0..dd343cf45e4 100644 --- a/app/graphql/types/notes/diff_position_type.rb +++ b/app/graphql/types/notes/diff_position_type.rb @@ -7,33 +7,35 @@ module Types class DiffPositionType < BaseObject graphql_name 'DiffPosition' - field :diff_refs, Types::DiffRefsType, null: false, + field :diff_refs, + Types::DiffRefsType, + null: false, description: 'Information about the branch, HEAD, and base at the time of commenting.' field :file_path, GraphQL::Types::String, null: false, - description: 'Path of the file that was changed.' + description: 'Path of the file that was changed.' field :new_path, GraphQL::Types::String, null: true, - description: 'Path of the file on the HEAD SHA.' + description: 'Path of the file on the HEAD SHA.' field :old_path, GraphQL::Types::String, null: true, - description: 'Path of the file on the start SHA.' + description: 'Path of the file on the start SHA.' field :position_type, Types::Notes::PositionTypeEnum, null: false, - description: 'Type of file the position refers to.' + description: 'Type of file the position refers to.' # Fields for text positions field :new_line, GraphQL::Types::Int, null: true, - description: 'Line on HEAD SHA that was changed.' + description: 'Line on HEAD SHA that was changed.' field :old_line, GraphQL::Types::Int, null: true, - description: 'Line on start SHA that was changed.' + description: 'Line on start SHA that was changed.' # Fields for image positions field :height, GraphQL::Types::Int, null: true, - description: 'Total height of the image.' + description: 'Total height of the image.' field :width, GraphQL::Types::Int, null: true, - description: 'Total width of the image.' + description: 'Total width of the image.' field :x, GraphQL::Types::Int, null: true, - description: 'X position of the note.' + description: 'X position of the note.' field :y, GraphQL::Types::Int, null: true, - description: 'Y position of the note.' + description: 'Y position of the note.' def old_line object.old_line if object.on_text? diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb index 89778b2a99a..5e40c8008a9 100644 --- a/app/graphql/types/notes/discussion_type.rb +++ b/app/graphql/types/notes/discussion_type.rb @@ -12,15 +12,15 @@ module Types implements(Types::ResolvableInterface) field :created_at, Types::TimeType, null: false, - description: "Timestamp of the discussion's creation." + description: "Timestamp of the discussion's creation." field :id, DiscussionID, null: false, - description: "ID of this discussion." + description: "ID of this discussion." field :noteable, Types::NoteableType, null: true, - description: 'Object which the discussion belongs to.' + description: 'Object which the discussion belongs to.' field :notes, Types::Notes::NoteType.connection_type, null: false, - description: 'All notes in the discussion.' + description: 'All notes in the discussion.' field :reply_id, DiscussionID, null: false, - description: 'ID used to reply to this discussion.' + description: 'ID used to reply to this discussion.' # DiscussionID.coerce_result is suitable here, but will always mark this # as being a 'Discussion'. Using `GlobalId.build` guarantees that we get diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index 32f3ff7f556..c254460a51f 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -12,7 +12,7 @@ module Types implements(Types::ResolvableInterface) field :id, ::Types::GlobalIDType[::Note], null: false, - description: 'ID of the note.' + description: 'ID of the note.' field :project, Types::ProjectType, null: true, @@ -25,7 +25,9 @@ module Types field :system, GraphQL::Types::Boolean, null: false, description: 'Indicates whether this note was created by the system or by a user.' - field :system_note_icon_name, GraphQL::Types::String, null: true, + field :system_note_icon_name, + GraphQL::Types::String, + null: true, description: 'Name of the icon corresponding to a system note.' field :body, GraphQL::Types::String, @@ -34,16 +36,26 @@ module Types description: 'Content of the note.' field :confidential, GraphQL::Types::Boolean, null: true, - description: 'Indicates if this note is confidential.', - method: :confidential? + description: 'Indicates if this note is confidential.', + method: :confidential?, + deprecated: { + reason: :renamed, + replacement: 'internal', + milestone: '15.3' + } + + field :internal, GraphQL::Types::Boolean, null: true, + description: 'Indicates if this note is internal.', + method: :confidential? + field :created_at, Types::TimeType, null: false, - description: 'Timestamp of the note creation.' + description: 'Timestamp of the note creation.' field :discussion, Types::Notes::DiscussionType, null: true, - description: 'Discussion this note is a part of.' + description: 'Discussion this note is a part of.' field :position, Types::Notes::DiffPositionType, null: true, - description: 'Position of this note on a diff.' + description: 'Position of this note on a diff.' field :updated_at, Types::TimeType, null: false, - description: "Timestamp of the note's last activity." + description: "Timestamp of the note's last activity." field :url, GraphQL::Types::String, null: true, description: 'URL to view this Note in the Web UI.' diff --git a/app/graphql/types/packages/package_base_type.rb b/app/graphql/types/packages/package_base_type.rb index 06ccde94cd4..2dc4a2a2bb6 100644 --- a/app/graphql/types/packages/package_base_type.rb +++ b/app/graphql/types/packages/package_base_type.rb @@ -10,12 +10,12 @@ module Types authorize :read_package - field :id, ::Types::GlobalIDType[::Packages::Package], null: false, - description: 'ID of the package.' + field :id, ::Types::GlobalIDType[::Packages::Package], null: false, description: 'ID of the package.' field :can_destroy, GraphQL::Types::Boolean, null: false, description: 'Whether the user can destroy the package.' field :created_at, Types::TimeType, null: false, description: 'Date of creation.' - field :metadata, Types::Packages::MetadataType, null: true, + field :metadata, Types::Packages::MetadataType, + null: true, description: 'Package metadata.' field :name, GraphQL::Types::String, null: false, description: 'Name of the package.' field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'Package type.' diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb index ae57e103f40..0413177ef14 100644 --- a/app/graphql/types/packages/package_details_type.rb +++ b/app/graphql/types/packages/package_details_type.rb @@ -11,7 +11,7 @@ module Types authorize :read_package field :versions, ::Types::Packages::PackageBaseType.connection_type, null: true, - description: 'Other versions of the package.' + description: 'Other versions of the package.' field :package_files, Types::Packages::PackageFileType.connection_type, null: true, method: :installable_package_files, description: 'Package files.' diff --git a/app/graphql/types/packages/package_file_type.rb b/app/graphql/types/packages/package_file_type.rb index b058dc0ab0d..3ee0d983745 100644 --- a/app/graphql/types/packages/package_file_type.rb +++ b/app/graphql/types/packages/package_file_type.rb @@ -11,7 +11,7 @@ module Types field :download_path, GraphQL::Types::String, null: false, description: 'Download path of the package file.' field :file_md5, GraphQL::Types::String, null: true, description: 'Md5 of the package file.' field :file_metadata, Types::Packages::FileMetadataType, null: true, - description: 'File metadata.' + description: 'File metadata.' field :file_name, GraphQL::Types::String, null: false, description: 'Name of the package file.' field :file_sha1, GraphQL::Types::String, null: true, description: 'Sha1 of the package file.' field :file_sha256, GraphQL::Types::String, null: true, description: 'Sha256 of the package file.' diff --git a/app/graphql/types/permission_types/group_enum.rb b/app/graphql/types/permission_types/group_enum.rb index 8b0fee8898c..f636d43790f 100644 --- a/app/graphql/types/permission_types/group_enum.rb +++ b/app/graphql/types/permission_types/group_enum.rb @@ -7,7 +7,8 @@ module Types description 'User permission on groups' value 'CREATE_PROJECTS', value: :create_projects, description: 'Groups where the user can create projects.' - value 'TRANSFER_PROJECTS', value: :transfer_projects, + value 'TRANSFER_PROJECTS', + value: :transfer_projects, description: 'Groups where the user can transfer projects to.' end end diff --git a/app/graphql/types/project_invitation_type.rb b/app/graphql/types/project_invitation_type.rb index b76f05e289f..b5760a911be 100644 --- a/app/graphql/types/project_invitation_type.rb +++ b/app/graphql/types/project_invitation_type.rb @@ -12,7 +12,7 @@ module Types authorize :admin_project field :project, Types::ProjectType, null: true, - description: 'Project ID for the project of the invitation.' + description: 'Project ID for the project of the invitation.' def project Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.source_id).find diff --git a/app/graphql/types/project_member_type.rb b/app/graphql/types/project_member_type.rb index 1f00df84641..2eba0d2dea2 100644 --- a/app/graphql/types/project_member_type.rb +++ b/app/graphql/types/project_member_type.rb @@ -12,7 +12,7 @@ module Types authorize :read_project field :project, Types::ProjectType, null: true, - description: 'Project that User is a member of.' + description: 'Project that User is a member of.' def project Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.source_id).find diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb index 5ab3cc33e85..c43baf1280b 100644 --- a/app/graphql/types/project_statistics_type.rb +++ b/app/graphql/types/project_statistics_type.rb @@ -7,27 +7,31 @@ module Types authorize :read_statistics field :commit_count, GraphQL::Types::Float, null: false, - description: 'Commit count of the project.' + description: 'Commit count of the project.' field :build_artifacts_size, GraphQL::Types::Float, null: false, - description: 'Build artifacts size of the project in bytes.' - field :lfs_objects_size, GraphQL::Types::Float, null: false, + description: 'Build artifacts size of the project in bytes.' + field :lfs_objects_size, + GraphQL::Types::Float, + null: false, description: 'Large File Storage (LFS) object size of the project in bytes.' field :packages_size, GraphQL::Types::Float, null: false, - description: 'Packages size of the project in bytes.' + description: 'Packages size of the project in bytes.' field :pipeline_artifacts_size, GraphQL::Types::Float, null: true, - description: 'CI Pipeline artifacts size in bytes.' + description: 'CI Pipeline artifacts size in bytes.' field :repository_size, GraphQL::Types::Float, null: false, - description: 'Repository size of the project in bytes.' + description: 'Repository size of the project in bytes.' field :snippets_size, GraphQL::Types::Float, null: true, - description: 'Snippets size of the project in bytes.' + description: 'Snippets size of the project in bytes.' field :storage_size, GraphQL::Types::Float, null: false, - description: 'Storage size of the project in bytes.' + description: 'Storage size of the project in bytes.' field :uploads_size, GraphQL::Types::Float, null: true, - description: 'Uploads size of the project in bytes.' + description: 'Uploads size of the project in bytes.' field :wiki_size, GraphQL::Types::Float, null: true, - description: 'Wiki size of the project in bytes.' - field :container_registry_size, GraphQL::Types::Float, null: true, + description: 'Wiki size of the project in bytes.' + field :container_registry_size, + GraphQL::Types::Float, + null: true, description: 'Container Registry size of the project in bytes.' end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 7e3800c6a13..ecc6c9d7811 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -11,118 +11,118 @@ module Types expose_permissions Types::PermissionTypes::Project field :id, GraphQL::Types::ID, null: false, - description: 'ID of the project.' + description: 'ID of the project.' field :ci_config_path_or_default, GraphQL::Types::String, null: false, - description: 'Path of the CI configuration file.' + description: 'Path of the CI configuration file.' field :full_path, GraphQL::Types::ID, null: false, - description: 'Full path of the project.' + description: 'Full path of the project.' field :path, GraphQL::Types::String, null: false, - description: 'Path of the project.' + description: 'Path of the project.' field :sast_ci_configuration, Types::CiConfiguration::Sast::Type, null: true, - calls_gitaly: true, - description: 'SAST CI configuration for the project.' + calls_gitaly: true, + description: 'SAST CI configuration for the project.' field :name, GraphQL::Types::String, null: false, - description: 'Name of the project (without namespace).' + description: 'Name of the project (without namespace).' field :name_with_namespace, GraphQL::Types::String, null: false, - description: 'Full name of the project with its namespace.' + description: 'Full name of the project with its namespace.' field :description, GraphQL::Types::String, null: true, - description: 'Short description of the project.' + description: 'Short description of the project.' field :tag_list, GraphQL::Types::String, null: true, - deprecated: { reason: 'Use `topics`', milestone: '13.12' }, - description: 'List of project topics (not Git tags).', method: :topic_list + deprecated: { reason: 'Use `topics`', milestone: '13.12' }, + description: 'List of project topics (not Git tags).', method: :topic_list field :topics, [GraphQL::Types::String], null: true, - description: 'List of project topics.', method: :topic_list + description: 'List of project topics.', method: :topic_list field :http_url_to_repo, GraphQL::Types::String, null: true, - description: 'URL to connect to the project via HTTPS.' + description: 'URL to connect to the project via HTTPS.' field :ssh_url_to_repo, GraphQL::Types::String, null: true, - description: 'URL to connect to the project via SSH.' + description: 'URL to connect to the project via SSH.' field :web_url, GraphQL::Types::String, null: true, - description: 'Web URL of the project.' + description: 'Web URL of the project.' field :forks_count, GraphQL::Types::Int, null: false, calls_gitaly: true, # 4 times - description: 'Number of times the project has been forked.' + description: 'Number of times the project has been forked.' field :star_count, GraphQL::Types::Int, null: false, - description: 'Number of times the project has been starred.' + description: 'Number of times the project has been starred.' field :created_at, Types::TimeType, null: true, - description: 'Timestamp of the project creation.' + description: 'Timestamp of the project creation.' field :last_activity_at, Types::TimeType, null: true, - description: 'Timestamp of the project last activity.' + description: 'Timestamp of the project last activity.' field :archived, GraphQL::Types::Boolean, null: true, - description: 'Indicates the archived status of the project.' + description: 'Indicates the archived status of the project.' field :visibility, GraphQL::Types::String, null: true, - description: 'Visibility of the project.' + description: 'Visibility of the project.' field :lfs_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the project has Large File Storage (LFS) enabled.' + description: 'Indicates if the project has Large File Storage (LFS) enabled.' field :merge_requests_ff_only_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' + description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' field :shared_runners_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if shared runners are enabled for the project.' + description: 'Indicates if shared runners are enabled for the project.' field :service_desk_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the project has Service Desk enabled.' + description: 'Indicates if the project has Service Desk enabled.' field :service_desk_address, GraphQL::Types::String, null: true, - description: 'E-mail address of the Service Desk.' + description: 'E-mail address of the Service Desk.' field :avatar_url, GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'URL to avatar image file of the project.' + description: 'URL to avatar image file of the project.' field :jobs_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.' + description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.' field :public_jobs, GraphQL::Types::Boolean, method: :public_builds, null: true, - description: 'Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts.' + description: 'Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts.' field :open_issues_count, GraphQL::Types::Int, null: true, - description: 'Number of open issues for the project.' + description: 'Number of open issues for the project.' field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, null: true, - description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of the project can also be merged with skipped jobs.' + description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of the project can also be merged with skipped jobs.' field :autoclose_referenced_issues, GraphQL::Types::Boolean, null: true, - description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically.' + description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically.' field :import_status, GraphQL::Types::String, null: true, - description: 'Status of import background job of the project.' + description: 'Status of import background job of the project.' field :jira_import_status, GraphQL::Types::String, null: true, - description: 'Status of Jira import background job of the project.' + description: 'Status of Jira import background job of the project.' field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::Types::Boolean, null: true, - description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.' + description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.' field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean, null: true, - description: 'Indicates if merge requests of the project can only be merged with successful jobs.' + description: 'Indicates if merge requests of the project can only be merged with successful jobs.' field :printing_merge_request_link_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line.' + description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line.' field :remove_source_branch_after_merge, GraphQL::Types::Boolean, null: true, - description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project.' + description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project.' field :request_access_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if users can request member access to the project.' + description: 'Indicates if users can request member access to the project.' field :squash_read_only, GraphQL::Types::Boolean, null: false, method: :squash_readonly?, - description: 'Indicates if `squashReadOnly` is enabled.' + description: 'Indicates if `squashReadOnly` is enabled.' field :suggestion_commit_message, GraphQL::Types::String, null: true, - description: 'Commit message used to apply merge request suggestions.' + description: 'Commit message used to apply merge request suggestions.' # No, the quotes are not a typo. Used to get around circular dependencies. # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536#note_871009675 field :group, 'Types::GroupType', null: true, - description: 'Group of the project.' + description: 'Group of the project.' field :namespace, Types::NamespaceType, null: true, - description: 'Namespace of the project.' + description: 'Namespace of the project.' field :statistics, Types::ProjectStatisticsType, null: true, description: 'Statistics of the project.' field :repository, Types::RepositoryType, null: true, - description: 'Git repository of the project.' + description: 'Git repository of the project.' field :merge_requests, Types::MergeRequestType.connection_type, @@ -147,7 +147,7 @@ module Types field :work_items, Types::WorkItemType.connection_type, null: true, - deprecated: { milestone: '15.1', reason: :alpha }, + alpha: { milestone: '15.1' }, description: 'Work items of the project.', extras: [:lookahead], resolver: Resolvers::WorkItemsResolver @@ -160,8 +160,8 @@ module Types resolver: Resolvers::IssueStatusCountsResolver field :milestones, Types::MilestoneType.connection_type, null: true, - description: 'Milestones of the project.', - resolver: Resolvers::ProjectMilestonesResolver + description: 'Milestones of the project.', + resolver: Resolvers::ProjectMilestonesResolver field :project_members, description: 'Members of the project.', @@ -221,7 +221,7 @@ module Types resolver: Resolvers::Ci::ProjectPipelineCountsResolver field :ci_variables, - Types::Ci::VariableType.connection_type, + Types::Ci::ProjectVariableType.connection_type, null: true, description: "List of the project's CI/CD variables.", authorize: :admin_build, @@ -355,7 +355,7 @@ module Types resolver: Resolvers::ContainerRepositoriesResolver field :container_repositories_count, GraphQL::Types::Int, null: false, - description: 'Number of container repositories in the project.' + description: 'Number of container repositories in the project.' field :label, Types::LabelType, @@ -379,23 +379,23 @@ module Types resolver: Resolvers::Terraform::StatesResolver field :pipeline_analytics, Types::Ci::AnalyticsType, null: true, - description: 'Pipeline analytics.', - resolver: Resolvers::ProjectPipelineStatisticsResolver + description: 'Pipeline analytics.', + resolver: Resolvers::ProjectPipelineStatisticsResolver field :ci_template, Types::Ci::TemplateType, null: true, - description: 'Find a single CI/CD template by name.', - resolver: Resolvers::Ci::TemplateResolver + description: 'Find a single CI/CD template by name.', + resolver: Resolvers::Ci::TemplateResolver field :ci_job_token_scope, Types::Ci::JobTokenScopeType, null: true, - description: 'The CI Job Tokens scope of access.', - resolver: Resolvers::Ci::JobTokenScopeResolver + description: 'The CI Job Tokens scope of access.', + resolver: Resolvers::Ci::JobTokenScopeResolver field :timelogs, Types::TimelogType.connection_type, null: true, - description: 'Time logged on issues and merge requests in the project.', - extras: [:lookahead], - complexity: 5, - resolver: ::Resolvers::TimelogResolver + description: 'Time logged on issues and merge requests in the project.', + extras: [:lookahead], + complexity: 5, + resolver: ::Resolvers::TimelogResolver field :agent_configurations, ::Types::Kas::AgentConfigurationType.connection_type, @@ -438,6 +438,20 @@ module Types ' Returns `null` if `work_items` feature flag is disabled.' \ ' This flag is disabled by default, because the feature is experimental and is subject to change without notice.' + field :timelog_categories, + Types::TimeTracking::TimelogCategoryType.connection_type, + null: true, + description: "Timelog categories for the project.", + alpha: { milestone: '15.3' } + + field :fork_targets, Types::NamespaceType.connection_type, + resolver: Resolvers::Projects::ForkTargetsResolver, + description: 'Namespaces in which the current user can fork the project into.' + + def timelog_categories + object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories) + end + def label(title:) BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| LabelsFinder @@ -455,7 +469,7 @@ module Types container_registry: 'Container Registry is' }.each do |feature, name_string| field "#{feature}_enabled", GraphQL::Types::Boolean, null: true, - description: "Indicates if #{name_string} enabled for the current user" + description: "Indicates if #{name_string} enabled for the current user" define_method "#{feature}_enabled" do object.feature_available?(feature, context[:current_user]) diff --git a/app/graphql/types/projects/service_type.rb b/app/graphql/types/projects/service_type.rb index 88b7b95aa57..1416d93d3b4 100644 --- a/app/graphql/types/projects/service_type.rb +++ b/app/graphql/types/projects/service_type.rb @@ -9,11 +9,11 @@ module Types # TODO: Add all the fields that we want to expose for the project services integrations # https://gitlab.com/gitlab-org/gitlab/-/issues/213088 field :type, GraphQL::Types::String, null: true, - description: 'Class name of the service.' + description: 'Class name of the service.' field :service_type, ::Types::Projects::ServiceTypeEnum, null: true, - description: 'Type of the service.' + description: 'Type of the service.' field :active, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the service is active.' + description: 'Indicates if the service is active.' def type enum = ::Types::Projects::ServiceTypeEnum.coerce_result(service_type, context) diff --git a/app/graphql/types/projects/services/jira_project_type.rb b/app/graphql/types/projects/services/jira_project_type.rb index 0ff1b9d8903..1c5b97802e3 100644 --- a/app/graphql/types/projects/services/jira_project_type.rb +++ b/app/graphql/types/projects/services/jira_project_type.rb @@ -8,12 +8,12 @@ module Types graphql_name 'JiraProject' field :key, GraphQL::Types::String, null: false, - description: 'Key of the Jira project.' + description: 'Key of the Jira project.' field :name, GraphQL::Types::String, null: true, - description: 'Name of the Jira project.' + description: 'Name of the Jira project.' field :project_id, GraphQL::Types::Int, null: false, - description: 'ID of the Jira project.', - method: :id + description: 'ID of the Jira project.', + method: :id end # rubocop:enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/projects/topic_type.rb b/app/graphql/types/projects/topic_type.rb index bde6d79ddbf..da7df6df4a2 100644 --- a/app/graphql/types/projects/topic_type.rb +++ b/app/graphql/types/projects/topic_type.rb @@ -7,20 +7,20 @@ module Types graphql_name 'Topic' field :id, GraphQL::Types::ID, null: false, - description: 'ID of the topic.' + description: 'ID of the topic.' field :name, GraphQL::Types::String, null: false, - description: 'Name of the topic.' + description: 'Name of the topic.' field :title, GraphQL::Types::String, null: false, - method: :title_or_name, - description: 'Title of the topic.' + method: :title_or_name, + description: 'Title of the topic.' field :description, GraphQL::Types::String, null: true, - description: 'Description of the topic.' + description: 'Description of the topic.' field :avatar_url, GraphQL::Types::String, null: true, - description: 'URL to avatar image file of the topic.' + description: 'URL to avatar image file of the topic.' markdown_field :description_html, null: true diff --git a/app/graphql/types/prometheus_alert_type.rb b/app/graphql/types/prometheus_alert_type.rb index 789f1d6eb5f..fddb8b73768 100644 --- a/app/graphql/types/prometheus_alert_type.rb +++ b/app/graphql/types/prometheus_alert_type.rb @@ -10,7 +10,7 @@ module Types present_using PrometheusAlertPresenter field :id, GraphQL::Types::ID, null: false, - description: 'ID of the alert condition.' + description: 'ID of the alert condition.' field :humanized_text, GraphQL::Types::String, diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 9207a867639..84355390ea0 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -91,7 +91,7 @@ module Types field :work_item, Types::WorkItemType, null: true, resolver: Resolvers::WorkItemResolver, - deprecated: { milestone: '15.1', reason: :alpha }, + alpha: { milestone: '15.1' }, description: 'Find a work item. Returns `null` if `work_items` feature flag is disabled.' field :merge_request, Types::MergeRequestType, @@ -124,7 +124,7 @@ module Types description: "Find runners visible to the current user." field :ci_variables, - Types::Ci::VariableType.connection_type, + Types::Ci::InstanceVariableType.connection_type, null: true, description: "List of the instance's CI/CD variables." @@ -184,7 +184,7 @@ module Types end def ci_variables - return unless current_user.can_admin_all_resources? + return unless current_user&.can_admin_all_resources? ::Ci::InstanceVariable.all end diff --git a/app/graphql/types/release_asset_link_type.rb b/app/graphql/types/release_asset_link_type.rb index 29738de27e5..e171c683e7d 100644 --- a/app/graphql/types/release_asset_link_type.rb +++ b/app/graphql/types/release_asset_link_type.rb @@ -10,19 +10,21 @@ module Types present_using Releases::LinkPresenter field :external, GraphQL::Types::Boolean, null: true, method: :external?, - description: 'Indicates the link points to an external resource.' + description: 'Indicates the link points to an external resource.' field :id, GraphQL::Types::ID, null: false, - description: 'ID of the link.' - field :link_type, Types::ReleaseAssetLinkTypeEnum, null: true, + description: 'ID of the link.' + field :link_type, + Types::ReleaseAssetLinkTypeEnum, + null: true, description: 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`.' field :name, GraphQL::Types::String, null: true, - description: 'Name of the link.' + description: 'Name of the link.' field :url, GraphQL::Types::String, null: true, - description: 'URL of the link.' + description: 'URL of the link.' field :direct_asset_path, GraphQL::Types::String, null: true, method: :filepath, - description: 'Relative path for the direct asset link.' + description: 'Relative path for the direct asset link.' field :direct_asset_url, GraphQL::Types::String, null: true, - description: 'Direct asset URL of the link.' + description: 'Direct asset URL of the link.' end end diff --git a/app/graphql/types/release_assets_type.rb b/app/graphql/types/release_assets_type.rb index ea6ee0b5fd9..396ba112130 100644 --- a/app/graphql/types/release_assets_type.rb +++ b/app/graphql/types/release_assets_type.rb @@ -12,10 +12,10 @@ module Types present_using ReleasePresenter field :count, GraphQL::Types::Int, null: true, method: :assets_count, - description: 'Number of assets of the release.' + description: 'Number of assets of the release.' field :links, Types::ReleaseAssetLinkType.connection_type, null: true, method: :sorted_links, - description: 'Asset links of the release.' + description: 'Asset links of the release.' field :sources, Types::ReleaseSourceType.connection_type, null: true, - description: 'Sources of the release.' + description: 'Sources of the release.' end end diff --git a/app/graphql/types/release_links_type.rb b/app/graphql/types/release_links_type.rb index b7a1a5a9dbe..6bc767152e8 100644 --- a/app/graphql/types/release_links_type.rb +++ b/app/graphql/types/release_links_type.rb @@ -10,25 +10,35 @@ module Types present_using ReleasePresenter - field :closed_issues_url, GraphQL::Types::String, null: true, + field :closed_issues_url, + GraphQL::Types::String, + null: true, description: 'HTTP URL of the issues page, filtered by this release and `state=closed`.', authorize: :download_code - field :closed_merge_requests_url, GraphQL::Types::String, null: true, + field :closed_merge_requests_url, + GraphQL::Types::String, + null: true, description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`.', authorize: :download_code field :edit_url, GraphQL::Types::String, null: true, - description: "HTTP URL of the release's edit page.", - authorize: :update_release - field :merged_merge_requests_url, GraphQL::Types::String, null: true, + description: "HTTP URL of the release's edit page.", + authorize: :update_release + field :merged_merge_requests_url, + GraphQL::Types::String, + null: true, description: 'HTTP URL of the merge request page , filtered by this release and `state=merged`.', authorize: :download_code - field :opened_issues_url, GraphQL::Types::String, null: true, + field :opened_issues_url, + GraphQL::Types::String, + null: true, description: 'HTTP URL of the issues page, filtered by this release and `state=open`.', authorize: :download_code - field :opened_merge_requests_url, GraphQL::Types::String, null: true, + field :opened_merge_requests_url, + GraphQL::Types::String, + null: true, description: 'HTTP URL of the merge request page, filtered by this release and `state=open`.', authorize: :download_code field :self_url, GraphQL::Types::String, null: true, - description: 'HTTP URL of the release.' + description: 'HTTP URL of the release.' end end diff --git a/app/graphql/types/release_source_type.rb b/app/graphql/types/release_source_type.rb index fd29a69d72a..e05a2926ac1 100644 --- a/app/graphql/types/release_source_type.rb +++ b/app/graphql/types/release_source_type.rb @@ -8,8 +8,8 @@ module Types authorize :download_code field :format, GraphQL::Types::String, null: true, - description: 'Format of the source.' + description: 'Format of the source.' field :url, GraphQL::Types::String, null: true, - description: 'Download URL of the source.' + description: 'Download URL of the source.' end end diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb index d906c577aa5..d70fe05c906 100644 --- a/app/graphql/types/release_type.rb +++ b/app/graphql/types/release_type.rb @@ -17,38 +17,40 @@ module Types null: false, description: 'Global ID of the release.' field :assets, Types::ReleaseAssetsType, null: true, method: :itself, - description: 'Assets of the release.' + description: 'Assets of the release.' field :created_at, Types::TimeType, null: true, - description: 'Timestamp of when the release was created.' - field :description, GraphQL::Types::String, null: true, + description: 'Timestamp of when the release was created.' + field :description, + GraphQL::Types::String, + null: true, description: 'Description (also known as "release notes") of the release.' field :evidences, Types::EvidenceType.connection_type, null: true, - description: 'Evidence for the release.' + description: 'Evidence for the release.' field :links, Types::ReleaseLinksType, null: true, method: :itself, - description: 'Links of the release.' + description: 'Links of the release.' field :milestones, Types::MilestoneType.connection_type, null: true, - description: 'Milestones associated to the release.', - resolver: ::Resolvers::ReleaseMilestonesResolver + description: 'Milestones associated to the release.', + resolver: ::Resolvers::ReleaseMilestonesResolver field :name, GraphQL::Types::String, null: true, - description: 'Name of the release.' + description: 'Name of the release.' field :released_at, Types::TimeType, null: true, - description: 'Timestamp of when the release was released.' + description: 'Timestamp of when the release was released.' field :tag_name, GraphQL::Types::String, null: true, method: :tag, - description: 'Name of the tag associated with the release.' + description: 'Name of the tag associated with the release.' field :tag_path, GraphQL::Types::String, null: true, - description: 'Relative web path to the tag associated with the release.', - authorize: :download_code + description: 'Relative web path to the tag associated with the release.', + authorize: :download_code field :upcoming_release, GraphQL::Types::Boolean, null: true, method: :upcoming_release?, - description: 'Indicates the release is an upcoming release.' + description: 'Indicates the release is an upcoming release.' field :historical_release, GraphQL::Types::Boolean, null: true, method: :historical_release?, - description: 'Indicates the release is an historical release.' + description: 'Indicates the release is an historical release.' field :author, Types::UserType, null: true, - description: 'User that created the release.' + description: 'User that created the release.' field :commit, Types::CommitType, null: true, - complexity: 10, calls_gitaly: true, - description: 'Commit associated with the release.' + complexity: 10, calls_gitaly: true, + description: 'Commit associated with the release.' markdown_field :description_html, null: true diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb index dd5c70887de..8c90a8df611 100644 --- a/app/graphql/types/repository/blob_type.rb +++ b/app/graphql/types/repository/blob_type.rb @@ -9,108 +9,108 @@ module Types present_using BlobPresenter field :id, GraphQL::Types::ID, null: false, - description: 'ID of the blob.' + description: 'ID of the blob.' field :oid, GraphQL::Types::String, null: false, method: :id, - description: 'OID of the blob.' + description: 'OID of the blob.' field :path, GraphQL::Types::String, null: false, - description: 'Path of the blob.' + description: 'Path of the blob.' field :name, GraphQL::Types::String, description: 'Blob name.', null: true field :mode, type: GraphQL::Types::String, - description: 'Blob mode.', - null: true + description: 'Blob mode.', + null: true field :lfs_oid, GraphQL::Types::String, null: true, - calls_gitaly: true, - description: 'LFS OID of the blob.' + calls_gitaly: true, + description: 'LFS OID of the blob.' field :web_path, GraphQL::Types::String, null: true, - description: 'Web path of the blob.' + description: 'Web path of the blob.' field :ide_edit_path, GraphQL::Types::String, null: true, - description: 'Web path to edit this blob in the Web IDE.' + description: 'Web path to edit this blob in the Web IDE.' field :fork_and_edit_path, GraphQL::Types::String, null: true, - description: 'Web path to edit this blob using a forked project.' + description: 'Web path to edit this blob using a forked project.' field :ide_fork_and_edit_path, GraphQL::Types::String, null: true, - description: 'Web path to edit this blob in the Web IDE using a forked project.' + description: 'Web path to edit this blob in the Web IDE using a forked project.' field :fork_and_view_path, GraphQL::Types::String, null: true, - description: 'Web path to view this blob using a forked project.' + description: 'Web path to view this blob using a forked project.' field :size, GraphQL::Types::Int, null: true, - description: 'Size (in bytes) of the blob.' + description: 'Size (in bytes) of the blob.' field :raw_size, GraphQL::Types::Int, null: true, - description: 'Size (in bytes) of the blob, or the blob target if stored externally.' + description: 'Size (in bytes) of the blob, or the blob target if stored externally.' field :raw_blob, GraphQL::Types::String, null: true, method: :data, - description: 'Raw content of the blob.' + description: 'Raw content of the blob.' field :raw_text_blob, GraphQL::Types::String, null: true, method: :text_only_data, - description: 'Raw content of the blob, if the blob is text data.' + description: 'Raw content of the blob, if the blob is text data.' field :stored_externally, GraphQL::Types::Boolean, null: true, method: :stored_externally?, - description: "Whether the blob's content is stored externally (for instance, in LFS)." + description: "Whether the blob's content is stored externally (for instance, in LFS)." field :external_storage, GraphQL::Types::String, null: true, method: :external_storage, - description: "External storage being used, if enabled (for instance, 'LFS')." + description: "External storage being used, if enabled (for instance, 'LFS')." field :edit_blob_path, GraphQL::Types::String, null: true, - description: 'Web path to edit the blob in the old-style editor.' + description: 'Web path to edit the blob in the old-style editor.' field :raw_path, GraphQL::Types::String, null: true, - description: 'Web path to download the raw blob.' + description: 'Web path to download the raw blob.' field :external_storage_url, GraphQL::Types::String, null: true, - description: 'Web path to download the raw blob via external storage, if enabled.' + description: 'Web path to download the raw blob via external storage, if enabled.' field :replace_path, GraphQL::Types::String, null: true, - description: 'Web path to replace the blob content.' + description: 'Web path to replace the blob content.' field :pipeline_editor_path, GraphQL::Types::String, null: true, - description: 'Web path to edit .gitlab-ci.yml file.' + description: 'Web path to edit .gitlab-ci.yml file.' field :gitpod_blob_url, GraphQL::Types::String, null: true, - description: 'URL to the blob within Gitpod.' + description: 'URL to the blob within Gitpod.' field :find_file_path, GraphQL::Types::String, null: true, - description: 'Web path to find file.' + description: 'Web path to find file.' field :blame_path, GraphQL::Types::String, null: true, - description: 'Web path to blob blame page.' + description: 'Web path to blob blame page.' field :history_path, GraphQL::Types::String, null: true, - description: 'Web path to blob history page.' + description: 'Web path to blob history page.' field :permalink_path, GraphQL::Types::String, null: true, - description: 'Web path to blob permalink.', - calls_gitaly: true + description: 'Web path to blob permalink.', + calls_gitaly: true field :environment_formatted_external_url, GraphQL::Types::String, null: true, - description: 'Environment on which the blob is available.', - calls_gitaly: true + description: 'Environment on which the blob is available.', + calls_gitaly: true field :environment_external_url_for_route_map, GraphQL::Types::String, null: true, - description: 'Web path to blob on an environment.', - calls_gitaly: true + description: 'Web path to blob on an environment.', + calls_gitaly: true field :file_type, GraphQL::Types::String, null: true, - description: 'Expected format of the blob based on the extension.' + description: 'Expected format of the blob based on the extension.' field :simple_viewer, type: Types::BlobViewerType, - description: 'Blob content simple viewer.', - null: false + description: 'Blob content simple viewer.', + null: false field :rich_viewer, type: Types::BlobViewerType, - description: 'Blob content rich viewer.', - null: true + description: 'Blob content rich viewer.', + null: true field :plain_data, GraphQL::Types::String, description: 'Blob plain highlighted data.', @@ -118,14 +118,14 @@ module Types calls_gitaly: true field :can_modify_blob, GraphQL::Types::Boolean, null: true, method: :can_modify_blob?, - calls_gitaly: true, - description: 'Whether the current user can modify the blob.' + calls_gitaly: true, + description: 'Whether the current user can modify the blob.' field :can_current_user_push_to_branch, GraphQL::Types::Boolean, null: true, method: :can_current_user_push_to_branch?, - description: 'Whether the current user can push to the branch.' + description: 'Whether the current user can push to the branch.' field :archived, GraphQL::Types::Boolean, null: true, method: :archived?, - description: 'Whether the current project is archived.' + description: 'Whether the current project is archived.' field :language, GraphQL::Types::String, description: 'Blob language.', @@ -134,10 +134,10 @@ module Types calls_gitaly: true field :code_navigation_path, GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'Web path for code navigation.' + description: 'Web path for code navigation.' field :project_blob_path_root, GraphQL::Types::String, null: true, - description: 'Web path for the root of the blob.' + description: 'Web path for the root of the blob.' def raw_text_blob object.data unless object.binary? diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb index aa02f0058da..ba94f59ab6c 100644 --- a/app/graphql/types/repository_type.rb +++ b/app/graphql/types/repository_type.rb @@ -7,24 +7,24 @@ module Types authorize :download_code field :blobs, Types::Repository::BlobType.connection_type, null: true, resolver: Resolvers::BlobsResolver, calls_gitaly: true, - description: 'Blobs contained within the repository' + description: 'Blobs contained within the repository' field :branch_names, [GraphQL::Types::String], null: true, calls_gitaly: true, - complexity: 170, description: 'Names of branches available in this repository that match the search pattern.', - resolver: Resolvers::RepositoryBranchNamesResolver + complexity: 170, description: 'Names of branches available in this repository that match the search pattern.', + resolver: Resolvers::RepositoryBranchNamesResolver field :disk_path, GraphQL::Types::String, description: 'Shows a disk path of the repository.', null: true, authorize: :read_storage_disk_path field :empty, GraphQL::Types::Boolean, null: false, method: :empty?, calls_gitaly: true, - description: 'Indicates repository has no visible content.' + description: 'Indicates repository has no visible content.' field :exists, GraphQL::Types::Boolean, null: false, method: :exists?, calls_gitaly: true, - description: 'Indicates a corresponding Git repository exists on disk.' + description: 'Indicates a corresponding Git repository exists on disk.' field :paginated_tree, Types::Tree::TreeType.connection_type, null: true, resolver: Resolvers::PaginatedTreeResolver, calls_gitaly: true, - max_page_size: 100, - description: 'Paginated tree of the repository.' + max_page_size: 100, + description: 'Paginated tree of the repository.' field :root_ref, GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'Default branch of the repository.' + description: 'Default branch of the repository.' field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true, - description: 'Tree of the repository.' + description: 'Tree of the repository.' end end diff --git a/app/graphql/types/resolvable_interface.rb b/app/graphql/types/resolvable_interface.rb index 42784aa5e00..2869d2cfd0f 100644 --- a/app/graphql/types/resolvable_interface.rb +++ b/app/graphql/types/resolvable_interface.rb @@ -17,12 +17,12 @@ module Types end field :resolved, GraphQL::Types::Boolean, null: false, - description: 'Indicates if the object is resolved.', - method: :resolved? + description: 'Indicates if the object is resolved.', + method: :resolved? field :resolvable, GraphQL::Types::Boolean, null: false, - description: 'Indicates if the object can be resolved.', - method: :resolvable? + description: 'Indicates if the object can be resolved.', + method: :resolvable? field :resolved_at, Types::TimeType, null: true, - description: 'Timestamp of when the object was resolved.' + description: 'Timestamp of when the object was resolved.' end end diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb index 7b96cc34941..5ee0500b1e0 100644 --- a/app/graphql/types/snippet_type.rb +++ b/app/graphql/types/snippet_type.rb @@ -54,28 +54,28 @@ module Types null: false field :web_url, type: GraphQL::Types::String, - description: 'Web URL of the snippet.', - null: false + description: 'Web URL of the snippet.', + null: false field :raw_url, type: GraphQL::Types::String, - description: 'Raw URL of the snippet.', - null: false + description: 'Raw URL of the snippet.', + null: false field :blobs, type: Types::Snippets::BlobType.connection_type, - description: 'Snippet blobs.', - calls_gitaly: true, - null: true, - resolver: Resolvers::Snippets::BlobsResolver + description: 'Snippet blobs.', + calls_gitaly: true, + null: true, + resolver: Resolvers::Snippets::BlobsResolver field :ssh_url_to_repo, type: GraphQL::Types::String, - description: 'SSH URL to the snippet repository.', - calls_gitaly: true, - null: true + description: 'SSH URL to the snippet repository.', + calls_gitaly: true, + null: true field :http_url_to_repo, type: GraphQL::Types::String, - description: 'HTTP URL to the snippet repository.', - calls_gitaly: true, - null: true + description: 'HTTP URL to the snippet repository.', + calls_gitaly: true, + null: true markdown_field :description_html, null: true, method: :description diff --git a/app/graphql/types/snippets/blob_connection_type.rb b/app/graphql/types/snippets/blob_connection_type.rb index 15d26af7374..476a6f04b4a 100644 --- a/app/graphql/types/snippets/blob_connection_type.rb +++ b/app/graphql/types/snippets/blob_connection_type.rb @@ -4,7 +4,9 @@ module Types module Snippets # rubocop: disable Graphql/AuthorizeTypes class BlobConnectionType < GraphQL::Types::Relay::BaseConnection - field :has_unretrievable_blobs, GraphQL::Types::Boolean, null: false, + field :has_unretrievable_blobs, + GraphQL::Types::Boolean, + null: false, description: 'Indicates if the snippet has unretrievable blobs.', resolver_method: :unretrievable_blobs? diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb index 80702c71f63..bb4a0a64de8 100644 --- a/app/graphql/types/snippets/blob_type.rb +++ b/app/graphql/types/snippets/blob_type.rb @@ -44,25 +44,25 @@ module Types null: true field :simple_viewer, type: Types::Snippets::BlobViewerType, - description: 'Blob content simple viewer.', - null: false + description: 'Blob content simple viewer.', + null: false field :rich_viewer, type: Types::Snippets::BlobViewerType, - description: 'Blob content rich viewer.', - null: true + description: 'Blob content rich viewer.', + null: true field :mode, type: GraphQL::Types::String, - description: 'Blob mode.', - null: true + description: 'Blob mode.', + null: true field :external_storage, type: GraphQL::Types::String, - description: 'Blob external storage.', - null: true + description: 'Blob external storage.', + null: true field :rendered_as_text, type: GraphQL::Types::Boolean, - description: 'Shows whether the blob is rendered as text.', - method: :rendered_as_text?, - null: false + description: 'Shows whether the blob is rendered as text.', + method: :rendered_as_text?, + null: false end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index de3f71090f6..9b5f028a857 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -5,15 +5,18 @@ module Types graphql_name 'Subscription' field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the assignees of an issuable are updated.' + description: 'Triggered when the assignees of an issuable are updated.' field :issue_crm_contacts_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the crm contacts of an issuable are updated.' + description: 'Triggered when the crm contacts of an issuable are updated.' field :issuable_title_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the title of an issuable is updated.' + description: 'Triggered when the title of an issuable is updated.' field :issuable_labels_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the labels of an issuable are updated.' + description: 'Triggered when the labels of an issuable are updated.' + + field :issuable_dates_updated, subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the due date or start date of an issuable is updated.' end end diff --git a/app/graphql/types/task_completion_status.rb b/app/graphql/types/task_completion_status.rb index 9a979b04d37..c7da2f2cf01 100644 --- a/app/graphql/types/task_completion_status.rb +++ b/app/graphql/types/task_completion_status.rb @@ -9,9 +9,9 @@ module Types description 'Completion status of tasks' field :completed_count, GraphQL::Types::Int, null: false, - description: 'Number of completed tasks.' + description: 'Number of completed tasks.' field :count, GraphQL::Types::Int, null: false, - description: 'Number of total tasks.' + description: 'Number of total tasks.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/time_tracking/timelog_category_type.rb b/app/graphql/types/time_tracking/timelog_category_type.rb new file mode 100644 index 00000000000..c73a6fbd43b --- /dev/null +++ b/app/graphql/types/time_tracking/timelog_category_type.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Types + module TimeTracking + class TimelogCategoryType < BaseObject + graphql_name 'TimeTrackingTimelogCategory' + + authorize :read_timelog_category + + field :id, + GraphQL::Types::ID, + null: false, + description: 'Internal ID of the timelog category.' + + field :name, + GraphQL::Types::String, + null: false, + description: 'Name of the category.' + + field :description, + GraphQL::Types::String, + null: true, + description: 'Description of the category.' + + field :color, + Types::ColorType, + null: true, + description: 'Color assigned to the category.' + + field :billable, + GraphQL::Types::Boolean, + null: true, + description: 'Whether the category is billable or not.' + + field :billing_rate, + GraphQL::Types::Float, + null: true, + description: 'Billing rate for the category.' + + field :created_at, + Types::TimeType, + null: false, + description: 'When the category was created.' + + field :updated_at, + Types::TimeType, + null: false, + description: 'When the category was last updated.' + end + end +end diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb index 284542e1d2a..3db64d812c5 100644 --- a/app/graphql/types/tree/blob_type.rb +++ b/app/graphql/types/tree/blob_type.rb @@ -10,14 +10,14 @@ module Types present_using BlobPresenter field :lfs_oid, GraphQL::Types::String, null: true, - calls_gitaly: true, - description: 'LFS ID of the blob.' + calls_gitaly: true, + description: 'LFS ID of the blob.' field :mode, GraphQL::Types::String, null: true, - description: 'Blob mode in numeric format.' + description: 'Blob mode in numeric format.' field :web_path, GraphQL::Types::String, null: true, - description: 'Web path of the blob.' + description: 'Web path of the blob.' field :web_url, GraphQL::Types::String, null: true, - description: 'Web URL of the blob.' + description: 'Web URL of the blob.' def lfs_oid Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(object.repository, object.id).find diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb index 1c612f91a5b..4b4119dcab9 100644 --- a/app/graphql/types/tree/entry_type.rb +++ b/app/graphql/types/tree/entry_type.rb @@ -5,17 +5,17 @@ module Types include Types::BaseInterface field :id, GraphQL::Types::ID, null: false, - description: 'ID of the entry.' + description: 'ID of the entry.' field :sha, GraphQL::Types::String, null: false, - description: 'Last commit SHA for the entry.', method: :id + description: 'Last commit SHA for the entry.', method: :id field :name, GraphQL::Types::String, null: false, - description: 'Name of the entry.' + description: 'Name of the entry.' field :type, Tree::TypeEnum, null: false, - description: 'Type of tree entry.' + description: 'Type of tree entry.' field :path, GraphQL::Types::String, null: false, - description: 'Path of the entry.' + description: 'Path of the entry.' field :flat_path, GraphQL::Types::String, null: false, - description: 'Flat path of the entry.' + description: 'Flat path of the entry.' end end end diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb index 8f462011f0f..57597d9884c 100644 --- a/app/graphql/types/tree/submodule_type.rb +++ b/app/graphql/types/tree/submodule_type.rb @@ -9,9 +9,9 @@ module Types implements Types::Tree::EntryType field :tree_url, type: GraphQL::Types::String, null: true, - description: 'Tree URL for the sub-module.' + description: 'Tree URL for the sub-module.' field :web_url, type: GraphQL::Types::String, null: true, - description: 'Web URL for the sub-module.' + description: 'Web URL for the sub-module.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb index 28024fd010b..1de78250812 100644 --- a/app/graphql/types/tree/tree_entry_type.rb +++ b/app/graphql/types/tree/tree_entry_type.rb @@ -11,9 +11,9 @@ module Types present_using TreeEntryPresenter field :web_path, GraphQL::Types::String, null: true, - description: 'Web path for the tree entry (directory).' + description: 'Web path for the tree entry (directory).' field :web_url, GraphQL::Types::String, null: true, - description: 'Web URL for the tree entry (directory).' + description: 'Web URL for the tree entry (directory).' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb index 011cff0c89c..51dc8cdb7bb 100644 --- a/app/graphql/types/tree/tree_type.rb +++ b/app/graphql/types/tree/tree_type.rb @@ -12,15 +12,15 @@ module Types description: 'Last commit for the tree.' field :trees, Types::Tree::TreeEntryType.connection_type, null: false, - description: 'Trees of the tree.' + description: 'Trees of the tree.' field :submodules, Types::Tree::SubmoduleType.connection_type, null: false, - description: 'Sub-modules of the tree.', - calls_gitaly: true + description: 'Sub-modules of the tree.', + calls_gitaly: true field :blobs, Types::Tree::BlobType.connection_type, null: false, - description: 'Blobs of the tree.', - calls_gitaly: true + description: 'Blobs of the tree.', + calls_gitaly: true def trees Gitlab::Graphql::Representation::TreeEntry.decorate(object.trees, object.repository) diff --git a/app/graphql/types/upload_type.rb b/app/graphql/types/upload_type.rb new file mode 100644 index 00000000000..0bb7f68cdba --- /dev/null +++ b/app/graphql/types/upload_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + class UploadType < BaseObject + graphql_name 'FileUpload' + + authorize :read_upload + + field :id, Types::GlobalIDType[::Upload], + null: false, + description: 'Global ID of the upload.' + field :path, GraphQL::Types::String, + null: false, + description: 'Path of the upload.' + field :size, GraphQL::Types::Int, + null: false, + description: 'Size of the upload in bytes.' + end +end diff --git a/app/graphql/types/user_callout_type.rb b/app/graphql/types/user_callout_type.rb index 526027322ef..f509900e91d 100644 --- a/app/graphql/types/user_callout_type.rb +++ b/app/graphql/types/user_callout_type.rb @@ -5,8 +5,8 @@ module Types graphql_name 'UserCallout' field :dismissed_at, Types::TimeType, null: true, - description: 'Date when the callout was dismissed.' + description: 'Date when the callout was dismissed.' field :feature_name, UserCalloutFeatureNameEnum, null: true, - description: 'Name of the feature that the callout is for.' + description: 'Name of the feature that the callout is for.' end end diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index edbc8aee9c5..f49b3eee4f5 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -122,13 +122,15 @@ module Types 'Will not return saved replies if `saved_replies` feature flag is disabled.' field :gitpod_enabled, GraphQL::Types::Boolean, null: true, - description: 'Whether Gitpod is enabled at the user level.' + description: 'Whether Gitpod is enabled at the user level.' - field :preferences_gitpod_path, GraphQL::Types::String, null: true, + field :preferences_gitpod_path, + GraphQL::Types::String, + null: true, description: 'Web path to the Gitpod section within user preferences.' field :profile_enable_gitpod_path, GraphQL::Types::String, null: true, - description: 'Web path to enable Gitpod for the user.' + description: 'Web path to enable Gitpod for the user.' definition_methods do def resolve_type(object, context) diff --git a/app/graphql/types/user_status_type.rb b/app/graphql/types/user_status_type.rb index 68c00bffe48..199c7d31083 100644 --- a/app/graphql/types/user_status_type.rb +++ b/app/graphql/types/user_status_type.rb @@ -6,12 +6,12 @@ module Types graphql_name 'UserStatus' markdown_field :message_html, null: true, - description: 'HTML of the user status message' + description: 'HTML of the user status message' field :availability, Types::AvailabilityEnum, null: false, - description: 'User availability status.' + description: 'User availability status.' field :emoji, GraphQL::Types::String, null: true, - description: 'String representation of emoji.' + description: 'String representation of emoji.' field :message, GraphQL::Types::String, null: true, - description: 'User status message.' + description: 'User status message.' end end diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb index 18b9bfd1c9a..7904841863b 100644 --- a/app/graphql/types/work_item_type.rb +++ b/app/graphql/types/work_item_type.rb @@ -6,22 +6,37 @@ module Types authorize :read_work_item + field :closed_at, Types::TimeType, null: true, + description: 'Timestamp of when the work item was closed.' + field :confidential, GraphQL::Types::Boolean, null: false, + description: 'Indicates the work item is confidential.' + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of when the work item was created.' field :description, GraphQL::Types::String, null: true, - description: 'Description of the work item.' + description: 'Description of the work item.' field :id, Types::GlobalIDType[::WorkItem], null: false, - description: 'Global ID of the work item.' + description: 'Global ID of the work item.' field :iid, GraphQL::Types::ID, null: false, - description: 'Internal ID of the work item.' - field :lock_version, GraphQL::Types::Int, null: false, + description: 'Internal ID of the work item.' + field :lock_version, + GraphQL::Types::Int, + null: false, description: 'Lock version of the work item. Incremented each time the work item is updated.' + field :project, Types::ProjectType, null: false, + description: 'Project the work item belongs to.', + alpha: { milestone: '15.3' } field :state, WorkItemStateEnum, null: false, - description: 'State of the work item.' + description: 'State of the work item.' field :title, GraphQL::Types::String, null: false, - description: 'Title of the work item.' - field :widgets, [Types::WorkItems::WidgetInterface], null: true, + description: 'Title of the work item.' + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp of when the work item was last updated.' + field :widgets, + [Types::WorkItems::WidgetInterface], + null: true, description: 'Collection of widgets that belong to the work item.' field :work_item_type, Types::WorkItems::TypeType, null: false, - description: 'Type assigned to the work item.' + description: 'Type assigned to the work item.' markdown_field :title_html, null: true markdown_field :description_html, null: true diff --git a/app/graphql/types/work_items/type_type.rb b/app/graphql/types/work_items/type_type.rb index f31bd7ee9ba..4d008a21b9c 100644 --- a/app/graphql/types/work_items/type_type.rb +++ b/app/graphql/types/work_items/type_type.rb @@ -8,11 +8,11 @@ module Types authorize :read_work_item_type field :icon_name, GraphQL::Types::String, null: true, - description: 'Icon name of the work item type.' + description: 'Icon name of the work item type.' field :id, Types::GlobalIDType[::WorkItems::Type], null: false, - description: 'Global ID of the work item type.' + description: 'Global ID of the work item type.' field :name, GraphQL::Types::String, null: false, - description: 'Name of the work item type.' + description: 'Name of the work item type.' end end end diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb index 1b752393296..eca8c8d845a 100644 --- a/app/graphql/types/work_items/widget_interface.rb +++ b/app/graphql/types/work_items/widget_interface.rb @@ -7,8 +7,21 @@ module Types graphql_name 'WorkItemWidget' - field :type, ::Types::WorkItems::WidgetTypeEnum, null: true, - description: 'Widget type.' + field :type, ::Types::WorkItems::WidgetTypeEnum, + null: true, + description: 'Widget type.' + + ORPHAN_TYPES = [ + ::Types::WorkItems::Widgets::DescriptionType, + ::Types::WorkItems::Widgets::HierarchyType, + ::Types::WorkItems::Widgets::LabelsType, + ::Types::WorkItems::Widgets::AssigneesType, + ::Types::WorkItems::Widgets::StartAndDueDateType + ].freeze + + def self.ce_orphan_types + ORPHAN_TYPES + end def self.resolve_type(object, context) case object @@ -18,17 +31,18 @@ module Types ::Types::WorkItems::Widgets::HierarchyType when ::WorkItems::Widgets::Assignees ::Types::WorkItems::Widgets::AssigneesType - when ::WorkItems::Widgets::Weight - ::Types::WorkItems::Widgets::WeightType + when ::WorkItems::Widgets::Labels + ::Types::WorkItems::Widgets::LabelsType + when ::WorkItems::Widgets::StartAndDueDate + ::Types::WorkItems::Widgets::StartAndDueDateType else raise "Unknown GraphQL type for widget #{object}" end end - orphan_types ::Types::WorkItems::Widgets::DescriptionType, - ::Types::WorkItems::Widgets::HierarchyType, - ::Types::WorkItems::Widgets::AssigneesType, - ::Types::WorkItems::Widgets::WeightType + orphan_types(*ORPHAN_TYPES) end end end + +Types::WorkItems::WidgetInterface.prepend_mod diff --git a/app/graphql/types/work_items/widgets/assignees_input_type.rb b/app/graphql/types/work_items/widgets/assignees_input_type.rb new file mode 100644 index 00000000000..ee61bc73054 --- /dev/null +++ b/app/graphql/types/work_items/widgets/assignees_input_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + class AssigneesInputType < BaseInputObject + graphql_name 'WorkItemWidgetAssigneesInput' + + argument :assignee_ids, [::Types::GlobalIDType[::User]], + required: true, + description: 'Global IDs of assignees.', + prepare: ->(ids, _) { ids.map(&:model_id) } + end + end + end +end diff --git a/app/graphql/types/work_items/widgets/assignees_type.rb b/app/graphql/types/work_items/widgets/assignees_type.rb index 08ee06fdfa0..74da3264567 100644 --- a/app/graphql/types/work_items/widgets/assignees_type.rb +++ b/app/graphql/types/work_items/widgets/assignees_type.rb @@ -12,14 +12,17 @@ module Types implements Types::WorkItems::WidgetInterface - field :assignees, Types::UserType.connection_type, null: true, - description: 'Assignees of the work item.' + field :assignees, Types::UserType.connection_type, + null: true, + description: 'Assignees of the work item.' - field :allows_multiple_assignees, GraphQL::Types::Boolean, null: true, method: :allows_multiple_assignees?, - description: 'Indicates whether multiple assignees are allowed.' + field :allows_multiple_assignees, GraphQL::Types::Boolean, + null: true, method: :allows_multiple_assignees?, + description: 'Indicates whether multiple assignees are allowed.' - field :can_invite_members, GraphQL::Types::Boolean, null: false, resolver_method: :can_invite_members?, - description: 'Indicates whether the current user can invite members to the work item\'s project.' + field :can_invite_members, GraphQL::Types::Boolean, + null: false, resolver_method: :can_invite_members?, + description: 'Indicates whether the current user can invite members to the work item\'s project.' def can_invite_members? Ability.allowed?(current_user, :admin_project_member, object.work_item.project) diff --git a/app/graphql/types/work_items/widgets/description_type.rb b/app/graphql/types/work_items/widgets/description_type.rb index 79192d7c3d4..4c365a67bfd 100644 --- a/app/graphql/types/work_items/widgets/description_type.rb +++ b/app/graphql/types/work_items/widgets/description_type.rb @@ -12,8 +12,9 @@ module Types implements Types::WorkItems::WidgetInterface - field :description, GraphQL::Types::String, null: true, - description: 'Description of the work item.' + field :description, GraphQL::Types::String, + null: true, + description: 'Description of the work item.' markdown_field :description_html, null: true do |resolved_object| resolved_object.work_item diff --git a/app/graphql/types/work_items/widgets/hierarchy_type.rb b/app/graphql/types/work_items/widgets/hierarchy_type.rb index 057d5fbf056..0ccd8af7dc8 100644 --- a/app/graphql/types/work_items/widgets/hierarchy_type.rb +++ b/app/graphql/types/work_items/widgets/hierarchy_type.rb @@ -12,13 +12,13 @@ module Types implements Types::WorkItems::WidgetInterface - field :parent, ::Types::WorkItemType, null: true, - description: 'Parent work item.', - complexity: 5 + field :parent, ::Types::WorkItemType, + null: true, complexity: 5, + description: 'Parent work item.' - field :children, ::Types::WorkItemType.connection_type, null: true, - description: 'Child work items.', - complexity: 5 + field :children, ::Types::WorkItemType.connection_type, + null: true, complexity: 5, + description: 'Child work items.' def children object.children.inc_relations_for_permission_check diff --git a/app/graphql/types/work_items/widgets/labels_type.rb b/app/graphql/types/work_items/widgets/labels_type.rb new file mode 100644 index 00000000000..20574b3e3bc --- /dev/null +++ b/app/graphql/types/work_items/widgets/labels_type.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + # Disabling widget level authorization as it might be too granular + # and we already authorize the parent work item + # rubocop:disable Graphql/AuthorizeTypes + class LabelsType < BaseObject + graphql_name 'WorkItemWidgetLabels' + description 'Represents the labels widget' + + implements Types::WorkItems::WidgetInterface + + field :labels, Types::LabelType.connection_type, + null: true, + description: 'Labels assigned to the work item.' + + field :allows_scoped_labels, GraphQL::Types::Boolean, + null: true, + method: :allows_scoped_labels?, + description: 'Indicates whether a scoped label is allowed.' + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/work_items/widgets/start_and_due_date_type.rb b/app/graphql/types/work_items/widgets/start_and_due_date_type.rb new file mode 100644 index 00000000000..d4dbc969937 --- /dev/null +++ b/app/graphql/types/work_items/widgets/start_and_due_date_type.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + # Disabling widget level authorization as it might be too granular + # and we already authorize the parent work item + # rubocop:disable Graphql/AuthorizeTypes + class StartAndDueDateType < BaseObject + graphql_name 'WorkItemWidgetStartAndDueDate' + description 'Represents a start and due date widget' + + implements Types::WorkItems::WidgetInterface + + field :due_date, Types::DateType, + null: true, + description: 'Due date of the work item.' + field :start_date, Types::DateType, + null: true, + description: 'Start date of the work item.' + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/work_items/widgets/start_and_due_date_update_input_type.rb b/app/graphql/types/work_items/widgets/start_and_due_date_update_input_type.rb new file mode 100644 index 00000000000..bccd4afe8f3 --- /dev/null +++ b/app/graphql/types/work_items/widgets/start_and_due_date_update_input_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + class StartAndDueDateUpdateInputType < BaseInputObject + graphql_name 'WorkItemWidgetStartAndDueDateUpdateInput' + + argument :due_date, Types::DateType, + required: false, + description: 'Due date for the work item.' + argument :start_date, Types::DateType, + required: false, + description: 'Start date for the work item.' + end + end + end +end diff --git a/app/graphql/types/work_items/widgets/weight_input_type.rb b/app/graphql/types/work_items/widgets/weight_input_type.rb deleted file mode 100644 index a01c63222a5..00000000000 --- a/app/graphql/types/work_items/widgets/weight_input_type.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Types - module WorkItems - module Widgets - class WeightInputType < BaseInputObject - graphql_name 'WorkItemWidgetWeightInput' - - argument :weight, GraphQL::Types::Int, - required: true, - description: 'Weight of the work item.' - end - end - end -end diff --git a/app/graphql/types/work_items/widgets/weight_type.rb b/app/graphql/types/work_items/widgets/weight_type.rb deleted file mode 100644 index c8eaf560268..00000000000 --- a/app/graphql/types/work_items/widgets/weight_type.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Types - module WorkItems - module Widgets - # Disabling widget level authorization as it might be too granular - # and we already authorize the parent work item - # rubocop:disable Graphql/AuthorizeTypes - class WeightType < BaseObject - graphql_name 'WorkItemWidgetWeight' - description 'Represents a weight widget' - - implements Types::WorkItems::WidgetInterface - - field :weight, GraphQL::Types::Int, null: true, - description: 'Weight of the work item.' - end - # rubocop:enable Graphql/AuthorizeTypes - end - end -end diff --git a/app/helpers/admin/identities_helper.rb b/app/helpers/admin/identities_helper.rb new file mode 100644 index 00000000000..48e01840394 --- /dev/null +++ b/app/helpers/admin/identities_helper.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Admin + module IdentitiesHelper + def label_for_identity_provider(identity) + provider = identity.provider + "#{Gitlab::Auth::OAuth::Provider.label_for(provider)} (#{provider})" + end + + def provider_id_cell_testid(identity) + 'provider_id_blank' + end + + def provider_id(identity) + '-' + end + + def saml_group_cell_testid(identity) + 'saml_group_blank' + end + + def saml_group_link(identity) + '-' + end + + def identity_cells_to_render?(identities, _user) + identities.present? + end + + def scim_identities_collection(_user) + [] + end + end +end + +Admin::IdentitiesHelper.prepend_mod diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d2cc50be509..a75c1b16145 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -19,23 +19,23 @@ module ApplicationHelper def dispensable_render(...) render(...) - rescue StandardError => error + rescue StandardError => e if Feature.enabled?(:dispensable_render) - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) nil else - raise error + raise e end end def dispensable_render_if_exists(...) render_if_exists(...) - rescue StandardError => error + rescue StandardError => e if Feature.enabled?(:dispensable_render) - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) nil else - raise error + raise e end end @@ -223,6 +223,16 @@ module ApplicationHelper ApplicationHelper.promo_host end + # This needs to be used outside of Rails + def self.community_forum + 'https://forum.gitlab.com' + end + + # Convenient method for Rails helper + def community_forum + ApplicationHelper.community_forum + end + def promo_url 'https://' + promo_host end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 9dc93779b12..617bc0e9bee 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -84,9 +84,9 @@ module AvatarsHelper end image_options = { - alt: alt_text, - src: avatar_url, - data: data_attributes, + alt: alt_text, + src: avatar_url, + data: data_attributes, class: css_class, title: user_name } diff --git a/app/helpers/badges_helper.rb b/app/helpers/badges_helper.rb index 26ebe8a6470..d48eae26a90 100644 --- a/app/helpers/badges_helper.rb +++ b/app/helpers/badges_helper.rb @@ -8,13 +8,13 @@ module BadgesHelper success: "badge-success", warning: "badge-warning", danger: "badge-danger" - }.tap { |hash| hash.default = hash.fetch(:muted) } .freeze + }.tap { |hash| hash.default = hash.fetch(:muted) }.freeze SIZE_CLASSES = { sm: "sm", md: "md", lg: "lg" - }.tap { |hash| hash.default = hash.fetch(:md) } .freeze + }.tap { |hash| hash.default = hash.fetch(:md) }.freeze GL_BADGE_CLASSES = %w[gl-badge badge badge-pill].freeze @@ -53,7 +53,7 @@ module BadgesHelper # # See also https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-badge--default. def gl_badge_tag(*args, &block) - if block_given? + if block build_gl_badge_tag(capture(&block), *args) else build_gl_badge_tag(*args) diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index fcf6a177984..2c84da4862a 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -98,9 +98,9 @@ module BlobHelper ref, path, blob: blob, - label: _("Replace"), - action: "replace", - btn_class: "default", + label: _("Replace"), + action: "replace", + btn_class: "default", modal_type: "upload" ) end @@ -111,9 +111,9 @@ module BlobHelper ref, path, blob: blob, - label: _("Delete"), - action: "delete", - btn_class: "default", + label: _("Delete"), + action: "delete", + btn_class: "default", modal_type: "remove" ) end @@ -298,7 +298,9 @@ module BlobHelper def readable_blob(options, path, project, ref) blob = options.fetch(:blob) do - project.repository.blob_at(ref, path) rescue nil + project.repository.blob_at(ref, path) + rescue StandardError + nil end blob if blob&.readable_text? diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb index d044a93213a..d00301678dd 100644 --- a/app/helpers/ci/pipeline_editor_helper.rb +++ b/app/helpers/ci/pipeline_editor_helper.rb @@ -32,8 +32,7 @@ module Ci "project-path" => project.path, "project-full-path" => project.full_path, "project-namespace" => project.namespace.full_path, - "runner-help-page-path" => help_page_path('ci/runners/index'), - "simulate-pipeline-help-page-path" => help_page_path('ci/lint', anchor: 'simulate-a-pipeline'), + "simulate-pipeline-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'simulate-a-cicd-pipeline'), "total-branches" => total_branches, "validate-tab-illustration-path" => image_path('illustrations/project-run-CICD-pipelines-sm.svg'), "yml-help-page-path" => help_page_path('ci/yaml/index') diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index 7722677e503..a67771116b9 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -40,14 +40,14 @@ module Ci { name: 'Crystal', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/crystal.svg') }, { name: 'Dart', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/dart.svg') }, { name: 'Django', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/django.svg') }, - { name: 'Docker', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/docker.svg') }, + { name: 'Docker', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/docker.png') }, { name: 'Elixir', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/elixir.svg') }, { name: 'iOS-Fastlane', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/fastlane.svg'), title: 'iOS with Fastlane' }, { name: 'Flutter', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/flutter.svg') }, { name: 'Go', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/go_logo.svg') }, { name: 'Gradle', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/gradle.svg') }, { name: 'Grails', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/grails.svg') }, - { name: 'dotNET', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/dotnet.svg') }, + { name: 'dotNET', logo: image_path('illustrations/third-party-logos/dotnet.svg') }, { name: 'Julia', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/julia.svg') }, { name: 'Laravel', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/laravel.svg') }, { name: 'LaTeX', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/latex.svg') }, diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 74318797069..852eaeca5e3 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -73,7 +73,7 @@ module Ci def group_shared_runners_settings_data(group) { - update_path: api_v4_groups_path(id: group.id), + group_id: group.id, shared_runners_setting: group.shared_runners_setting, parent_shared_runners_setting: group.parent&.shared_runners_setting, runner_enabled_value: Namespace::SR_ENABLED, diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 33b771eef69..1920650bc93 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -28,7 +28,7 @@ module CommitsHelper def commit_to_html(commit, ref, project) render partial: 'projects/commits/commit', formats: :html, - locals: { + locals: { commit: commit, ref: ref, project: project @@ -137,7 +137,12 @@ module CommitsHelper def conditionally_paginate_diff_files(diffs, paginate:, page:, per:) if paginate - Kaminari.paginate_array(diffs.diff_files.to_a).page(page).per(per) + diff_files = diffs.diff_files.to_a + Gitlab::Utils::BatchLoader.clear_key([:repository_blobs, diffs.project.repository]) + + Kaminari.paginate_array(diff_files).page(page).per(per).tap do |diff_files| + diff_files.each(&:add_blobs_to_batch_loader) + end else diffs.diff_files end diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb index f9d62747308..e955ad4cfda 100644 --- a/app/helpers/compare_helper.rb +++ b/app/helpers/compare_helper.rb @@ -1,25 +1,30 @@ # frozen_string_literal: true module CompareHelper - def create_mr_button?(from: params[:from], to: params[:to], source_project: @project, target_project: @target_project) + def create_mr_button?(source_project:, from:, to: nil, target_project: nil) + target_project ||= source_project.default_merge_request_target + to ||= target_project.default_branch + from.present? && to.present? && from != to && can?(current_user, :create_merge_request_from, source_project) && can?(current_user, :create_merge_request_in, target_project) && - target_project.repository.branch_exists?(from) && - source_project.repository.branch_exists?(to) + target_project.repository.branch_exists?(to) && + source_project.repository.branch_exists?(from) end - def create_mr_path(from: params[:from], to: params[:to], source_project: @project, target_project: @target_project) + def create_mr_path(from:, source_project:, to: nil, target_project: nil, mr_params: {}) + merge_request_params = { + source_branch: from + } + + merge_request_params[:target_project_id] = target_project.id if target_project + merge_request_params[:target_branch] = to if to + project_new_merge_request_path( - target_project, - merge_request: { - source_project_id: source_project.id, - source_branch: to, - target_project_id: target_project.id, - target_branch: from - } + source_project, + merge_request: merge_request_params.merge(mr_params) ) end @@ -32,14 +37,32 @@ module CompareHelper def project_compare_selector_data(project, merge_request, params) { project_compare_index_path: project_compare_index_path(project), - refs_project_path: refs_project_path(project), + source_project: { id: project.id, name: project.full_path }.to_json, + target_project: { id: @target_project.id, name: @target_project.full_path }.to_json, + source_project_refs_path: refs_project_path(project), + target_project_refs_path: refs_project_path(@target_project), params_from: params[:from], - params_to: params[:to], - project_merge_request_path: merge_request.present? ? project_merge_request_path(project, merge_request) : '', - create_mr_path: create_mr_button? ? create_mr_path : '' + params_to: params[:to] }.tap do |data| - data[:project_to] = { id: project.id, name: project.full_path }.to_json - data[:projects_from] = target_projects(project).map { |project| { id: project.id, name: project.full_path } }.to_json + data[:projects_from] = target_projects(project).map do |target_project| + { id: target_project.id, name: target_project.full_path } + end.to_json + + data[:project_merge_request_path] = + if merge_request.present? + project_merge_request_path(project, merge_request) + else + '' + end + + # The `from` and `to` params are inverted in the compare page. The route is `/compare/:from...:to`, but the UI + # correctly shows `:to` as the "Source" (i.e. the `from` for MR), and `:from` as "Target" (i.e. the `to` for MR). + data[:create_mr_path] = + if create_mr_button?(from: params[:to], to: params[:from], source_project: project, target_project: @target_project) + create_mr_path(from: params[:to], to: params[:from], source_project: project, target_project: @target_project) + else + '' + end end end end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index bcb1f63840d..f0e1f252917 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -15,10 +15,6 @@ module DashboardHelper merge_requests_dashboard_path(reviewer_username: current_user.username) end - def attention_requested_mrs_dashboard_path - merge_requests_dashboard_path(attention: current_user.username) - end - def dashboard_nav_links @dashboard_nav_links ||= get_dashboard_nav_links end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 2623e32dbc8..333237db6a4 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -58,17 +58,17 @@ module EnvironmentsHelper return {} unless project { - 'settings_path' => edit_project_settings_integration_path(project, 'prometheus'), - 'clusters_path' => project_clusters_path(project), - 'dashboards_endpoint' => project_performance_monitoring_dashboards_path(project, format: :json), - 'default_branch' => project.default_branch, - 'project_path' => project_path(project), - 'tags_path' => project_tags_path(project), - 'external_dashboard_url' => project.metrics_setting_external_dashboard_url, - 'custom_metrics_path' => project_prometheus_metrics_path(project), - 'validate_query_path' => validate_query_project_prometheus_metrics_path(project), - 'custom_metrics_available' => "#{custom_metrics_available?(project)}", - 'dashboard_timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase + 'settings_path' => edit_project_settings_integration_path(project, 'prometheus'), + 'clusters_path' => project_clusters_path(project), + 'dashboards_endpoint' => project_performance_monitoring_dashboards_path(project, format: :json), + 'default_branch' => project.default_branch, + 'project_path' => project_path(project), + 'tags_path' => project_tags_path(project), + 'external_dashboard_url' => project.metrics_setting_external_dashboard_url, + 'custom_metrics_path' => project_prometheus_metrics_path(project), + 'validate_query_path' => validate_query_project_prometheus_metrics_path(project), + 'custom_metrics_available' => "#{custom_metrics_available?(project)}", + 'dashboard_timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase } end @@ -77,9 +77,9 @@ module EnvironmentsHelper { 'metrics_dashboard_base_path' => metrics_dashboard_base_path(environment, project), - 'current_environment_name' => environment.name, - 'has_metrics' => "#{environment.has_metrics?}", - 'environment_state' => "#{environment.state}" + 'current_environment_name' => environment.name, + 'has_metrics' => "#{environment.has_metrics?}", + 'environment_state' => "#{environment.state}" } end @@ -98,8 +98,8 @@ module EnvironmentsHelper return {} unless project && environment { - 'metrics_endpoint' => additional_metrics_project_environment_path(project, environment, format: :json), - 'dashboard_endpoint' => metrics_dashboard_project_environment_path(project, environment, format: :json), + 'metrics_endpoint' => additional_metrics_project_environment_path(project, environment, format: :json), + 'dashboard_endpoint' => metrics_dashboard_project_environment_path(project, environment, format: :json), 'deployments_endpoint' => project_environment_deployments_path(project, environment, format: :json), 'operations_settings_path' => project_settings_operations_path(project), 'can_access_operations_settings' => can?(current_user, :admin_operations, project).to_s, @@ -109,14 +109,14 @@ module EnvironmentsHelper def static_metrics_data { - 'documentation_path' => help_page_path('administration/monitoring/prometheus/index.md'), + 'documentation_path' => help_page_path('administration/monitoring/prometheus/index.md'), 'add_dashboard_documentation_path' => help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'), - 'empty_getting_started_svg_path' => image_path('illustrations/monitoring/getting_started.svg'), - 'empty_loading_svg_path' => image_path('illustrations/monitoring/loading.svg'), - 'empty_no_data_svg_path' => image_path('illustrations/monitoring/no_data.svg'), - 'empty_no_data_small_svg_path' => image_path('illustrations/chart-empty-state-small.svg'), + 'empty_getting_started_svg_path' => image_path('illustrations/monitoring/getting_started.svg'), + 'empty_loading_svg_path' => image_path('illustrations/monitoring/loading.svg'), + 'empty_no_data_svg_path' => image_path('illustrations/monitoring/no_data.svg'), + 'empty_no_data_small_svg_path' => image_path('illustrations/chart-empty-state-small.svg'), 'empty_unable_to_connect_svg_path' => image_path('illustrations/monitoring/unable_to_connect.svg'), - 'custom_dashboard_base_path' => Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT + 'custom_dashboard_base_path' => Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT } end end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 4ee3acd32d2..b35dc3b00cb 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -38,7 +38,7 @@ module EventsHelper active = 'active' if @event_filter.active?(key) link_opts = { class: "event-filter-link", - id: "#{key}_event_filter", + id: "#{key}_event_filter", title: tooltip } diff --git a/app/helpers/favicon_helper.rb b/app/helpers/favicon_helper.rb index 4a809731d97..c98c7c4909a 100644 --- a/app/helpers/favicon_helper.rb +++ b/app/helpers/favicon_helper.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true module FaviconHelper - def favicon_extension_whitelist - FaviconUploader::EXTENSION_WHITELIST - .map { |extension| "'.#{extension}'"} + def favicon_extension_allowlist + FaviconUploader::EXTENSION_ALLOWLIST + .map { |extension| "'.#{extension}'" } .to_sentence end end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 17812aed3ff..f74eeeb8c6a 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module FormHelper - def form_errors(model, type: 'form', truncate: [], pajamas_alert: false) + def form_errors(model, type: 'form', truncate: [], pajamas_alert: true) errors = model.errors return unless errors.any? @@ -25,26 +25,27 @@ module FormHelper tag.li(message) end.join.html_safe - if pajamas_alert - render Pajamas::AlertComponent.new( - variant: :danger, - title: headline, - dismissible: false, - alert_options: { id: 'error_explanation', class: 'gl-mb-5' } - ) do |c| - c.body do - tag.ul(class: 'gl-pl-5 gl-mb-0') do - messages - end + render Pajamas::AlertComponent.new( + variant: :danger, + title: headline, + dismissible: false, + alert_options: { id: 'error_explanation', class: 'gl-mb-5' } + ) do |c| + c.body do + tag.ul(class: 'gl-pl-5 gl-mb-0') do + messages end end + end + end + + def dropdown_max_select(data) + return data[:'max-select'] unless Feature.enabled?(:limit_reviewer_and_assignee_size) + + if data[:'max-select'] && data[:'max-select'] < MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + data[:'max-select'] else - tag.div(class: 'alert alert-danger', id: 'error_explanation') do - tag.h4(headline) << - tag.ul do - messages - end - end + MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS end end @@ -165,7 +166,12 @@ module FormHelper new_options[:title] = _('Select reviewer(s)') new_options[:data][:'dropdown-header'] = _('Reviewer(s)') - new_options[:data].delete(:'max-select') + + if Feature.enabled?(:limit_reviewer_and_assignee_size) + new_options[:data][:'max-select'] = MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + else + new_options[:data].delete(:'max-select') + end new_options end diff --git a/app/helpers/gitlab_script_tag_helper.rb b/app/helpers/gitlab_script_tag_helper.rb index f784bb69dd8..55653c592e5 100644 --- a/app/helpers/gitlab_script_tag_helper.rb +++ b/app/helpers/gitlab_script_tag_helper.rb @@ -7,7 +7,9 @@ module GitlabScriptTagHelper # The helper also makes sure the `nonce` attribute is included in every script when the content security # policy is enabled. def javascript_include_tag(*sources) - super(*sources, defer: true, nonce: true) + options = { defer: true }.merge(sources.extract_options!) + options[:nonce] = true + super(*sources, **options) end # The helper makes sure the `nonce` attribute is included in every script when the content security diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index 2021961772a..6a013a6c864 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -21,10 +21,11 @@ module Groups::GroupMembersHelper end def group_member_header_subtext(group) - html_escape(_('You can invite a new member to ' \ - '%{strong_start}%{group_name}%{strong_end}.')) % { group_name: group.name, - strong_start: '<strong>'.html_safe, - strong_end: '</strong>'.html_safe } + html_escape(_("You're viewing members of %{strong_start}%{group_name}%{strong_end}.").html_safe) % { + group_name: group.name, + strong_start: '<strong>'.html_safe, + strong_end: '</strong>'.html_safe + } end private diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 9d152416b2e..bb92792de2d 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -134,6 +134,13 @@ module GroupsHelper @group_projects_sort || @sort || params[:sort] || sort_value_recently_created end + def subgroup_creation_data(group) + { + parent_group_name: group.parent&.name, + import_existing_group_path: new_group_path(parent_id: group.parent_id, anchor: 'import-group-pane') + } + end + def verification_for_group_creation_data # overridden in EE {} @@ -144,11 +151,9 @@ module GroupsHelper false end - def group_name_and_path_app_data(group) - parent = group.parent - + def group_name_and_path_app_data { - base_path: URI.join(root_url, parent&.full_path || "").to_s, + base_path: root_url, mattermost_enabled: Gitlab.config.mattermost.enabled.to_s } end @@ -156,7 +161,7 @@ module GroupsHelper def subgroups_and_projects_list_app_data(group) { show_schema_markup: 'true', - new_subgroup_path: new_group_path(parent_id: group.id), + new_subgroup_path: new_group_path(parent_id: group.id, anchor: 'create-group-pane'), new_project_path: new_project_path(namespace_id: group.id), new_subgroup_illustration: image_path('illustrations/subgroup-create-new-sm.svg'), new_project_illustration: image_path('illustrations/project-create-new-sm.svg'), @@ -182,7 +187,7 @@ module GroupsHelper def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false) link_to(group_path(group), class: "group-path #{'breadcrumb-item-text' unless for_dropdown} js-breadcrumb-item-text #{'hidable' if hidable}") do - icon = group_icon(group, class: "avatar-tile", width: 15, height: 15) if (group.try(:avatar_url) || show_avatar) && !Rails.env.test? + icon = group_icon(group, alt: group.name, class: "avatar-tile", width: 15, height: 15) if group.try(:avatar_url) || show_avatar [icon, simple_sanitize(group.name)].join.html_safe end end diff --git a/app/helpers/instance_configuration_helper.rb b/app/helpers/instance_configuration_helper.rb index b06e3ff2904..7eb14d31dc7 100644 --- a/app/helpers/instance_configuration_helper.rb +++ b/app/helpers/instance_configuration_helper.rb @@ -4,7 +4,7 @@ module InstanceConfigurationHelper def instance_configuration_cell_html(value, &block) return '-' unless value.to_s.presence - block_given? ? yield(value) : value + block ? yield(value) : value end def instance_configuration_host(host) diff --git a/app/helpers/issuables_description_templates_helper.rb b/app/helpers/issuables_description_templates_helper.rb index a82a5ac0fb0..58b86dca1e0 100644 --- a/app/helpers/issuables_description_templates_helper.rb +++ b/app/helpers/issuables_description_templates_helper.rb @@ -5,8 +5,12 @@ module IssuablesDescriptionTemplatesHelper include GitlabRoutingHelper def template_dropdown_tag(issuable, &block) - selected_template = selected_template(issuable) - title = selected_template || _('Choose a template') + template_names = template_names(issuable) + + selected_template = selected_template_name(template_names) + default_template = default_template_name(template_names, issuable) + title = _('Choose a template') + options = { toggle_class: 'js-issuable-selector', title: title, @@ -17,6 +21,7 @@ module IssuablesDescriptionTemplatesHelper data: issuable_templates(ref_project, issuable.to_ability_name), field_name: 'issuable_template', selected: selected_template, + default: default_template, project_id: ref_project.id } } @@ -32,19 +37,19 @@ module IssuablesDescriptionTemplatesHelper @template_types[project.id][issuable_type] ||= TemplateFinder.all_template_names(project, issuable_type.pluralize) end - def selected_template(issuable) - all_templates = issuable_templates(ref_project, issuable.to_ability_name) - - # Only local templates will be listed if licenses for inherited templates are not present - all_templates = all_templates.values.flatten.map { |tpl| tpl[:name] }.compact.uniq + def selected_template_name(template_names) + template_names.find { |tmpl_name| tmpl_name == params[:issuable_template] } + end - template = all_templates.find { |tmpl_name| tmpl_name == params[:issuable_template] } + def default_template_name(template_names, issuable) + return if issuable.description.present? || issuable.persisted? - unless issuable.description.present? - template ||= all_templates.find { |tmpl_name| tmpl_name.casecmp?('default') } - end + template_names.find { |tmpl_name| tmpl_name.casecmp?('default') } + end - template + def template_names(issuable) + # Only local templates will be listed if licenses for inherited templates are not present + issuable_templates(ref_project, issuable.to_ability_name).values.flatten.map { |tpl| tpl[:name] }.compact.uniq end def available_service_desk_templates_for(project) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 486d5bb3866..8fd004233e2 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -147,14 +147,20 @@ module IssuablesHelper end def issuable_meta_author_status(author) - return "" unless show_status_emoji?(author&.status) && status = user_status(author) + return "" unless author&.status&.customized? && status = user_status(author) "#{status}".html_safe end def issuable_meta(issuable, project) output = [] - output << "Created #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe + + if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.work_item_type.base_type) + output << content_tag(:span, sprite_icon("#{issuable.work_item_type.icon_name}", css_class: 'gl-icon gl-vertical-align-middle'), class: 'gl-mr-2', aria: { hidden: 'true' }) + output << s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) } + else + output << s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) } + end if issuable.is_a?(Issue) && issuable.service_desk_reply_to output << "#{html_escape(issuable.service_desk_reply_to)} via " diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 877785c9eaf..2d0bc1bc63f 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -39,7 +39,7 @@ module LabelsHelper def link_to_label(label, type: :issue, tooltip: true, small: false, css_class: nil, &block) link = label.filter_path(type: type) - if block_given? + if block link_to link, class: css_class, &block else render_label(label, link: link, tooltip: tooltip, small: small) diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 6077a059f6f..fc558958ca3 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -266,9 +266,10 @@ module MarkupHelper def markdown_toolbar_button(options = {}) data = options[:data].merge({ container: 'body' }) + css_classes = %w[gl-button btn btn-default-tertiary btn-icon js-md has-tooltip] << options[:css_class].to_s content_tag :button, type: 'button', - class: 'gl-button btn btn-default-tertiary btn-icon js-md has-tooltip', + class: css_classes.join(' '), data: data, title: options[:title], aria: { label: options[:title] } do @@ -282,8 +283,8 @@ module MarkupHelper def asciidoc_unsafe(text, context = {}) context.reverse_merge!( - commit: @commit, - ref: @ref, + commit: @commit, + ref: @ref, requested_path: @path ) Gitlab::Asciidoc.render(text, context) @@ -323,9 +324,9 @@ module MarkupHelper current_user: (current_user if defined?(current_user)), # RepositoryLinkFilter and UploadLinkFilter - commit: @commit, - wiki: @wiki, - ref: @ref, + commit: @commit, + wiki: @wiki, + ref: @ref, requested_path: @path ) diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 4b1cbd3f1ae..f1f5f941edd 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -83,6 +83,24 @@ module MembersHelper params: pagination[:params] || {} } end + + def member_request_access_link(member) + user = member.user + member_source = member.source + + member_link = link_to user.name, user, class: :highlight + member_role = content_tag :span, member.human_access, class: :highlight + target_source_link = link_to member_source.human_name, polymorphic_url([member_source, :members]), class: :highlight + target_type = member_source.model_name.singular + + s_('Notify|%{member_link} requested %{member_role} access to the %{target_source_link} %{target_type}.') + .html_safe % { + member_link: member_link, + member_role: member_role, + target_source_link: target_source_link, + target_type: target_type + } + end end MembersHelper.prepend_mod_with('MembersHelper') diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index d840223a066..4581da4a063 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -3,23 +3,12 @@ module MergeRequestsHelper include Gitlab::Utils::StrongMemoize - def new_mr_path_from_push_event(event) - target_project = event.project.default_merge_request_target - project_new_merge_request_path( - event.project, - new_mr_from_push_event(event, target_project) - ) + def create_mr_button_from_event?(event) + create_mr_button?(from: event.branch_name, source_project: event.project) end - def new_mr_from_push_event(event, target_project) - { - merge_request: { - source_project_id: event.project.id, - target_project_id: target_project.id, - source_branch: event.branch_name, - target_branch: target_project.repository.root_ref - } - } + def create_mr_path_from_push_event(event) + create_mr_path(from: event.branch_name, source_project: event.project) end def mr_css_classes(mr) @@ -29,11 +18,31 @@ module MergeRequestsHelper classes.join(' ') end - def merge_path_description(merge_request, separator) + def merge_path_description(merge_request, with_arrow: false) if merge_request.for_fork? - "Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.full_path}:#{@merge_request.target_branch}" + msg = if with_arrow + _("Project:Branches: %{source_project_path}:%{source_branch} → %{target_project_path}:%{target_branch}") + else + _("Project:Branches: %{source_project_path}:%{source_branch} to %{target_project_path}:%{target_branch}") + end + + msg % { + source_project_path: merge_request.source_project_path, + source_branch: merge_request.source_branch, + target_project_path: merge_request.target_project.full_path, + target_branch: merge_request.target_branch + } else - "Branches: #{@merge_request.source_branch} #{separator} #{@merge_request.target_branch}" + msg = if with_arrow + _("Branches: %{source_branch} → %{target_branch}") + else + _("Branches: %{source_branch} to %{target_branch}") + end + + msg % { + source_branch: merge_request.source_branch, + target_branch: merge_request.target_branch + } end end @@ -150,20 +159,11 @@ module MergeRequestsHelper review_requested_count = review_requested_merge_requests_count total_count = assigned_count + review_requested_count - counts = { + { assigned: assigned_count, review_requested: review_requested_count, total: total_count } - - if current_user&.mr_attention_requests_enabled? - attention_requested_count = attention_requested_merge_requests_count - - counts[:attention_requested_count] = attention_requested_count - counts[:total] = attention_requested_count - end - - counts end end @@ -225,10 +225,6 @@ module MergeRequestsHelper current_user.review_requested_open_merge_requests_count end - def attention_requested_merge_requests_count - current_user.attention_requested_open_merge_requests_count - end - def default_suggestion_commit_message @project.suggestion_commit_message.presence || Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE end diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index a50629b7996..60796e628a3 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -81,13 +81,6 @@ module NamespacesHelper group.namespace_settings.public_send(method_name, **args) # rubocop:disable GitlabSecurity/PublicSend end - def namespaces_as_json(selected = :current_user) - { - group: formatted_namespaces(current_user.manageable_groups_with_routes), - user: formatted_namespaces([current_user.namespace]) - }.to_json - end - def pipeline_usage_app_data(namespace) { namespace_actual_plan_name: namespace.actual_plan_name, @@ -129,17 +122,6 @@ module NamespacesHelper [group_label.camelize, elements] end - - def formatted_namespaces(namespaces) - namespaces.sort_by(&:human_name).map! do |n| - { - id: n.id, - display_path: n.full_path, - human_name: n.human_name, - name: n.name - } - end - end end NamespacesHelper.prepend_mod_with('NamespacesHelper') diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb index fb8fafe59f3..dc7d8049556 100644 --- a/app/helpers/nav/new_dropdown_helper.rb +++ b/app/helpers/nav/new_dropdown_helper.rb @@ -42,7 +42,7 @@ module Nav ::Gitlab::Nav::TopNavMenuItem.build( id: 'new_subgroup', title: _('New subgroup'), - href: new_group_path(parent_id: group.id), + href: new_group_path(parent_id: group.id, anchor: 'create-group-pane'), data: { track_action: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' } ) ) diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb index 3ceb60251c2..efec6f2d0d8 100644 --- a/app/helpers/nav/top_nav_helper.rb +++ b/app/helpers/nav/top_nav_helper.rb @@ -81,13 +81,6 @@ module Nav **snippets_menu_item_attrs ) end - - builder.add_secondary_menu_item( - id: 'help', - title: _('Help'), - icon: 'question-o', - href: help_path - ) end def build_view_model(builder:, project:, group:) diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index ec64746d6b6..b52357bc891 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -63,4 +63,27 @@ module PackagesHelper Gitlab.config.packages.enabled && Ability.allowed?(current_user, :admin_package, project) end + + def cleanup_settings_data + { + project_id: @project.id, + project_path: @project.full_path, + cadence_options: cadence_options.to_json, + keep_n_options: keep_n_options.to_json, + older_than_options: older_than_options.to_json, + is_admin: current_user&.admin.to_s, + admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), + enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s, + help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'), + show_cleanup_policy_link: show_cleanup_policy_link(@project).to_s, + tags_regex_help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'regex-pattern-examples') + } + end + + def settings_data + cleanup_settings_data.merge( + show_container_registry_settings: show_container_registry_settings(@project).to_s, + show_package_registry_settings: show_package_registry_settings(@project).to_s + ) + end end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 39a57e786ed..57afe0ed0be 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -26,7 +26,7 @@ module PreferencesHelper def localized_dashboard_choices { projects: _("Your Projects (default)"), - stars: _("Starred Projects"), + stars: _("Starred Projects"), project_activity: _("Your Projects' Activity"), starred_project_activity: _("Starred Projects' Activity"), followed_user_activity: _("Followed Users' Activity"), diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 20d0dd9b30c..104026ff21e 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -31,10 +31,6 @@ module ProfilesHelper Types::AvailabilityEnum.enum end - def user_status_set_to_busy?(status) - status&.availability == availability_values[:busy] - end - def middle_dot_divider_classes(stacking, breakpoint) ['gl-mb-3'].tap do |classes| if stacking diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index 3b3fe13e58a..5f2a9f7bf21 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -14,7 +14,14 @@ module Projects metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json), pipeline_iid: pipeline.iid, pipeline_project_path: project.full_path, - total_job_count: pipeline.total_size + total_job_count: pipeline.total_size, + summary_endpoint: summary_project_pipeline_tests_path(project, pipeline, format: :json), + suite_endpoint: project_pipeline_test_path(project, pipeline, suite_name: 'suite', format: :json), + blob_path: project_blob_path(project, pipeline.sha), + has_test_report: pipeline.has_test_reports?, + empty_state_image_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg'), + artifacts_expired_image_path: image_path('illustrations/pipeline.svg'), + tests_count: pipeline.test_report_summary.total[:count] } end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 2ece3e87500..dfc270adf8b 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -458,6 +458,16 @@ module ProjectsHelper end end + def project_coverage_chart_data_attributes(daily_coverage_options, ref) + { + graph_endpoint: "#{daily_coverage_options[:graph_api_path]}?#{daily_coverage_options[:base_params].to_query}", + graph_start_date: "#{daily_coverage_options[:base_params][:start_date].strftime('%b %d')}", + graph_end_date: "#{daily_coverage_options[:base_params][:end_date].strftime('%b %d')}", + graph_ref: "#{ref}", + graph_csv_path: "#{daily_coverage_options[:download_path]}?#{daily_coverage_options[:base_params].to_query}" + } + end + private def configure_oauth_import_message(provider, help_url) @@ -473,35 +483,35 @@ module ProjectsHelper def tab_ability_map { - cycle_analytics: :read_cycle_analytics, - environments: :read_environment, + cycle_analytics: :read_cycle_analytics, + environments: :read_environment, metrics_dashboards: :metrics_dashboard, - milestones: :read_milestone, - snippets: :read_snippet, - settings: :admin_project, - builds: :read_build, - clusters: :read_cluster, - serverless: :read_cluster, - terraform: :read_terraform_state, - error_tracking: :read_sentry_issue, - alert_management: :read_alert_management_alert, - incidents: :read_issue, - labels: :read_label, - issues: :read_issue, - project_members: :read_project_member, - wiki: :read_wiki, - feature_flags: :read_feature_flag, - analytics: :read_analytics + milestones: :read_milestone, + snippets: :read_snippet, + settings: :admin_project, + builds: :read_build, + clusters: :read_cluster, + serverless: :read_cluster, + terraform: :read_terraform_state, + error_tracking: :read_sentry_issue, + alert_management: :read_alert_management_alert, + incidents: :read_issue, + labels: :read_label, + issues: :read_issue, + project_members: :read_project_member, + wiki: :read_wiki, + feature_flags: :read_feature_flag, + analytics: :read_analytics } end def search_tab_ability_map @search_tab_ability_map ||= tab_ability_map.merge( - blobs: :download_code, - commits: :download_code, + blobs: :download_code, + commits: :download_code, merge_requests: :read_merge_request, - notes: [:read_merge_request, :download_code, :read_issue, :read_snippet], - members: :read_project_member + notes: [:read_merge_request, :download_code, :read_issue, :read_snippet], + members: :read_project_member ) end @@ -629,7 +639,10 @@ module ProjectsHelper warnAboutPotentiallyUnwantedCharacters: project.warn_about_potentially_unwanted_characters?, enforceAuthChecksOnUploads: project.enforce_auth_checks_on_uploads?, securityAndComplianceAccessLevel: project.security_and_compliance_access_level, - containerRegistryAccessLevel: feature.container_registry_access_level + containerRegistryAccessLevel: feature.container_registry_access_level, + environmentsAccessLevel: feature.environments_access_level, + featureFlagsAccessLevel: feature.feature_flags_access_level, + releasesAccessLevel: feature.releases_access_level } end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index ecbcaec27bc..dc53be330fe 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -14,28 +14,42 @@ module SearchHelper :project_ids ].freeze - def search_autocomplete_opts(term) + def search_autocomplete_opts(term, filter: nil) return unless current_user - resources_results = [ - recent_items_autocomplete(term), + results = case filter&.to_sym + when :search + resource_results(term) + when :generic + [ + recent_items_autocomplete(term), + generic_results(term) + ] + else + [ + recent_items_autocomplete(term), + resource_results(term), + generic_results(term) + ] + end + + results.flatten { |item| item[:label] } + end + + def resource_results(term) + [ groups_autocomplete(term), projects_autocomplete(term), issue_autocomplete(term) ].flatten + end + def generic_results(term) search_pattern = Regexp.new(Regexp.escape(term), "i") generic_results = project_autocomplete + default_autocomplete + help_autocomplete generic_results.concat(default_autocomplete_admin) if current_user.admin? - generic_results.select! { |result| result[:label] =~ search_pattern } - - [ - resources_results, - generic_results - ].flatten do |item| - item[:label] - end + generic_results.select { |result| result[:label] =~ search_pattern } end def recent_items_autocomplete(term) diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index ef79e2bc86f..58f0af883f5 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -6,39 +6,39 @@ module SortingHelper # rubocop: disable Metrics/AbcSize def sort_options_hash { - sort_value_created_date => sort_title_created_date, - sort_value_downvotes => sort_title_downvotes, - sort_value_due_date => sort_title_due_date, - sort_value_due_date_later => sort_title_due_date_later, - sort_value_due_date_soon => sort_title_due_date_soon, - sort_value_label_priority => sort_title_label_priority, - sort_value_largest_group => sort_title_largest_group, - sort_value_largest_repo => sort_title_largest_repo, - sort_value_milestone => sort_title_milestone, - sort_value_milestone_later => sort_title_milestone_later, - sort_value_milestone_soon => sort_title_milestone_soon, - sort_value_name => sort_title_name, - sort_value_name_desc => sort_title_name_desc, - sort_value_oldest_created => sort_title_oldest_created, - sort_value_oldest_signin => sort_title_oldest_signin, - sort_value_oldest_updated => sort_title_oldest_updated, - sort_value_recently_created => sort_title_recently_created, - sort_value_recently_signin => sort_title_recently_signin, - sort_value_recently_updated => sort_title_recently_updated, - sort_value_popularity => sort_title_popularity, - sort_value_priority => sort_title_priority, - sort_value_merged_date => sort_title_merged_date, - sort_value_merged_recently => sort_title_merged_recently, - sort_value_merged_earlier => sort_title_merged_earlier, - sort_value_closed_date => sort_title_closed_date, - sort_value_closed_recently => sort_title_closed_recently, - sort_value_closed_earlier => sort_title_closed_earlier, - sort_value_upvotes => sort_title_upvotes, - sort_value_contacted_date => sort_title_contacted_date, + sort_value_created_date => sort_title_created_date, + sort_value_downvotes => sort_title_downvotes, + sort_value_due_date => sort_title_due_date, + sort_value_due_date_later => sort_title_due_date_later, + sort_value_due_date_soon => sort_title_due_date_soon, + sort_value_label_priority => sort_title_label_priority, + sort_value_largest_group => sort_title_largest_group, + sort_value_largest_repo => sort_title_largest_repo, + sort_value_milestone => sort_title_milestone, + sort_value_milestone_later => sort_title_milestone_later, + sort_value_milestone_soon => sort_title_milestone_soon, + sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name_desc, + sort_value_oldest_created => sort_title_oldest_created, + sort_value_oldest_signin => sort_title_oldest_signin, + sort_value_oldest_updated => sort_title_oldest_updated, + sort_value_recently_created => sort_title_recently_created, + sort_value_recently_signin => sort_title_recently_signin, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_popularity => sort_title_popularity, + sort_value_priority => sort_title_priority, + sort_value_merged_date => sort_title_merged_date, + sort_value_merged_recently => sort_title_merged_recently, + sort_value_merged_earlier => sort_title_merged_earlier, + sort_value_closed_date => sort_title_closed_date, + sort_value_closed_recently => sort_title_closed_recently, + sort_value_closed_earlier => sort_title_closed_earlier, + sort_value_upvotes => sort_title_upvotes, + sort_value_contacted_date => sort_title_contacted_date, sort_value_relative_position => sort_title_relative_position, - sort_value_size => sort_title_size, - sort_value_expire_date => sort_title_expire_date, - sort_value_title => sort_title_title + sort_value_size => sort_title_size, + sort_value_expire_date => sort_title_expire_date, + sort_value_title => sort_title_title } end # rubocop: enable Metrics/AbcSize @@ -47,19 +47,19 @@ module SortingHelper use_old_sorting = Feature.disabled?(:project_list_filter_bar) || current_controller?('admin/projects') options = { - sort_value_latest_activity => sort_title_latest_activity, + sort_value_latest_activity => sort_title_latest_activity, sort_value_recently_created => sort_title_created_date, - sort_value_name => sort_title_name, - sort_value_name_desc => sort_title_name_desc, - sort_value_stars_desc => sort_title_stars + sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name_desc, + sort_value_stars_desc => sort_title_stars } if use_old_sorting options = options.merge({ - sort_value_oldest_activity => sort_title_oldest_activity, - sort_value_oldest_created => sort_title_oldest_created, + sort_value_oldest_activity => sort_title_oldest_activity, + sort_value_oldest_created => sort_title_oldest_created, sort_value_recently_created => sort_title_recently_created, - sort_value_stars_desc => sort_title_most_stars + sort_value_stars_desc => sort_title_most_stars }) end @@ -73,52 +73,52 @@ module SortingHelper def forks_sort_options_hash { sort_value_recently_created => sort_title_created_date, - sort_value_oldest_created => sort_title_created_date, - sort_value_latest_activity => sort_title_latest_activity, - sort_value_oldest_activity => sort_title_latest_activity + sort_value_oldest_created => sort_title_created_date, + sort_value_latest_activity => sort_title_latest_activity, + sort_value_oldest_activity => sort_title_latest_activity } end def projects_sort_option_titles # Only used for the project filter search bar projects_sort_options_hash.merge({ - sort_value_oldest_activity => sort_title_latest_activity, - sort_value_oldest_created => sort_title_created_date, - sort_value_name_desc => sort_title_name, - sort_value_stars_asc => sort_title_stars + sort_value_oldest_activity => sort_title_latest_activity, + sort_value_oldest_created => sort_title_created_date, + sort_value_name_desc => sort_title_name, + sort_value_stars_asc => sort_title_stars }) end def projects_reverse_sort_options_hash { - sort_value_latest_activity => sort_value_oldest_activity, + sort_value_latest_activity => sort_value_oldest_activity, sort_value_recently_created => sort_value_oldest_created, - sort_value_name => sort_value_name_desc, - sort_value_stars_desc => sort_value_stars_asc, - sort_value_oldest_activity => sort_value_latest_activity, - sort_value_oldest_created => sort_value_recently_created, - sort_value_name_desc => sort_value_name, - sort_value_stars_asc => sort_value_stars_desc + sort_value_name => sort_value_name_desc, + sort_value_stars_desc => sort_value_stars_asc, + sort_value_oldest_activity => sort_value_latest_activity, + sort_value_oldest_created => sort_value_recently_created, + sort_value_name_desc => sort_value_name, + sort_value_stars_asc => sort_value_stars_desc } end def forks_reverse_sort_options_hash { sort_value_recently_created => sort_value_oldest_created, - sort_value_oldest_created => sort_value_recently_created, - sort_value_latest_activity => sort_value_oldest_activity, - sort_value_oldest_activity => sort_value_latest_activity + sort_value_oldest_created => sort_value_recently_created, + sort_value_latest_activity => sort_value_oldest_activity, + sort_value_oldest_activity => sort_value_latest_activity } end def groups_sort_options_hash { - sort_value_name => sort_title_name, - sort_value_name_desc => sort_title_name_desc, + sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name_desc, sort_value_recently_created => sort_title_recently_created, - sort_value_oldest_created => sort_title_oldest_created, - sort_value_latest_activity => sort_title_recently_updated, - sort_value_oldest_activity => sort_title_oldest_updated + sort_value_oldest_created => sort_title_oldest_created, + sort_value_latest_activity => sort_title_recently_updated, + sort_value_oldest_activity => sort_title_oldest_updated } end @@ -136,27 +136,27 @@ module SortingHelper def milestones_sort_options_hash { - sort_value_due_date_soon => sort_title_due_date_soon, - sort_value_due_date_later => sort_title_due_date_later, - sort_value_start_date_soon => sort_title_start_date_soon, + sort_value_due_date_soon => sort_title_due_date_soon, + sort_value_due_date_later => sort_title_due_date_later, + sort_value_start_date_soon => sort_title_start_date_soon, sort_value_start_date_later => sort_title_start_date_later, - sort_value_name => sort_title_name_asc, - sort_value_name_desc => sort_title_name_desc + sort_value_name => sort_title_name_asc, + sort_value_name_desc => sort_title_name_desc } end def branches_sort_options_hash { - sort_value_name => sort_title_name, - sort_value_oldest_updated => sort_title_oldest_updated, + sort_value_name => sort_title_name, + sort_value_oldest_updated => sort_title_oldest_updated, sort_value_recently_updated => sort_title_recently_updated } end def tags_sort_options_hash { - sort_value_name => sort_title_name, - sort_value_oldest_updated => sort_title_oldest_updated, + sort_value_name => sort_title_name, + sort_value_oldest_updated => sort_title_oldest_updated, sort_value_recently_updated => sort_title_recently_updated } end @@ -240,7 +240,7 @@ module SortingHelper def audit_logs_sort_order_hash { sort_value_recently_created => sort_title_recently_created, - sort_value_oldest_created => sort_title_oldest_created + sort_value_oldest_created => sort_title_oldest_created } end @@ -336,31 +336,31 @@ module SortingHelper def packages_sort_options_hash { - sort_value_recently_created => sort_title_created_date, - sort_value_oldest_created => sort_title_created_date, - sort_value_name => sort_title_name, - sort_value_name_desc => sort_title_name, - sort_value_version_desc => sort_title_version, - sort_value_version_asc => sort_title_version, - sort_value_type_desc => sort_title_type, - sort_value_type_asc => sort_title_type, + sort_value_recently_created => sort_title_created_date, + sort_value_oldest_created => sort_title_created_date, + sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name, + sort_value_version_desc => sort_title_version, + sort_value_version_asc => sort_title_version, + sort_value_type_desc => sort_title_type, + sort_value_type_asc => sort_title_type, sort_value_project_name_desc => sort_title_project_name, - sort_value_project_name_asc => sort_title_project_name + sort_value_project_name_asc => sort_title_project_name } end def packages_reverse_sort_order_hash { - sort_value_recently_created => sort_value_oldest_created, - sort_value_oldest_created => sort_value_recently_created, - sort_value_name => sort_value_name_desc, - sort_value_name_desc => sort_value_name, - sort_value_version_desc => sort_value_version_asc, - sort_value_version_asc => sort_value_version_desc, - sort_value_type_desc => sort_value_type_asc, - sort_value_type_asc => sort_value_type_desc, + sort_value_recently_created => sort_value_oldest_created, + sort_value_oldest_created => sort_value_recently_created, + sort_value_name => sort_value_name_desc, + sort_value_name_desc => sort_value_name, + sort_value_version_desc => sort_value_version_asc, + sort_value_version_asc => sort_value_version_desc, + sort_value_type_desc => sort_value_type_asc, + sort_value_type_asc => sort_value_type_desc, sort_value_project_name_desc => sort_value_project_name_asc, - sort_value_project_name_asc => sort_value_project_name_desc + sort_value_project_name_asc => sort_value_project_name_desc } end diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index ca81d5af4af..9e516d726c1 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -24,29 +24,89 @@ module StorageHelper _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters end - def storage_enforcement_banner_info(namespace) - root_ancestor = namespace.root_ancestor + def storage_enforcement_banner_info(context) + root_ancestor = context.root_ancestor - return unless can?(current_user, :maintain_namespace, root_ancestor) - return if root_ancestor.paid? - return unless future_enforcement_date?(root_ancestor) - return if user_dismissed_storage_enforcement_banner?(root_ancestor) - return unless ::Feature.enabled?(:namespace_storage_limit_show_preenforcement_banner, root_ancestor) + return unless should_show_storage_enforcement_banner?(context, current_user, root_ancestor) + + text_args = storage_enforcement_banner_text_args(root_ancestor, context) + + text_paragraph_2 = if root_ancestor.user_namespace? + html_escape_once(s_("UsageQuota|The namespace is currently using %{strong_start}%{used_storage}%{strong_end} of namespace storage. " \ + "View and manage your usage from %{strong_start}User settings > Usage quotas%{strong_end}. %{docs_link_start}Learn more%{link_end} " \ + "about how to reduce your storage.")).html_safe % text_args[:p2] + else + html_escape_once(s_("UsageQuota|The namespace is currently using %{strong_start}%{used_storage}%{strong_end} of namespace storage. " \ + "Group owners can view namespace storage usage and purchase more from %{strong_start}Group settings > Usage quotas%{strong_end}. %{docs_link_start}Learn more.%{link_end}" \ + )).html_safe % text_args[:p2] + end { - text: html_escape_once(s_("UsageQuota|From %{storage_enforcement_date} storage limits will apply to this namespace. " \ - "You are currently using %{used_storage} of namespace storage. " \ - "View and manage your usage from %{strong_start}%{namespace_type} settings > Usage quotas%{strong_end}.")).html_safe % - { storage_enforcement_date: root_ancestor.storage_enforcement_date, used_storage: storage_counter(root_ancestor.root_storage_statistics&.storage_size || 0), strong_start: "<strong>".html_safe, strong_end: "</strong>".html_safe, namespace_type: root_ancestor.type }, + text_paragraph_1: html_escape_once(s_("UsageQuota|Effective %{storage_enforcement_date}, namespace storage limits will apply " \ + "to the %{strong_start}%{namespace_name}%{strong_end} namespace. %{extra_message}" \ + "View the %{rollout_link_start}rollout schedule for this change%{link_end}.")).html_safe % text_args[:p1], + text_paragraph_2: text_paragraph_2, + text_paragraph_3: html_escape_once(s_("UsageQuota|See our %{faq_link_start}FAQ%{link_end} for more information.")).html_safe % text_args[:p3], variant: 'warning', + namespace_id: root_ancestor.id, callouts_path: root_ancestor.user_namespace? ? callouts_path : group_callouts_path, - callouts_feature_name: storage_enforcement_banner_user_callouts_feature_name(root_ancestor), - learn_more_link: link_to(_('Learn more.'), help_page_path('/'), rel: 'noopener noreferrer', target: '_blank') + callouts_feature_name: storage_enforcement_banner_user_callouts_feature_name(root_ancestor) } end private + def should_show_storage_enforcement_banner?(context, current_user, root_ancestor) + return false unless user_allowed_storage_enforcement_banner?(context, current_user, root_ancestor) + return false if root_ancestor.paid? + return false unless future_enforcement_date?(root_ancestor) + return false if user_dismissed_storage_enforcement_banner?(root_ancestor) + + ::Feature.enabled?(:namespace_storage_limit_show_preenforcement_banner, root_ancestor) + end + + def user_allowed_storage_enforcement_banner?(context, current_user, root_ancestor) + return can?(current_user, :maintainer_access, context) unless context.respond_to?(:user_namespace?) && context.user_namespace? + + can?(current_user, :owner_access, context) + end + + def storage_enforcement_banner_text_args(root_ancestor, context) + strong_tags = { + strong_start: "<strong>".html_safe, + strong_end: "</strong>".html_safe + } + + extra_message = if context.is_a?(Project) + html_escape_once(s_("UsageQuota|The %{strong_start}%{context_name}%{strong_end} project will be affected by this. ")) + .html_safe % strong_tags.merge(context_name: context.name) + elsif !context.root? + html_escape_once(s_("UsageQuota|The %{strong_start}%{context_name}%{strong_end} group will be affected by this. ")) + .html_safe % strong_tags.merge(context_name: context.name) + else + '' + end + + { + p1: { + storage_enforcement_date: root_ancestor.storage_enforcement_date, + namespace_name: root_ancestor.name, + extra_message: extra_message, + rollout_link_start: '<a href="%{url}" >'.html_safe % { url: help_page_path('user/usage_quotas', anchor: 'namespace-storage-limit-enforcement-schedule') }, + link_end: "</a>".html_safe + }.merge(strong_tags), + p2: { + used_storage: storage_counter(root_ancestor.root_storage_statistics&.storage_size || 0), + docs_link_start: '<a href="%{url}" >'.html_safe % { url: help_page_path('user/usage_quotas', anchor: 'manage-your-storage-usage') }, + link_end: "</a>".html_safe + }.merge(strong_tags), + p3: { + faq_link_start: '<a href="%{url}" >'.html_safe % { url: "#{Gitlab::Saas.about_pricing_url}faq-efficient-free-tier/#storage-limits-on-gitlab-saas-free-tier" }, + link_end: "</a>".html_safe + } + } + end + def storage_enforcement_banner_user_callouts_feature_name(namespace) "storage_enforcement_banner_#{storage_enforcement_banner_threshold(namespace)}_enforcement_threshold" end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 5ab70115f34..a957c9ce9e0 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -30,6 +30,7 @@ module SystemNoteHelper 'locked' => 'lock', 'unlocked' => 'lock-open', 'due_date' => 'calendar', + 'start_date_or_due_date' => 'calendar', 'health_status' => 'status-health', 'designs_added' => 'doc-image', 'designs_modified' => 'doc-image', diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index dbbe7069ca4..04619ad3bda 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -17,7 +17,7 @@ module TabHelper class: [*html_options[:class], gl_tabs_classes].join(' ') ) - content = capture(&block) if block_given? + content = capture(&block) if block content_tag(:ul, content, html_options) end @@ -35,7 +35,7 @@ module TabHelper link_classes = %w[nav-link gl-tab-nav-item] active_link_classes = %w[active gl-tab-nav-item-active] - if block_given? + if block # Shift params to skip the omitted "name" param html_options = options options = name @@ -54,7 +54,7 @@ module TabHelper tab_class = %w[nav-item].push(*extra_tab_classes) content_tag(:li, class: tab_class) do - if block_given? + if block link_to(options, html_options, &block) else link_to(name, options, html_options) @@ -150,7 +150,7 @@ module TabHelper o[:class] = [*o[:class], klass].join(' ') o[:class].strip! - if block_given? + if block content_tag(:li, capture(&block), o) else content_tag(:li, nil, o) diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb index d16f13304e5..29bd5a84651 100644 --- a/app/helpers/time_zone_helper.rb +++ b/app/helpers/time_zone_helper.rb @@ -18,7 +18,7 @@ module TimeZoneHelper # def timezone_data(format: :short) attrs = TIME_ZONE_FORMAT_ATTRS.fetch(format) do - valid_formats = TIME_ZONE_FORMAT_ATTRS.keys.map { |k| ":#{k}"}.join(", ") + valid_formats = TIME_ZONE_FORMAT_ATTRS.keys.map { |k| ":#{k}" }.join(", ") raise ArgumentError, "Invalid format :#{format}. Valid formats are #{valid_formats}." end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index f87125af07d..5977f51cab1 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -23,7 +23,6 @@ module TodosHelper when Todo::APPROVAL_REQUIRED then "set #{todo_action_subject(todo)} as an approver for" when Todo::UNMERGEABLE then 'Could not merge' when Todo::MERGE_TRAIN_REMOVED then "Removed from Merge Train:" - when Todo::ATTENTION_REQUESTED then 'requested your attention on' end end @@ -131,11 +130,11 @@ module TodosHelper def todos_filter_params { - state: params[:state], + state: params[:state], project_id: params[:project_id], - author_id: params[:author_id], - type: params[:type], - action_id: params[:action_id] + author_id: params[:author_id], + type: params[:type], + action_id: params[:action_id] } end @@ -179,7 +178,7 @@ module TodosHelper end def todo_actions_dropdown_label(selected_action_id, default_action) - selected_action = todo_actions_options.find { |action| action[:id] == selected_action_id.to_i} + selected_action = todo_actions_options.find { |action| action[:id] == selected_action_id.to_i } selected_action ? selected_action[:text] : default_action end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 4ea2512bc67..cae2addea9c 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -15,11 +15,11 @@ module UsersHelper end def user_email_help_text(user) - return 'We also use email for avatar detection if no avatar is uploaded.' unless user.unconfirmed_email.present? + return _('We also use email for avatar detection if no avatar is uploaded.') unless user.unconfirmed_email.present? - confirmation_link = link_to 'Resend confirmation e-mail', user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post + confirmation_link = link_to _('Resend confirmation e-mail'), user_confirmation_path(user: { email: user.unconfirmed_email }), method: :post - h('Please click the link in the confirmation email before continuing. It was sent to ') + + h(_('Please click the link in the confirmation email before continuing. It was sent to ')) + content_tag(:strong) { user.unconfirmed_email } + h('.') + content_tag(:p) { confirmation_link } end @@ -67,12 +67,6 @@ module UsersHelper "access:#{max_project_member_access(project)}" end - def show_status_emoji?(status) - return false unless status - - status.message.present? || status.emoji != UserStatus::DEFAULT_EMOJI - end - def user_status(user) return unless user diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb index 64900714327..ba3c232bec4 100644 --- a/app/helpers/webpack_helper.rb +++ b/app/helpers/webpack_helper.rb @@ -83,16 +83,8 @@ module WebpackHelper end def webpack_public_host - # We do not proxy the webpack output in the 'test' environment, - # so we must reference the webpack dev server directly. - if Rails.env.test? && Gitlab.config.webpack.dev_server.enabled - host = Gitlab.config.webpack.dev_server.host - port = Gitlab.config.webpack.dev_server.port - protocol = Gitlab.config.webpack.dev_server.https ? 'https' : 'http' - "#{protocol}://#{host}:#{port}" - else - ActionController::Base.asset_host.try(:chomp, '/') - end + # We proxy webpack output in 'test' and 'dev' environment, so we can just use asset_host + ActionController::Base.asset_host.try(:chomp, '/') end def webpack_public_path diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index 02ea3c1b010..d6ffd3deafe 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -121,11 +121,11 @@ module WikiHelper def wiki_page_tracking_context(page) { - 'wiki-format' => page.format, - 'wiki-title-size' => page.title.bytesize, - 'wiki-content-size' => page.raw_content.bytesize, + 'wiki-format' => page.format, + 'wiki-title-size' => page.title.bytesize, + 'wiki-content-size' => page.raw_content.bytesize, 'wiki-directory-nest-level' => page.path.scan('/').count, - 'wiki-container-type' => page.wiki.container.class.name + 'wiki-container-type' => page.wiki.container.class.name } end diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb index 20aabb6fe58..1fa85064c57 100644 --- a/app/mailers/abuse_report_mailer.rb +++ b/app/mailers/abuse_report_mailer.rb @@ -11,8 +11,8 @@ class AbuseReportMailer < ApplicationMailer @abuse_report = AbuseReport.find(abuse_report_id) mail( - to: Gitlab::CurrentSettings.abuse_notification_email, - subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse" + to: Gitlab::CurrentSettings.abuse_notification_email, + subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse" ) end diff --git a/app/mailers/emails/admin_notification.rb b/app/mailers/emails/admin_notification.rb index 9d02d4132a1..3766b4447d1 100644 --- a/app/mailers/emails/admin_notification.rb +++ b/app/mailers/emails/admin_notification.rb @@ -15,23 +15,7 @@ module Emails email = user.notification_email_or_default mail to: email, subject: "Unsubscribed from GitLab administrator notifications" end - - def user_auto_banned_email(admin_id, user_id, max_project_downloads:, within_seconds:, group: nil) - admin = User.find(admin_id) - @user = User.find(user_id) - @max_project_downloads = max_project_downloads - @within_minutes = within_seconds / 60 - @ban_scope = if group.present? - _('your group (%{group_name})' % { group_name: group.name }) - else - _('your GitLab instance') - end - - Gitlab::I18n.with_locale(admin.preferred_language) do - email_with_layout( - to: admin.notification_email_or_default, - subject: subject(_("We've detected unusual activity"))) - end - end end end + +Emails::AdminNotification.prepend_mod diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 6a2b447f4a0..fc944c34166 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -104,13 +104,6 @@ module Emails mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end - def attention_requested_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) - setup_merge_request_mail(merge_request_id, recipient_id) - - @updated_by = User.find(updated_by_user_id) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) - end - def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index ed3fa28b15f..5b8471abb0f 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -51,9 +51,9 @@ module Emails add_project_headers headers['X-GitLab-Author'] = @message.author_username - mail(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?), - reply_to: @message.reply_to, - subject: @message.subject) + mail(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?), + reply_to: @message.reply_to, + subject: @message.subject) end def prometheus_alert_fired_email(project, user, alert) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 17b46f929c3..579f2c38ae6 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -790,10 +790,10 @@ class ApplicationSetting < ApplicationRecord def parsed_kroki_url @parsed_kroki_url ||= Gitlab::UrlBlocker.validate!(kroki_url, schemes: %w(http https), enforce_sanitization: true)[0] - rescue Gitlab::UrlBlocker::BlockedUrlError => error + rescue Gitlab::UrlBlocker::BlockedUrlError => e self.errors.add( :kroki_url, - "is not valid. #{error}" + "is not valid. #{e}" ) end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index e9a0a156121..4d377855dea 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -122,7 +122,7 @@ module ApplicationSettingImplementation password_authentication_enabled_for_git: true, password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'], performance_bar_allowed_group_id: nil, - personal_access_token_prefix: nil, + personal_access_token_prefix: 'glpat-', plantuml_enabled: false, plantuml_url: nil, polling_interval_multiplier: 1, diff --git a/app/models/approval.rb b/app/models/approval.rb index 899ea466315..9ded44fe425 100644 --- a/app/models/approval.rb +++ b/app/models/approval.rb @@ -2,11 +2,12 @@ class Approval < ApplicationRecord include CreatedAtFilterable + include Importable belongs_to :user belongs_to :merge_request - validates :merge_request_id, presence: true + validates :merge_request_id, presence: true, unless: :importing? validates :user_id, presence: true, uniqueness: { scope: [:merge_request_id] } scope :with_user, -> { joins(:user) } diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 8e8e9389e2d..0ad17cd8869 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -86,6 +86,18 @@ class AuditEvent < ApplicationRecord end end + def target_type + super || details[:target_type] + end + + def target_id + details[:target_id] + end + + def target_details + super || details[:target_details] + end + private def sanitize_message diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb index 0ed197f32df..d5a5079acd6 100644 --- a/app/models/authentication_event.rb +++ b/app/models/authentication_event.rb @@ -20,7 +20,7 @@ class AuthenticationEvent < ApplicationRecord } scope :for_provider, ->(provider) { where(provider: provider) } - scope :ldap, -> { where('provider LIKE ?', 'ldap%')} + scope :ldap, -> { where('provider LIKE ?', 'ldap%') } def self.providers STATIC_PROVIDERS | Devise.omniauth_providers.map(&:to_s) diff --git a/app/models/blob.rb b/app/models/blob.rb index a12d856dc36..20d7c230aa2 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -93,8 +93,8 @@ class Blob < SimpleDelegator end def self.lazy(repository, commit_id, path, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE) - BatchLoader.for([commit_id, path]).batch(key: repository) do |items, loader, args| - args[:key].blobs_at(items, blob_size_limit: blob_size_limit).each do |blob| + BatchLoader.for([commit_id, path]).batch(key: [:repository_blobs, repository]) do |items, loader, args| + args[:key].last.blobs_at(items, blob_size_limit: blob_size_limit).each do |blob| loader.call([blob.commit_id, blob.path], blob) if blob end end diff --git a/app/models/blob_viewer/metrics_dashboard_yml.rb b/app/models/blob_viewer/metrics_dashboard_yml.rb index 88643253d3d..cac6b2192d0 100644 --- a/app/models/blob_viewer/metrics_dashboard_yml.rb +++ b/app/models/blob_viewer/metrics_dashboard_yml.rb @@ -36,10 +36,10 @@ module BlobViewer yaml = ::Gitlab::Config::Loader::Yaml.new(blob.data).load_raw! ::PerformanceMonitoring::PrometheusDashboard.from_json(yaml) [] - rescue Gitlab::Config::Loader::FormatError => error - ["YAML syntax: #{error.message}"] - rescue ActiveModel::ValidationError => invalid - invalid.model.errors.messages.map { |messages| messages.join(': ') } + rescue Gitlab::Config::Loader::FormatError => e + ["YAML syntax: #{e.message}"] + rescue ActiveModel::ValidationError => e + e.model.errors.messages.map { |messages| messages.join(': ') } end def exhaustive_metrics_dashboard_validation @@ -47,8 +47,8 @@ module BlobViewer Gitlab::Metrics::Dashboard::Validator .errors(yaml, dashboard_path: blob.path, project: project) .map(&:message) - rescue Gitlab::Config::Loader::FormatError => error - [error.message] + rescue Gitlab::Config::Loader::FormatError => e + [e.message] end end end diff --git a/app/models/bulk_imports/configuration.rb b/app/models/bulk_imports/configuration.rb index 6d9f598583e..3b263ed0340 100644 --- a/app/models/bulk_imports/configuration.rb +++ b/app/models/bulk_imports/configuration.rb @@ -9,7 +9,7 @@ class BulkImports::Configuration < ApplicationRecord validates :url, :access_token, length: { maximum: 255 }, presence: true validates :url, public_url: { schemes: %w[http https], enforce_sanitization: true, ascii_only: true }, - allow_nil: true + allow_nil: true attr_encrypted :url, key: Settings.attr_encrypted_db_key_base_32, diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index cad2fafe640..e0a616b5fb4 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -52,9 +52,11 @@ class BulkImports::Entity < ApplicationRecord scope :by_user_id, ->(user_id) { joins(:bulk_import).where(bulk_imports: { user_id: user_id }) } scope :stale, -> { where('created_at < ?', 8.hours.ago).where(status: [0, 1]) } - scope :by_bulk_import_id, ->(bulk_import_id) { where(bulk_import_id: bulk_import_id)} + scope :by_bulk_import_id, ->(bulk_import_id) { where(bulk_import_id: bulk_import_id) } scope :order_by_created_at, -> (direction) { order(created_at: direction) } + alias_attribute :destination_slug, :destination_name + state_machine :status, initial: :created do state :created, value: 0 state :started, value: 1 diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb index ff3f2663b73..60370c525d5 100644 --- a/app/models/chat_name.rb +++ b/app/models/chat_name.rb @@ -3,7 +3,7 @@ class ChatName < ApplicationRecord LAST_USED_AT_INTERVAL = 1.hour - belongs_to :integration, foreign_key: :service_id + belongs_to :integration belongs_to :user validates :user, presence: true @@ -11,8 +11,8 @@ class ChatName < ApplicationRecord validates :team_id, presence: true validates :chat_id, presence: true - validates :user_id, uniqueness: { scope: [:service_id] } - validates :chat_id, uniqueness: { scope: [:service_id, :team_id] } + validates :user_id, uniqueness: { scope: [:integration_id] } + validates :chat_id, uniqueness: { scope: [:integration_id, :team_id] } # Updates the "last_used_timestamp" but only if it wasn't already updated # recently. diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 13af5b1f8d1..3fda8693a58 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -19,7 +19,7 @@ module Ci belongs_to :project belongs_to :trigger_request has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline", - foreign_key: :source_job_id + foreign_key: :source_job_id has_one :sourced_pipeline, class_name: "::Ci::Sources::Pipeline", foreign_key: :source_job_id has_one :downstream_pipeline, through: :sourced_pipeline, source: :pipeline @@ -114,7 +114,12 @@ module Ci def downstream_project_path strong_memoize(:downstream_project_path) do - options&.dig(:trigger, :project) + project = options&.dig(:trigger, :project) + next unless project + + scoped_variables.to_runner_variables.yield_self do |all_variables| + ::ExpandVariables.expand(project, all_variables) + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 7f9697d0424..bf8817e6e78 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -194,7 +194,7 @@ module Ci after_save :stick_build_if_status_changed after_create unless: :importing? do |build| - run_after_commit { BuildHooksWorker.perform_async(build) } + run_after_commit { build.feature_flagged_execute_hooks } end class << self @@ -285,7 +285,7 @@ module Ci build.run_after_commit do BuildQueueWorker.perform_async(id) - BuildHooksWorker.perform_async(build) + build.feature_flagged_execute_hooks end end @@ -313,7 +313,7 @@ module Ci build.run_after_commit do build.ensure_persistent_ref - BuildHooksWorker.perform_async(build) + build.feature_flagged_execute_hooks end end @@ -322,6 +322,8 @@ module Ci build.run_status_commit_hooks! Ci::BuildFinishedWorker.perform_async(id) + + observe_report_types end end @@ -340,8 +342,8 @@ module Ci # rubocop: disable CodeReuse/ServiceClass Ci::RetryJobService.new(build.project, build.user).execute(build) # rubocop: enable CodeReuse/ServiceClass - rescue Gitlab::Access::AccessDeniedError => ex - Gitlab::AppLogger.error "Unable to auto-retry job #{build.id}: #{ex}" + rescue Gitlab::Access::AccessDeniedError => e + Gitlab::AppLogger.error "Unable to auto-retry job #{build.id}: #{e}" end end end @@ -490,11 +492,7 @@ module Ci if metadata&.expanded_environment_name.present? metadata.expanded_environment_name else - if ::Feature.enabled?(:ci_expand_environment_name_and_url, project) - ExpandVariables.expand(environment, -> { simple_variables.sort_and_expand_all }) - else - ExpandVariables.expand(environment, -> { simple_variables }) - end + ExpandVariables.expand(environment, -> { simple_variables.sort_and_expand_all }) end end end @@ -527,10 +525,14 @@ module Ci self.options.fetch(:environment, {}).fetch(:action, 'start') if self.options end - def environment_deployment_tier + def environment_tier_from_options self.options.dig(:environment, :deployment_tier) if self.options end + def environment_tier + environment_tier_from_options || persisted_environment.try(:tier) + end + def triggered_by?(current_user) user == current_user end @@ -585,6 +587,7 @@ module Ci variables.concat(persisted_environment.predefined_variables) variables.append(key: 'CI_ENVIRONMENT_ACTION', value: environment_action) + variables.append(key: 'CI_ENVIRONMENT_TIER', value: environment_tier) # Here we're passing unexpanded environment_url for runner to expand, # and we need to make sure that CI_ENVIRONMENT_NAME and @@ -777,10 +780,20 @@ module Ci pending? && !any_runners_online? end + def feature_flagged_execute_hooks + if Feature.enabled?(:execute_build_hooks_inline, project) + execute_hooks + else + BuildHooksWorker.perform_async(self) + end + end + def execute_hooks return unless project return if user&.blocked? + ActiveRecord::Associations::Preloader.new.preload([self], { runner: :tags }) + project.execute_hooks(build_data.dup, :job_hooks) if project.has_active_hooks?(:job_hooks) project.execute_integrations(build_data.dup, :job_hooks) if project.has_active_integrations?(:job_hooks) end @@ -818,7 +831,11 @@ module Ci ) end - job_artifacts.erasable.destroy_all # rubocop: disable Cop/DestroyAll + destroyed_artifacts = job_artifacts.erasable.destroy_all # rubocop: disable Cop/DestroyAll + + Gitlab::Ci::Artifacts::Logger.log_deleted(destroyed_artifacts, 'Ci::Build#erase_erasable_artifacts!') + + destroyed_artifacts end def erase(opts = {}) @@ -831,7 +848,12 @@ module Ci ) end - job_artifacts.destroy_all # rubocop: disable Cop/DestroyAll + # TODO: We should use DestroyBatchService here + # See https://gitlab.com/gitlab-org/gitlab/-/issues/369132 + destroyed_artifacts = job_artifacts.destroy_all # rubocop: disable Cop/DestroyAll + + Gitlab::Ci::Artifacts::Logger.log_deleted(destroyed_artifacts, 'Ci::Build#erase') + erase_trace! update_erased!(opts[:erased_by]) end @@ -983,7 +1005,7 @@ module Ci def collect_test_reports!(test_reports) test_reports.get_suite(test_suite_name).tap do |test_suite| - each_report(Ci::JobArtifact::TEST_REPORT_FILE_TYPES) do |file_type, blob| + each_report(Ci::JobArtifact.file_types_for_report(:test)) do |file_type, blob| Gitlab::Ci::Parsers.fabricate!(file_type).parse!( blob, test_suite, @@ -994,7 +1016,7 @@ module Ci end def collect_accessibility_reports!(accessibility_report) - each_report(Ci::JobArtifact::ACCESSIBILITY_REPORT_FILE_TYPES) do |file_type, blob| + each_report(Ci::JobArtifact.file_types_for_report(:accessibility)) do |file_type, blob| Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, accessibility_report) end @@ -1002,7 +1024,7 @@ module Ci end def collect_codequality_reports!(codequality_report) - each_report(Ci::JobArtifact::CODEQUALITY_REPORT_FILE_TYPES) do |file_type, blob| + each_report(Ci::JobArtifact.file_types_for_report(:codequality)) do |file_type, blob| Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, codequality_report) end @@ -1010,7 +1032,7 @@ module Ci end def collect_terraform_reports!(terraform_reports) - each_report(::Ci::JobArtifact::TERRAFORM_REPORT_FILE_TYPES) do |file_type, blob, report_artifact| + each_report(::Ci::JobArtifact.file_types_for_report(:terraform)) do |file_type, blob, report_artifact| ::Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, terraform_reports, artifact: report_artifact) end @@ -1079,7 +1101,10 @@ module Ci end def drop_with_exit_code!(failure_reason, exit_code) - drop!(::Gitlab::Ci::Build::Status::Reason.new(self, failure_reason, exit_code)) + failure_reason ||= :unknown_failure + result = drop!(::Gitlab::Ci::Build::Status::Reason.new(self, failure_reason, exit_code)) + ::Ci::TrackFailedBuildWorker.perform_async(id, exit_code, failure_reason) + result end def exit_codes_defined? @@ -1149,6 +1174,21 @@ module Ci end end + def clone(current_user:, new_job_variables_attributes: []) + new_build = super + + if action? && new_job_variables_attributes.any? + new_build.job_variables = [] + new_build.job_variables_attributes = new_job_variables_attributes + end + + new_build + end + + def job_artifact_types + job_artifacts.map(&:file_type) + end + protected def run_status_commit_hooks! @@ -1256,6 +1296,20 @@ module Ci expires_in: RUNNERS_STATUS_CACHE_EXPIRATION ) { yield } end + + def observe_report_types + return unless ::Gitlab.com? && Feature.enabled?(:report_artifact_build_completed_metrics_on_build_completion) + + report_types = options&.dig(:artifacts, :reports)&.keys || [] + + report_types.each do |report_type| + next unless Ci::JobArtifact::REPORT_TYPES.include?(report_type) + + ::Gitlab::Ci::Artifacts::Metrics + .build_completed_report_type_counter(report_type) + .increment(status: status) + end + end end end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 4ee661d89f4..5fc21ba3f28 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -19,6 +19,7 @@ module Ci before_create :set_build_project validates :build, presence: true + validates :id_tokens, json_schema: { filename: 'build_metadata_id_tokens' } validates :secrets, json_schema: { filename: 'build_metadata_secrets' } serialize :config_options, Serializers::SymbolizedJson # rubocop:disable Cop/ActiveRecordSerialize diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb index 1ffa0e31f99..86de90983ff 100644 --- a/app/models/ci/build_trace_metadata.rb +++ b/app/models/ci/build_trace_metadata.rb @@ -39,8 +39,8 @@ module Ci def track_archival!(trace_artifact_id, checksum) update!(trace_artifact_id: trace_artifact_id, - checksum: checksum, - archived_at: Time.current) + checksum: checksum, + archived_at: Time.current) end def archival_attempts_message diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb index aba7b73aba9..d36646aba66 100644 --- a/app/models/ci/deleted_object.rb +++ b/app/models/ci/deleted_object.rb @@ -27,8 +27,8 @@ module Ci def delete_file_from_storage file.remove! true - rescue StandardError => exception - Gitlab::ErrorTracking.track_exception(exception) + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e) false end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index ee7175a4f69..71d33f0bb63 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -13,14 +13,19 @@ module Ci include EachBatch include Gitlab::Utils::StrongMemoize - TEST_REPORT_FILE_TYPES = %w[junit].freeze - COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze - CODEQUALITY_REPORT_FILE_TYPES = %w[codequality].freeze - ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze NON_ERASABLE_FILE_TYPES = %w[trace].freeze - TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze - SAST_REPORT_TYPES = %w[sast].freeze - SECRET_DETECTION_REPORT_TYPES = %w[secret_detection].freeze + + REPORT_FILE_TYPES = { + sast: %w[sast], + secret_detection: %w[secret_detection], + test: %w[junit], + accessibility: %w[accessibility], + coverage: %w[cobertura], + codequality: %w[codequality], + terraform: %w[terraform], + sbom: %w[cyclonedx] + }.freeze + DEFAULT_FILE_NAMES = { archive: nil, metadata: nil, @@ -48,7 +53,8 @@ module Ci cluster_applications: 'gl-cluster-applications.json', # DEPRECATED: https://gitlab.com/gitlab-org/gitlab/-/issues/361094 requirements: 'requirements.json', coverage_fuzzing: 'gl-coverage-fuzzing.json', - api_fuzzing: 'gl-api-fuzzing-report.json' + api_fuzzing: 'gl-api-fuzzing-report.json', + cyclonedx: 'gl-sbom.cdx.zip' }.freeze INTERNAL_TYPES = { @@ -88,7 +94,8 @@ module Ci terraform: :raw, requirements: :raw, coverage_fuzzing: :raw, - api_fuzzing: :raw + api_fuzzing: :raw, + cyclonedx: :zip }.freeze DOWNLOADABLE_TYPES = %w[ @@ -112,6 +119,7 @@ module Ci secret_detection requirements cluster_image_scanning + cyclonedx ].freeze TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze @@ -152,36 +160,14 @@ module Ci where(file_type: types) end - scope :all_reports, -> do - with_file_types(REPORT_TYPES.keys.map(&:to_s)) - end - - scope :sast_reports, -> do - with_file_types(SAST_REPORT_TYPES) - end - - scope :secret_detection_reports, -> do - with_file_types(SECRET_DETECTION_REPORT_TYPES) - end - - scope :test_reports, -> do - with_file_types(TEST_REPORT_FILE_TYPES) - end - - scope :accessibility_reports, -> do - with_file_types(ACCESSIBILITY_REPORT_FILE_TYPES) - end - - scope :coverage_reports, -> do - with_file_types(COVERAGE_REPORT_FILE_TYPES) - end - - scope :codequality_reports, -> do - with_file_types(CODEQUALITY_REPORT_FILE_TYPES) + REPORT_FILE_TYPES.each do |report_type, file_types| + scope "#{report_type}_reports", -> do + with_file_types(file_types) + end end - scope :terraform_reports, -> do - with_file_types(TERRAFORM_REPORT_FILE_TYPES) + scope :all_reports, -> do + with_file_types(REPORT_TYPES.keys.map(&:to_s)) end scope :erasable, -> do @@ -225,7 +211,8 @@ module Ci browser_performance: 24, ## EE-specific load_performance: 25, ## EE-specific api_fuzzing: 26, ## EE-specific - cluster_image_scanning: 27 ## EE-specific + cluster_image_scanning: 27, ## EE-specific + cyclonedx: 28 ## EE-specific } # `file_location` indicates where actual files are stored. @@ -259,6 +246,10 @@ module Ci end end + def self.file_types_for_report(report_type) + REPORT_FILE_TYPES.fetch(report_type) + end + def self.associated_file_types_for(file_type) return unless file_types.include?(file_type) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 95c6da4a7af..a94330270e2 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -52,15 +52,15 @@ module Ci belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines has_internal_id :iid, scope: :project, presence: false, - track_if: -> { !importing? }, - ensure_if: -> { !importing? }, - init: ->(pipeline, scope) do - if pipeline - pipeline.project&.all_pipelines&.maximum(:iid) || pipeline.project&.all_pipelines&.count - elsif scope - ::Ci::Pipeline.where(**scope).maximum(:iid) - end - end + track_if: -> { !importing? }, + ensure_if: -> { !importing? }, + init: ->(pipeline, scope) do + if pipeline + pipeline.project&.all_pipelines&.maximum(:iid) || pipeline.project&.all_pipelines&.count + elsif scope + ::Ci::Pipeline.where(**scope).maximum(:iid) + end + end has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline @@ -102,6 +102,7 @@ module Ci has_one :chat_data, class_name: 'Ci::PipelineChatData' has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline + # Only includes direct and not nested children has_many :child_pipelines, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :sourced_pipelines, source: :pipeline has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline has_one :parent_pipeline, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :source_pipeline, source: :source_pipeline @@ -389,7 +390,7 @@ module Ci end def self.latest_status(ref = nil) - newest_first(ref: ref).pluck(:status).first + newest_first(ref: ref).pick(:status) end def self.latest_successful_for_ref(ref) @@ -592,26 +593,20 @@ module Ci canceled? && auto_canceled_by_id? end - def cancel_running(retries: 1) - preloaded_relations = [:project, :pipeline, :deployment, :taggings] - - retry_lock(cancelable_statuses, retries, name: 'ci_pipeline_cancel_running') do |cancelables| - cancelables.find_in_batches do |batch| - Preloaders::CommitStatusPreloader.new(batch).execute(preloaded_relations) - - batch.each do |job| - yield(job) if block_given? - job.cancel - end - end - end - end + # Cancel a pipelines cancelable jobs and optionally it's child pipelines cancelable jobs + # retries - # of times to retry if errors + # cascade_to_children - if true cancels all related child pipelines for parent child pipelines + # auto_canceled_by_pipeline_id - store the pipeline_id of the pipeline that triggered cancellation + # execute_async - if true cancel the children asyncronously + def cancel_running(retries: 1, cascade_to_children: true, auto_canceled_by_pipeline_id: nil, execute_async: true) + update(auto_canceled_by_id: auto_canceled_by_pipeline_id) if auto_canceled_by_pipeline_id - def auto_cancel_running(pipeline, retries: 1) - update(auto_canceled_by: pipeline) + cancel_jobs(cancelable_statuses, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id) - cancel_running(retries: retries) do |job| - job.auto_canceled_by = pipeline + if cascade_to_children + # cancel any bridges that could spin up new child pipelines + cancel_jobs(bridges_in_self_and_descendants.cancelable, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id) + cancel_children(auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id, execute_async: execute_async) end end @@ -953,6 +948,10 @@ module Ci Ci::Build.latest.where(pipeline: self_and_descendants) end + def bridges_in_self_and_descendants + Ci::Bridge.latest.where(pipeline: self_and_descendants) + end + def environments_in_self_and_descendants(deployment_status: nil) # We limit to 100 unique environments for application safety. # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700 @@ -986,6 +985,11 @@ module Ci object_hierarchy(project_condition: :same).base_and_descendants end + # With only parent-child pipelines + def all_child_pipelines + object_hierarchy(project_condition: :same).descendants + end + def self_and_descendants_complete? self_and_descendants.all?(&:complete?) end @@ -1152,6 +1156,10 @@ module Ci end end + def modified_paths_since(compare_to_sha) + project.repository.diff_stats(project.repository.merge_base(compare_to_sha, sha), sha).paths + end + def all_worktree_paths strong_memoize(:all_worktree_paths) do project.repository.ls_files(sha) @@ -1216,10 +1224,6 @@ module Ci stages.find_by(name: name) end - def find_stage_by_name!(name) - stages.find_by!(name: name) - end - def full_error_messages errors ? errors.full_messages.to_sentence : "" end @@ -1321,6 +1325,42 @@ module Ci private + def cancel_jobs(jobs, retries: 1, auto_canceled_by_pipeline_id: nil) + retry_lock(jobs, retries, name: 'ci_pipeline_cancel_running') do |statuses| + preloaded_relations = [:project, :pipeline, :deployment, :taggings] + + statuses.find_in_batches do |status_batch| + relation = CommitStatus.where(id: status_batch) + Preloaders::CommitStatusPreloader.new(relation).execute(preloaded_relations) + + relation.each do |job| + job.auto_canceled_by_id = auto_canceled_by_pipeline_id if auto_canceled_by_pipeline_id + job.cancel + end + end + end + end + + # For parent child-pipelines only (not multi-project) + def cancel_children(auto_canceled_by_pipeline_id: nil, execute_async: true) + all_child_pipelines.each do |child_pipeline| + if execute_async + ::Ci::CancelPipelineWorker.perform_async( + child_pipeline.id, + auto_canceled_by_pipeline_id + ) + else + child_pipeline.cancel_running( + # cascade_to_children is false because we iterate through children + # we also cancel bridges prior to prevent more children + cascade_to_children: false, + execute_async: execute_async, + auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id + ) + end + end + end + def add_message(severity, content) messages.build(severity: severity, content: content) end diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index f666629c8fd..a2ff49077be 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -101,7 +101,7 @@ module Ci :merge_train_pipeline?, to: :pipeline - def clone(current_user:) + def clone(current_user:, new_job_variables_attributes: []) new_attributes = self.class.clone_accessors.to_h do |attribute| [attribute, public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index f41ad890184..6c3754d84d0 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -15,7 +15,7 @@ module Ci include Presentable include EachBatch - ignore_column :semver, remove_with: '15.3', remove_after: '2022-07-22' + ignore_column :semver, remove_with: '15.4', remove_after: '2022-08-22' add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced? @@ -437,7 +437,12 @@ module Ci cache_attributes(values) # We save data without validation, it will always change due to `contacted_at` - self.update_columns(values) if persist_cached_data? + if persist_cached_data? + version_updated = values.include?(:version) && values[:version] != version + + update_columns(values) + schedule_runner_version_update if version_updated + end end end @@ -477,7 +482,7 @@ module Ci private scope :with_upgrade_status, ->(upgrade_status) do - Ci::Runner.joins(:runner_version).where(runner_version: { status: upgrade_status }) + joins(:runner_version).where(runner_version: { status: upgrade_status }) end EXECUTOR_NAME_TO_TYPES = { @@ -565,6 +570,12 @@ module Ci errors.add(:runner, 'needs to be assigned to exactly one group') end end + + def schedule_runner_version_update + return unless version + + Ci::Runners::ProcessRunnerVersionUpdateWorker.perform_async(version) + end end end diff --git a/app/models/ci/runner_version.rb b/app/models/ci/runner_version.rb index 6b2d0060c9b..bbde98ee591 100644 --- a/app/models/ci/runner_version.rb +++ b/app/models/ci/runner_version.rb @@ -8,7 +8,6 @@ module Ci enum_with_nil status: { not_processed: nil, invalid_version: -1, - unknown: 0, not_available: 1, available: 2, recommended: 3 @@ -16,7 +15,6 @@ module Ci STATUS_DESCRIPTIONS = { invalid_version: 'Runner version is not valid.', - unknown: 'Upgrade status is unknown.', not_available: 'Upgrade is not available for the runner.', available: 'Upgrade is available for the runner.', recommended: 'Upgrade is available and recommended for the runner.' @@ -27,7 +25,7 @@ module Ci # This scope returns all versions that might need recalculating. For instance, once a version is considered # :recommended, it normally doesn't change status even if the instance is upgraded - scope :potentially_outdated, -> { where(status: [nil, :not_available, :available, :unknown]) } + scope :potentially_outdated, -> { where(status: [nil, :not_available, :available]) } validates :version, length: { maximum: 2048 } end diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb index 078b05ff779..9a35f1876c9 100644 --- a/app/models/ci/secure_file.rb +++ b/app/models/ci/secure_file.rb @@ -3,11 +3,8 @@ module Ci class SecureFile < Ci::ApplicationRecord include FileStoreMounter - include IgnorableColumns include Limitable - ignore_column :permissions, remove_with: '15.2', remove_after: '2022-06-22' - FILE_SIZE_LIMIT = 5.megabytes.freeze CHECKSUM_ALGORITHM = 'sha256' @@ -24,6 +21,7 @@ module Ci before_validation :assign_checksum scope :order_by_created_at, -> { order(created_at: :desc) } + scope :project_id_in, ->(ids) { where(project_id: ids) } default_value_for(:file_store) { Ci::SecureFileUploader.default_store } @@ -46,3 +44,5 @@ module Ci end end end + +Ci::SecureFile.prepend_mod diff --git a/app/models/commit.rb b/app/models/commit.rb index ca18cb50e02..bd60f02b532 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -190,7 +190,7 @@ class Commit def self.link_reference_pattern @link_reference_pattern ||= - super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/) + super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/o) end def to_reference(from = nil, full: false) diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index 08f1eb3731e..e2f0de52bc9 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -50,7 +50,7 @@ class CommitRange end def self.link_reference_pattern - @link_reference_pattern ||= super("compare", /(?<commit_range>#{PATTERN})/) + @link_reference_pattern ||= super("compare", /(?<commit_range>#{PATTERN})/o) end # Initialize a CommitRange @@ -64,7 +64,7 @@ class CommitRange range_string = range_string.strip - unless range_string =~ /\A#{PATTERN}\z/ + unless range_string =~ /\A#{PATTERN}\z/o raise ArgumentError, "invalid CommitRange string format: #{range_string}" end diff --git a/app/models/commit_signatures/ssh_signature.rb b/app/models/commit_signatures/ssh_signature.rb index dbfbe0c3889..7a8d0653fcd 100644 --- a/app/models/commit_signatures/ssh_signature.rb +++ b/app/models/commit_signatures/ssh_signature.rb @@ -4,6 +4,6 @@ module CommitSignatures class SshSignature < ApplicationRecord include CommitSignature - belongs_to :key, optional: false + belongs_to :key, optional: true end end diff --git a/app/models/compare.rb b/app/models/compare.rb index 7f42e1ee491..f594a796987 100644 --- a/app/models/compare.rb +++ b/app/models/compare.rb @@ -92,7 +92,7 @@ class Compare def diff_refs Gitlab::Diff::DiffRefs.new( - base_sha: @straight ? start_commit_sha : base_commit_sha, + base_sha: @straight ? start_commit_sha : base_commit_sha, start_sha: start_commit_sha, head_sha: head_commit_sha ) diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb index fb4ea4206f4..ee8e98ec1bf 100644 --- a/app/models/concerns/ci/artifactable.rb +++ b/app/models/concerns/ci/artifactable.rb @@ -5,11 +5,13 @@ module Ci extend ActiveSupport::Concern include ObjectStorable + include Gitlab::Ci::Artifacts::Logger STORE_COLUMN = :file_store NotSupportedAdapterError = Class.new(StandardError) FILE_FORMAT_ADAPTERS = { gzip: Gitlab::Ci::Build::Artifacts::Adapters::GzipStream, + zip: Gitlab::Ci::Build::Artifacts::Adapters::ZipStream, raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream }.freeze @@ -30,7 +32,7 @@ module Ci raise NotSupportedAdapterError, 'This file format requires a dedicated adapter' end - ::Gitlab::ApplicationContext.push(artifact: file.model) + log_artifacts_filesize(file.model) file.open do |stream| file_format_adapter_class.new(stream).each_blob(&blk) diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index 721cb14201f..910885c833f 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -17,8 +17,8 @@ module Ci ALIVE_STATUSES = (ACTIVE_STATUSES + ['created']).freeze CANCELABLE_STATUSES = (ALIVE_STATUSES + ['scheduled']).freeze STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, - failed: 4, canceled: 5, skipped: 6, manual: 7, - scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze + failed: 4, canceled: 5, skipped: 6, manual: 7, + scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze UnknownStatusError = Class.new(StandardError) diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index aa9669ee208..8c3a05c23f0 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -20,6 +20,8 @@ module Ci delegate :interruptible, to: :metadata, prefix: false, allow_nil: true delegate :environment_auto_stop_in, to: :metadata, prefix: false, allow_nil: true delegate :set_cancel_gracefully, to: :metadata, prefix: false, allow_nil: false + delegate :id_tokens, to: :metadata, allow_nil: true + before_create :ensure_metadata end @@ -77,6 +79,14 @@ module Ci ensure_metadata.interruptible = value end + def id_tokens? + !!metadata&.id_tokens? + end + + def id_tokens=(value) + ensure_metadata.id_tokens = value + end + private def read_metadata_attribute(legacy_key, metadata_key, default_value = nil) diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index b41b1ba6008..65cf3246d11 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -82,18 +82,23 @@ module CounterAttribute lock_key = counter_lock_key(attribute) with_exclusive_lease(lock_key) do + previous_db_value = read_attribute(attribute) increment_key = counter_key(attribute) flushed_key = counter_flushed_key(attribute) increment_value = steal_increments(increment_key, flushed_key) + new_db_value = nil next if increment_value == 0 transaction do unsafe_update_counters(id, attribute => increment_value) redis_state { |redis| redis.del(flushed_key) } + new_db_value = reset.read_attribute(attribute) end execute_after_flush_callbacks + + log_flush_counter(attribute, increment_value, previous_db_value, new_db_value) end end @@ -115,15 +120,19 @@ module CounterAttribute def increment_counter(attribute, increment) if counter_attribute_enabled?(attribute) - redis_state do |redis| + new_value = redis_state do |redis| redis.incrby(counter_key(attribute), increment) end + + log_increment_counter(attribute, increment, new_value) end end def clear_counter!(attribute) if counter_attribute_enabled?(attribute) redis_state { |redis| redis.del(counter_key(attribute)) } + + log_clear_counter(attribute) end end @@ -184,4 +193,40 @@ module CounterAttribute rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError # a worker is already updating the counters end + + def log_increment_counter(attribute, increment, new_value) + payload = Gitlab::ApplicationContext.current.merge( + message: 'Increment counter attribute', + attribute: attribute, + project_id: project_id, + increment: increment, + new_counter_value: new_value, + current_db_value: read_attribute(attribute) + ) + + Gitlab::AppLogger.info(payload) + end + + def log_flush_counter(attribute, increment, previous_db_value, new_db_value) + payload = Gitlab::ApplicationContext.current.merge( + message: 'Flush counter attribute to database', + attribute: attribute, + project_id: project_id, + increment: increment, + previous_db_value: previous_db_value, + new_db_value: new_db_value + ) + + Gitlab::AppLogger.info(payload) + end + + def log_clear_counter(attribute) + payload = Gitlab::ApplicationContext.current.merge( + message: 'Clear counter attribute', + attribute: attribute, + project_id: project_id + ) + + Gitlab::AppLogger.info(payload) + end end diff --git a/app/models/concerns/cross_database_modification.rb b/app/models/concerns/cross_database_modification.rb index dea62f03f91..273d5f35e76 100644 --- a/app/models/concerns/cross_database_modification.rb +++ b/app/models/concerns/cross_database_modification.rb @@ -80,34 +80,22 @@ module CrossDatabaseModification end def transaction(**options, &block) - if track_gitlab_schema_in_current_transaction? - super(**options) do - # Hook into current transaction to ensure that once - # the `COMMIT` is executed the `gitlab_transactions_stack` - # will be allowing to execute `after_commit_queue` - record = TransactionStackTrackRecord.new(self, gitlab_schema) - - begin - connection.current_transaction.add_record(record) - - yield - ensure - record.done! - end + super(**options) do + # Hook into current transaction to ensure that once + # the `COMMIT` is executed the `gitlab_transactions_stack` + # will be allowing to execute `after_commit_queue` + record = TransactionStackTrackRecord.new(self, gitlab_schema) + + begin + connection.current_transaction.add_record(record) + + yield + ensure + record.done! end - else - super(**options, &block) end end - def track_gitlab_schema_in_current_transaction? - return false unless Feature::FlipperFeature.table_exists? - - Feature.enabled?(:track_gitlab_schema_in_current_transaction) - rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad - false - end - def gitlab_schema case self.name when 'ActiveRecord::Base', 'ApplicationRecord' diff --git a/app/models/concerns/database_event_tracking.rb b/app/models/concerns/database_event_tracking.rb new file mode 100644 index 00000000000..9f75b3ed4d8 --- /dev/null +++ b/app/models/concerns/database_event_tracking.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module DatabaseEventTracking + extend ActiveSupport::Concern + + included do + after_create_commit :publish_database_create_event + after_destroy_commit :publish_database_destroy_event + after_update_commit :publish_database_update_event + end + + def publish_database_create_event + publish_database_event('create') + end + + def publish_database_destroy_event + publish_database_event('destroy') + end + + def publish_database_update_event + publish_database_event('update') + end + + def publish_database_event(name) + return unless Feature.enabled?(:product_intelligence_database_event_tracking) + + # Gitlab::Tracking#event is triggering Snowplow event + # Snowplow events are sent with usage of + # https://snowplow.github.io/snowplow-ruby-tracker/SnowplowTracker/AsyncEmitter.html + # that reports data asynchronously and does not impact performance nor carries a risk of + # rollback in case of error + + Gitlab::Tracking.event( + self.class.to_s, + "database_event_#{name}", + label: self.class.table_name, + namespace: try(:group) || try(:namespace), + property: name, + **filtered_record_attributes + ) + rescue StandardError => err + # this rescue should be a dead code due to utilization of AsyncEmitter, however + # since this concern is expected to be included in every model, it is better to + # prevent against any unexpected outcome + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err) + end + + def filtered_record_attributes + attributes + .with_indifferent_access + .slice(*self.class::SNOWPLOW_ATTRIBUTES) + end +end diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb index 051158e5de5..7a6076c7d2e 100644 --- a/app/models/concerns/diff_positionable_note.rb +++ b/app/models/concerns/diff_positionable_note.rb @@ -17,7 +17,11 @@ module DiffPositionableNote %i(original_position position change_position).each do |meth| define_method "#{meth}=" do |new_position| if new_position.is_a?(String) - new_position = Gitlab::Json.parse(new_position) rescue nil + new_position = begin + Gitlab::Json.parse(new_position) + rescue StandardError + nil + end end if new_position.is_a?(Hash) diff --git a/app/models/concerns/enums/data_visualization_palette.rb b/app/models/concerns/enums/data_visualization_palette.rb index 25002e64ba6..6e712e79915 100644 --- a/app/models/concerns/enums/data_visualization_palette.rb +++ b/app/models/concerns/enums/data_visualization_palette.rb @@ -16,17 +16,17 @@ module Enums def self.weights { - '50' => 0, - '100' => 1, - '200' => 2, - '300' => 3, - '400' => 4, - '500' => 5, - '600' => 6, - '700' => 7, - '800' => 8, - '900' => 9, - '950' => 10 + '50' => 0, + '100' => 1, + '200' => 2, + '300' => 3, + '400' => 4, + '500' => 5, + '600' => 6, + '700' => 7, + '800' => 8, + '900' => 9, + '950' => 10 } end end diff --git a/app/models/concerns/enums/sbom.rb b/app/models/concerns/enums/sbom.rb new file mode 100644 index 00000000000..518efa669ad --- /dev/null +++ b/app/models/concerns/enums/sbom.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Enums + class Sbom + COMPONENT_TYPES = { + library: 0 + }.with_indifferent_access.freeze + + def self.component_types + COMPONENT_TYPES + end + end +end diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb index e029ada84f0..5975ea23723 100644 --- a/app/models/concerns/expirable.rb +++ b/app/models/concerns/expirable.rb @@ -6,7 +6,10 @@ module Expirable DAYS_TO_EXPIRE = 7 included do - scope :expired, -> { where('expires_at <= ?', Time.current) } + scope :not, ->(scope) { where(scope.arel.constraints.reduce(:and).not) } + + scope :expired, -> { where('expires_at IS NOT NULL AND expires_at <= ?', Time.current) } + scope :not_expired, -> { self.not(expired) } end def expired? diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb index 08189d83534..3b741208221 100644 --- a/app/models/concerns/featurable.rb +++ b/app/models/concerns/featurable.rb @@ -30,9 +30,9 @@ module Featurable STRING_OPTIONS = HashWithIndifferentAccess.new({ 'disabled' => DISABLED, - 'private' => PRIVATE, - 'enabled' => ENABLED, - 'public' => PUBLIC + 'private' => PRIVATE, + 'enabled' => ENABLED, + 'public' => PUBLIC }).freeze class_methods do @@ -114,7 +114,7 @@ module Featurable self.errors.add(field, "cannot have public visibility level") if not_allowed end - (self.class.available_features - feature_validation_exclusion).each {|f| validator.call("#{f}_access_level")} + (self.class.available_features - feature_validation_exclusion).each { |f| validator.call("#{f}_access_level") } end # Features that we should exclude from the validation diff --git a/app/models/concerns/integrations/base_data_fields.rb b/app/models/concerns/integrations/base_data_fields.rb index 11bdd3aae7b..2870922d90d 100644 --- a/app/models/concerns/integrations/base_data_fields.rb +++ b/app/models/concerns/integrations/base_data_fields.rb @@ -4,15 +4,10 @@ module Integrations module BaseDataFields extend ActiveSupport::Concern - LEGACY_FOREIGN_KEY_NAME = %w( - Integrations::IssueTrackerData - Integrations::JiraTrackerData - ).freeze - included do # TODO: Once we rename the tables we can't rely on `table_name` anymore. # https://gitlab.com/gitlab-org/gitlab/-/issues/331953 - belongs_to :integration, inverse_of: self.table_name.to_sym, foreign_key: foreign_key_name + belongs_to :integration, inverse_of: self.table_name.to_sym, foreign_key: :integration_id validates :integration, presence: true end @@ -26,16 +21,6 @@ module Integrations algorithm: 'aes-256-gcm' } end - - private - - # Older data field models use the `service_id` foreign key for the - # integration association. - def foreign_key_name - return :service_id if self.name.in?(LEGACY_FOREIGN_KEY_NAME) - - :integration_id - end end def activated? diff --git a/app/models/concerns/integrations/has_data_fields.rb b/app/models/concerns/integrations/has_data_fields.rb index 635147a2f3c..2671df873aa 100644 --- a/app/models/concerns/integrations/has_data_fields.rb +++ b/app/models/concerns/integrations/has_data_fields.rb @@ -44,8 +44,8 @@ module Integrations end 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 :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :integration_id, class_name: 'Integrations::IssueTrackerData' + has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :integration_id, class_name: 'Integrations::JiraTrackerData' 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/integrations/has_web_hook.rb b/app/models/concerns/integrations/has_web_hook.rb index bc28c32695c..e6ca6cc7938 100644 --- a/app/models/concerns/integrations/has_web_hook.rb +++ b/app/models/concerns/integrations/has_web_hook.rb @@ -6,6 +6,7 @@ module Integrations included do after_save :update_web_hook!, if: :activated? + has_one :service_hook, inverse_of: :integration, foreign_key: :service_id end # Return the URL to be used for the webhook. diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 4dca07132ef..b81a9b51e1c 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -515,11 +515,23 @@ module Issuable changes end + def hook_reviewer_changes(old_associations) + changes = {} + old_reviewers = old_associations.fetch(:reviewers, reviewers) + + if old_reviewers != reviewers + changes[:reviewers] = [old_reviewers.map(&:hook_attrs), reviewers.map(&:hook_attrs)] + end + + changes + end + def to_hook_data(user, old_associations: {}) changes = previous_changes if old_associations.present? changes.merge!(hook_association_changes(old_associations)) + changes.merge!(hook_reviewer_changes(old_associations)) if allows_reviewers? end Gitlab::DataBuilder::Issuable.new(self).build(user: user, changes: changes) @@ -537,6 +549,10 @@ module Issuable labels.map(&:hook_attrs) end + def allows_scoped_labels? + false + end + # Convert this Issuable class name to a format usable by Ability definitions # # Examples: @@ -550,7 +566,7 @@ module Issuable # Returns a Hash of attributes to be used for Twitter card metadata def card_attributes { - 'Author' => author.try(:name), + 'Author' => author.try(:name), 'Assignee' => assignee_list } end diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index f59b5d1ecc8..8130adf05f1 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -109,6 +109,7 @@ module Participable when User participants << source when Participable + next if skippable_system_notes?(source, participants) next unless !verify_access || source_visible_to_user?(source, current_user) source.class.participant_attrs.each do |attr| @@ -133,6 +134,13 @@ module Participable participants.merge(extractor.users) end + def skippable_system_notes?(source, participants) + source.is_a?(Note) && + source.system? && + source.author.in?(participants) && + !source.note.match?(User.reference_pattern) + end + def use_internal_notes_extractor_for?(source) source.is_a?(Note) && source.confidential? end diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 900e8f7d39b..7613691bc2e 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -94,6 +94,18 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:container_registry_access_level, value) end + def environments_access_level=(value) + write_feature_attribute_string(:environments_access_level, value) + end + + def feature_flags_access_level=(value) + write_feature_attribute_string(:feature_flags_access_level, value) + end + + def releases_access_level=(value) + write_feature_attribute_string(:releases_access_level, value) + end + # TODO: Remove this method after we drop support for project create/edit APIs to set the # container_registry_enabled attribute. They can instead set the container_registry_access_level # attribute. diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index 86280097d19..df297017119 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -62,8 +62,8 @@ module PrometheusAdapter data: data, last_update: Time.current.utc } - rescue Gitlab::PrometheusClient::Error => err - { success: false, result: err.message } + rescue Gitlab::PrometheusClient::Error => e + { success: false, result: e.message } end def query_klass_for(query_name) diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb index 1dd8eebeff3..b7fd52ab305 100644 --- a/app/models/concerns/repository_storage_movable.rb +++ b/app/models/concerns/repository_storage_movable.rb @@ -50,8 +50,8 @@ module RepositoryStorageMovable begin storage_move.container.set_repository_read_only!(skip_git_transfer_check: true) - rescue StandardError => err - storage_move.add_error(err.message) + rescue StandardError => e + storage_move.add_error(e.message) next false end diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index 904c96b11b3..ee5774d4868 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -59,7 +59,7 @@ module Taskable end # Return a string that describes the current state of this Taskable's task - # list items, e.g. "12 of 20 tasks completed" + # list items, e.g. "12 of 20 checklist items completed" def task_status(short: false) return '' if description.blank? @@ -70,7 +70,7 @@ module Taskable end sum = tasks.summary - "#{sum.complete_count}#{prep}#{sum.item_count} #{'task'.pluralize(sum.item_count)}#{completed}" + "#{sum.complete_count}#{prep}#{sum.item_count} #{'checklist item'.pluralize(sum.item_count)}#{completed}" end # Return a short string that describes the current state of this Taskable's diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index 8fe34632430..e3800caa43f 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -2,22 +2,22 @@ module TriggerableHooks AVAILABLE_TRIGGERS = { - repository_update_hooks: :repository_update_events, - push_hooks: :push_events, - tag_push_hooks: :tag_push_events, - issue_hooks: :issues_events, - confidential_note_hooks: :confidential_note_events, + repository_update_hooks: :repository_update_events, + push_hooks: :push_events, + tag_push_hooks: :tag_push_events, + issue_hooks: :issues_events, + confidential_note_hooks: :confidential_note_events, confidential_issue_hooks: :confidential_issues_events, - note_hooks: :note_events, - merge_request_hooks: :merge_requests_events, - job_hooks: :job_events, - pipeline_hooks: :pipeline_events, - wiki_page_hooks: :wiki_page_events, - deployment_hooks: :deployment_events, - feature_flag_hooks: :feature_flag_events, - release_hooks: :releases_events, - member_hooks: :member_events, - subgroup_hooks: :subgroup_events + note_hooks: :note_events, + merge_request_hooks: :merge_requests_events, + job_hooks: :job_events, + pipeline_hooks: :pipeline_events, + wiki_page_hooks: :wiki_page_events, + deployment_hooks: :deployment_events, + feature_flag_hooks: :feature_flag_events, + release_hooks: :releases_events, + member_hooks: :member_events, + subgroup_hooks: :subgroup_events }.freeze extend ActiveSupport::Concern diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index 4cf36f83857..b5d48260072 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -50,7 +50,7 @@ module VulnerabilityFindingHelpers finding_data = report_finding.to_hash.except(:compare_key, :identifiers, :location, :scanner, :links, :signatures, :flags, :evidence) identifiers = report_finding.identifiers.map do |identifier| - Vulnerabilities::Identifier.new(identifier.to_hash) + Vulnerabilities::Identifier.new(identifier.to_hash.merge({ project: project })) end signatures = report_finding.signatures.map do |signature| Vulnerabilities::FindingSignature.new(signature.to_hash) @@ -72,6 +72,7 @@ module VulnerabilityFindingHelpers end finding.identifiers = identifiers + finding.primary_identifier = identifiers.first finding.signatures = signatures end end diff --git a/app/models/concerns/x509_serial_number_attribute.rb b/app/models/concerns/x509_serial_number_attribute.rb index e51ed95bf70..9dc53859ac0 100644 --- a/app/models/concerns/x509_serial_number_attribute.rb +++ b/app/models/concerns/x509_serial_number_attribute.rb @@ -33,8 +33,8 @@ module X509SerialNumberAttribute unless column.type == :binary raise ArgumentError, "x509_serial_number_attribute #{name.inspect} is invalid since the column type is not :binary" end - rescue StandardError => error - Gitlab::AppLogger.error "X509SerialNumberAttribute initialization: #{error.message}" + rescue StandardError => e + Gitlab::AppLogger.error "X509SerialNumberAttribute initialization: #{e.message}" raise end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index cdfd24e00aa..e10452c1081 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -19,6 +19,8 @@ class ContainerRepository < ApplicationRecord MIGRATION_PHASE_1_STARTED_AT = Date.new(2021, 11, 4).freeze MIGRATION_PHASE_1_ENDED_AT = Date.new(2022, 01, 23).freeze + MAX_TAGS_PAGES = 2000 + TooManyImportsError = Class.new(StandardError) belongs_to :project @@ -377,6 +379,10 @@ class ContainerRepository < ApplicationRecord migration_retries_count >= ContainerRegistry::Migration.max_retries - 1 end + def migrated? + MIGRATION_PHASE_1_ENDED_AT < self.created_at || import_done? + end + def last_import_step_done_at [migration_pre_import_done_at, migration_import_done_at, migration_aborted_at, migration_skipped_at].compact.max end @@ -427,6 +433,32 @@ class ContainerRepository < ApplicationRecord end end + def each_tags_page(page_size: 100, &block) + raise ArgumentError, 'not a migrated repository' unless migrated? + raise ArgumentError, 'block not given' unless block + + # dummy uri to initialize the loop + next_page_uri = URI('') + page_count = 0 + + while next_page_uri && page_count < MAX_TAGS_PAGES + last = Rack::Utils.parse_nested_query(next_page_uri.query)['last'] + current_page = gitlab_api_client.tags(self.path, page_size: page_size, last: last) + + if current_page&.key?(:response_body) + yield transform_tags_page(current_page[:response_body]) + next_page_uri = current_page.dig(:pagination, :next, :uri) + else + # no current page. Break the loop + next_page_uri = nil + end + + page_count += 1 + end + + raise 'too many pages requested' if page_count >= MAX_TAGS_PAGES + end + def tags_count return 0 unless manifest && manifest['tags'] @@ -550,7 +582,7 @@ class ContainerRepository < ApplicationRecord def self.find_by_path(path) self.find_by(project: path.repository_project, - name: path.repository_name) + name: path.repository_name) end private @@ -559,6 +591,16 @@ class ContainerRepository < ApplicationRecord self.migration_skipped_reason = reason finish_import end + + def transform_tags_page(tags_response_body) + return [] unless tags_response_body + + tags_response_body.map do |raw_tag| + tag = ContainerRegistry::Tag.new(self, raw_tag['name']) + tag.force_created_at_from_iso8601(raw_tag['created_at']) + tag + end + end end ContainerRepository.prepend_mod_with('ContainerRepository') diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 09fbb93525b..625d68925c6 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -22,7 +22,7 @@ class CustomEmoji < ApplicationRecord presence: true, length: { maximum: 36 }, - format: { with: /\A#{NAME_REGEXP}\z/ } + format: { with: /\A#{NAME_REGEXP}\z/o } scope :by_name, -> (names) { where(name: names) } diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index 0f13c45b84d..f6455da890b 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -29,6 +29,12 @@ class CustomerRelations::Contact < ApplicationRecord validate :validate_email_format validate :validate_root_group + scope :order_scope_asc, ->(field) { order(arel_table[field].asc.nulls_last) } + scope :order_scope_desc, ->(field) { order(arel_table[field].desc.nulls_last) } + + scope :order_by_organization_asc, -> { includes(:organization).order("customer_relations_organizations.name ASC NULLS LAST") } + scope :order_by_organization_desc, -> { includes(:organization).order("customer_relations_organizations.name DESC NULLS LAST") } + def self.reference_prefix '[contact:' end @@ -56,6 +62,22 @@ class CustomerRelations::Contact < ApplicationRecord where(state: state) end + def self.sort_by_field(field, direction) + if direction == :asc + order_scope_asc(field) + else + order_scope_desc(field) + end + end + + def self.sort_by_organization(direction) + if direction == :asc + order_by_organization_asc + else + order_by_organization_desc + end + end + def self.sort_by_name order(Gitlab::Pagination::Keyset::Order.build([ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( @@ -115,6 +137,10 @@ class CustomerRelations::Contact < ApplicationRecord where(group: group).update_all(group_id: group.root_ancestor.id) end + def self.counts_by_state + group(:state).count + end + private def validate_email_format diff --git a/app/models/customer_relations/contact_state_counts.rb b/app/models/customer_relations/contact_state_counts.rb new file mode 100644 index 00000000000..31c95e166bb --- /dev/null +++ b/app/models/customer_relations/contact_state_counts.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module CustomerRelations + class ContactStateCounts + include Gitlab::Utils::StrongMemoize + + attr_reader :group + + def self.declarative_policy_class + 'CustomerRelations::ContactPolicy' + end + + def initialize(current_user, group, params) + @current_user = current_user + @group = group + @params = params + end + + # Define method for each state + ::CustomerRelations::Contact.states.each_key do |state| + define_method(state) { counts[state] } + end + + def all + counts.values.sum + end + + private + + attr_reader :current_user, :params + + def counts + strong_memoize(:counts) do + Hash.new(0).merge(counts_by_state) + end + end + + def counts_by_state + ::Crm::ContactsFinder.counts_by_state(current_user, params.merge({ group: group })) + end + end +end diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index 4ed38f578ee..94ac2405f61 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -40,6 +40,10 @@ class DeployKey < Key super || User.ghost end + def audit_details + title + end + def has_access_to?(project) deploy_keys_project_for(project).present? end @@ -62,4 +66,9 @@ class DeployKey < Key query end + + # This is used for the internal logic of AuditEvents::BuildService. + def impersonated? + false + end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index c25ba6f9268..a3213a59bed 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -206,11 +206,6 @@ class Deployment < ApplicationRecord end end - def self.distinct_on_environment - order('environment_id, deployments.id DESC') - .select('DISTINCT ON (environment_id) deployments.*') - end - def self.find_successful_deployment!(iid) success.find_by!(iid: iid) end @@ -438,7 +433,7 @@ class Deployment < ApplicationRecord def tier_in_yaml return unless deployable - deployable.environment_deployment_tier + deployable.environment_tier_from_options end private diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb index feb1bf5438c..317399e780a 100644 --- a/app/models/design_management/design.rb +++ b/app/models/design_management/design.rb @@ -28,8 +28,8 @@ module DesignManagement has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_internal_id :iid, scope: :project, presence: true, - hook_names: %i[create update], # Deal with old records - track_if: -> { !importing? } + hook_names: %i[create update], # Deal with old records + track_if: -> { !importing? } validates :project, :filename, presence: true validates :issue, presence: true, unless: :importing? diff --git a/app/models/design_management/design_action.rb b/app/models/design_management/design_action.rb index 43dcce545d2..eae470a1ae2 100644 --- a/app/models/design_management/design_action.rb +++ b/app/models/design_management/design_action.rb @@ -21,7 +21,7 @@ module DesignManagement validates :action, presence: true, inclusion: { in: EVENT_FOR_GITALY_ACTION.keys } validates :content, absence: { if: :forbids_content?, - message: 'this action forbids content' }, + message: 'this action forbids content' }, presence: { if: :needs_content?, message: 'this action needs content' } diff --git a/app/models/environment.rb b/app/models/environment.rb index 68540ce0f5c..1950431446b 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -26,12 +26,11 @@ class Environment < ApplicationRecord has_many :self_managed_prometheus_alert_events, inverse_of: :environment has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment + # NOTE: If you preload multiple last deployments of environments, use Preloaders::Environments::DeploymentPreloader. has_one :last_deployment, -> { success.ordered }, 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: true - has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: true + has_one :last_visible_deployment, -> { visible.order(id: :desc) }, inverse_of: :environment, class_name: 'Deployment' - has_one :upcoming_deployment, -> { upcoming.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment + has_one :upcoming_deployment, -> { upcoming.order(id: :desc) }, 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 before_validation :generate_slug, if: ->(env) { env.slug.blank? } @@ -56,8 +55,9 @@ class Environment < ApplicationRecord validates :external_url, length: { maximum: 255 }, - allow_nil: true, - addressable_url: true + allow_nil: true + + validate :safe_external_url delegate :manual_actions, :other_manual_actions, to: :last_deployment, allow_nil: true delegate :auto_rollback_enabled?, to: :project @@ -215,28 +215,11 @@ class Environment < ApplicationRecord deployable_id: last_deployment_pipeline.latest_builds.pluck(:id)) end - # NOTE: Below assocation overrides is a workaround for issue https://gitlab.com/gitlab-org/gitlab/-/issues/339908 - # It helps to avoid cross joins with the CI database. - # Caveat: It also overrides and losses the default AR caching mechanism. - # Read - https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68870#note_677227727 - - # NOTE: Association Preloads does not use the overriden definitions below. - # Association Preloads when preloading uses the original definitions from the relationships above. - # https://github.com/rails/rails/blob/75ac626c4e21129d8296d4206a1960563cc3d4aa/activerecord/lib/active_record/associations/preloader.rb#L158 - # But after preloading, when they are called it is using the overriden methods below. - # So we are checking for `association_cached?(:association_name)` in the overridden methods and calling `super` which inturn fetches the preloaded values. - - # Overriding association def last_visible_deployable - 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) - last_visible_deployable&.pipeline end @@ -252,7 +235,6 @@ class Environment < ApplicationRecord Gitlab::Ci::Variables::Collection.new .append(key: 'CI_ENVIRONMENT_NAME', value: name) .append(key: 'CI_ENVIRONMENT_SLUG', value: slug) - .append(key: 'CI_ENVIRONMENT_TIER', value: tier) end def recently_updated_on_branch?(ref) @@ -329,11 +311,7 @@ class Environment < ApplicationRecord end def last_deployment_group - if ::Feature.enabled?(:batch_load_environment_last_deployment_group, project) - Deployment.last_deployment_group_for_environment(self) - else - legacy_last_deployment_group - end + Deployment.last_deployment_group_for_environment(self) end def reset_auto_stop @@ -493,6 +471,22 @@ class Environment < ApplicationRecord private + # We deliberately avoid using AddressableUrlValidator to allow users to update their environments even if they have + # misconfigured `environment:url` keyword. The external URL is presented as a clickable link on UI and not consumed + # in GitLab internally, thus we sanitize the URL before the persistence to make sure the rendered link is XSS safe. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/337417 + def safe_external_url + return unless self.external_url.present? + + new_external_url = Addressable::URI.parse(self.external_url) + + if Gitlab::Utils::SanitizeNodeLink::UNSAFE_PROTOCOLS.include?(new_external_url.normalized_scheme) + errors.add(:external_url, "#{new_external_url.normalized_scheme} scheme is not allowed") + end + rescue Addressable::URI::InvalidURIError + errors.add(:external_url, 'URI is invalid') + end + def rollout_status_available? has_terminals? end diff --git a/app/models/event.rb b/app/models/event.rb index 7760be3e817..a20ca0dc423 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -14,18 +14,18 @@ class Event < ApplicationRecord default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope ACTIONS = HashWithIndifferentAccess.new( - created: 1, - updated: 2, - closed: 3, - reopened: 4, - pushed: 5, - commented: 6, - merged: 7, - joined: 8, # User joined project - left: 9, # User left project - destroyed: 10, - expired: 11, # User left project due to expiry - approved: 12 + created: 1, + updated: 2, + closed: 3, + reopened: 4, + pushed: 5, + commented: 6, + merged: 7, + joined: 8, # User joined project + left: 9, # User left project + destroyed: 10, + expired: 11, # User left project due to expiry + approved: 12 ).freeze private_constant :ACTIONS @@ -36,15 +36,15 @@ class Event < ApplicationRecord ISSUE_ACTIONS = [:created, :updated, :closed, :reopened].freeze TARGET_TYPES = HashWithIndifferentAccess.new( - issue: Issue, - milestone: Milestone, - merge_request: MergeRequest, - note: Note, - project: Project, - snippet: Snippet, - user: User, - wiki: WikiPage::Meta, - design: DesignManagement::Design + issue: Issue, + milestone: Milestone, + merge_request: MergeRequest, + note: Note, + project: Project, + snippet: Snippet, + user: User, + wiki: WikiPage::Meta, + design: DesignManagement::Design ).freeze RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour @@ -216,6 +216,10 @@ class Event < ApplicationRecord target_type == 'DesignManagement::Design' end + def work_item? + target_type == 'WorkItem' + end + def milestone target if milestone? end @@ -399,7 +403,8 @@ class Event < ApplicationRecord read_milestone: %i[milestone?], read_wiki: %i[wiki_page?], read_design: %i[design_note? design?], - read_note: %i[note?] + read_note: %i[note?], + read_work_item: %i[work_item?] } end diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index a56e28859c9..2db074e733e 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -21,7 +21,7 @@ class GpgKey < ApplicationRecord presence: true, uniqueness: true, format: { - with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX})(?!#{KEY_SUFFIX}).)+#{KEY_SUFFIX}\Z/m, + with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX})(?!#{KEY_SUFFIX}).)+#{KEY_SUFFIX}\Z/mo, message: "is invalid. A valid public GPG key begins with '#{KEY_PREFIX}' and ends with '#{KEY_SUFFIX}'" } diff --git a/app/models/grafana_integration.rb b/app/models/grafana_integration.rb index 0358e37c58b..5cd5aa1b085 100644 --- a/app/models/grafana_integration.rb +++ b/app/models/grafana_integration.rb @@ -4,9 +4,9 @@ class GrafanaIntegration < ApplicationRecord belongs_to :project attr_encrypted :token, - mode: :per_attribute_iv, + mode: :per_attribute_iv, algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_32 + key: Settings.attr_encrypted_db_key_base_32 before_validation :check_token_changes diff --git a/app/models/group.rb b/app/models/group.rb index 6d8f8bd7613..55455d85531 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -149,7 +149,7 @@ class Group < Namespace add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption) ? :optional : :required }, - prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX + prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX after_create :post_create_hook after_destroy :post_destroy_hook @@ -176,6 +176,16 @@ class Group < Namespace .where(project_authorizations: { user_id: user_ids }) end + scope :project_creation_allowed, -> do + permitted_levels = [ + ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS, + ::Gitlab::Access::MAINTAINER_PROJECT_ACCESS, + nil + ] + + where(project_creation_level: permitted_levels) + end + class << self def sort_by_attribute(method) if method == 'storage_size_desc' @@ -855,6 +865,14 @@ class Group < Namespace feature_flag_enabled_for_self_or_ancestor?(:work_items) end + def work_items_mvc_2_feature_flag_enabled? + feature_flag_enabled_for_self_or_ancestor?(:work_items_mvc_2) + end + + def work_items_create_from_markdown_feature_flag_enabled? + feature_flag_enabled_for_self_or_ancestor?(:work_items_create_from_markdown) + end + # Check for enabled features, similar to `Project#feature_available?` # NOTE: We still want to keep this after removing `Namespace#feature_available?`. override :feature_available? diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb index a70110c4076..8dd245a6ab5 100644 --- a/app/models/group_group_link.rb +++ b/app/models/group_group_link.rb @@ -14,6 +14,23 @@ class GroupGroupLink < ApplicationRecord presence: true scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) } + + scope :with_owner_or_maintainer_access, -> do + where(group_access: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER]) + end + + scope :groups_accessible_via, -> (shared_with_group_ids) do + links = where(shared_with_group_id: shared_with_group_ids) + # a group share also gives you access to the descendants of the group being shared, + # so we must include the descendants as well in the result. + Group.id_in(links.select(:shared_group_id)).self_and_descendants + end + + scope :groups_having_access_to, -> (shared_group_ids) do + links = where(shared_group_id: shared_group_ids) + Group.id_in(links.select(:shared_with_group_id)) + end + scope :preload_shared_with_groups, -> { preload(:shared_with_group) } scope :distinct_on_shared_with_group_id_with_group_access, -> do diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index f428d07cd7f..84ee23d77ce 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -12,14 +12,14 @@ class WebHook < ApplicationRecord BACKOFF_GROWTH_FACTOR = 2.0 attr_encrypted :token, - mode: :per_attribute_iv, + mode: :per_attribute_iv, algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_32 + key: Settings.attr_encrypted_db_key_base_32 attr_encrypted :url, - mode: :per_attribute_iv, + mode: :per_attribute_iv, algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_32 + key: Settings.attr_encrypted_db_key_base_32 attr_encrypted :url_variables, mode: :per_attribute_iv, @@ -57,14 +57,14 @@ class WebHook < ApplicationRecord !temporarily_disabled? && !permanently_disabled? end - def temporarily_disabled?(ignore_flag: false) - return false unless ignore_flag || web_hooks_disable_failed? + def temporarily_disabled? + return false unless web_hooks_disable_failed? disabled_until.present? && disabled_until >= Time.current end - def permanently_disabled?(ignore_flag: false) - return false unless ignore_flag || web_hooks_disable_failed? + def permanently_disabled? + return false unless web_hooks_disable_failed? recent_failures > FAILURE_THRESHOLD end @@ -126,13 +126,6 @@ class WebHook < ApplicationRecord save(validate: false) end - def active_state(ignore_flag: false) - return :permanently_disabled if permanently_disabled?(ignore_flag: ignore_flag) - return :temporarily_disabled if temporarily_disabled?(ignore_flag: ignore_flag) - - :enabled - end - # @return [Boolean] Whether or not the WebHook is currently throttled. def rate_limited? rate_limiter.rate_limited? diff --git a/app/models/integration.rb b/app/models/integration.rb index f5f701662e7..6d755016380 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -21,7 +21,7 @@ class Integration < ApplicationRecord asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat harbor irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email - pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao + pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao ].freeze # TODO Shimo is temporary disabled on group and instance-levels. @@ -48,6 +48,9 @@ class Integration < ApplicationRecord SECTION_TYPE_CONNECTION = 'connection' SECTION_TYPE_TRIGGER = 'trigger' + SNOWPLOW_EVENT_ACTION = 'perform_integrations_action' + SNOWPLOW_EVENT_LABEL = 'redis_hll_counters.ecosystem.ecosystem_total_unique_counts_monthly' + attr_encrypted :properties, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_32, @@ -89,7 +92,6 @@ class Integration < ApplicationRecord belongs_to :project, inverse_of: :integrations belongs_to :group, inverse_of: :integrations - has_one :service_hook, inverse_of: :integration, foreign_key: :service_id validates :project_id, presence: true, unless: -> { instance_level? || group_level? } validates :group_id, presence: true, unless: -> { instance_level? || project_level? } diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 230dc6bb336..c3a4b84bb2d 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -63,11 +63,11 @@ module Integrations end def build_page(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:build_page] } + with_reactive_cache(sha, ref) { |cached| cached[:build_page] } end def commit_status(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + with_reactive_cache(sha, ref) { |cached| cached[:commit_status] } end def execute(data) diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index fe4a2f43b13..a4cec5f927b 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -100,8 +100,8 @@ module Integrations message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" result = true end - rescue Gitlab::HTTP::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error - message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}" + rescue Gitlab::HTTP::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => e + message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{e.message}" end log_info(message) result diff --git a/app/models/integrations/base_slash_commands.rb b/app/models/integrations/base_slash_commands.rb index a0ac5474893..e51d748b562 100644 --- a/app/models/integrations/base_slash_commands.rb +++ b/app/models/integrations/base_slash_commands.rb @@ -8,7 +8,7 @@ module Integrations prop_accessor :token - has_many :chat_names, foreign_key: :service_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :chat_names, foreign_key: :integration_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent def valid_token?(token) self.respond_to?(:token) && diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb index def646c6d49..7a48e71b934 100644 --- a/app/models/integrations/buildkite.rb +++ b/app/models/integrations/buildkite.rb @@ -60,7 +60,7 @@ module Integrations end def commit_status(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + with_reactive_cache(sha, ref) { |cached| cached[:commit_status] } end def commit_status_path(sha) diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index 97e586c0662..bb0fb6b9079 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -15,75 +15,7 @@ module Integrations TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze - field :datadog_site, - placeholder: DEFAULT_DOMAIN, - help: -> do - ERB::Util.html_escape( - s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe - } - end - - field :api_url, - title: -> { s_('DatadogIntegration|API URL') }, - help: -> { s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.') } - - field :api_key, - type: 'password', - title: -> { _('API key') }, - non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, - non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key') }, - help: -> do - ERB::Util.html_escape( - s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') - ) % { - linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, - linkClose: '</a>'.html_safe - } - end, - required: true - - field :archive_trace_events, - type: 'checkbox', - title: -> { s_('Logs') }, - checkbox_label: -> { s_('Enable logs collection') }, - help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') } - - field :datadog_service, - title: -> { s_('DatadogIntegration|Service') }, - placeholder: 'gitlab-ci', - help: -> { s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') } - - field :datadog_env, - title: -> { s_('DatadogIntegration|Environment') }, - placeholder: 'ci', - help: -> do - ERB::Util.html_escape( - s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe, - linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, - linkClose: '</a>'.html_safe - } - end - - field :datadog_tags, - type: 'textarea', - title: -> { s_('DatadogIntegration|Tags') }, - placeholder: "tag:value\nanother_tag:value", - help: -> do - ERB::Util.html_escape( - s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') - ) % { - codeOpen: '<code>'.html_safe, - codeClose: '</code>'.html_safe, - linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, - linkClose: '</a>'.html_safe - } - end + prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env, :datadog_tags before_validation :strip_properties @@ -145,11 +77,92 @@ module Integrations end def fields + f = [ + { + type: 'text', + name: 'datadog_site', + placeholder: DEFAULT_DOMAIN, + help: ERB::Util.html_escape( + s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe + }, + required: false + }, + { + type: 'text', + name: 'api_url', + title: s_('DatadogIntegration|API URL'), + help: s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.'), + required: false + }, + { + type: 'password', + name: 'api_key', + title: _('API key'), + non_empty_password_title: s_('ProjectService|Enter new API key'), + non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'), + help: ERB::Util.html_escape( + s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') + ) % { + linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe, + linkClose: '</a>'.html_safe + }, + required: true + } + ] + if Feature.enabled?(:datadog_integration_logs_collection, parent) - super - else - super.reject { _1.name == 'archive_trace_events' } + f.append({ + type: 'checkbox', + name: 'archive_trace_events', + title: s_('Logs'), + checkbox_label: s_('Enable logs collection'), + help: s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.'), + required: false + }) end + + f += [ + { + type: 'text', + name: 'datadog_service', + title: s_('DatadogIntegration|Service'), + placeholder: 'gitlab-ci', + help: s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') + }, + { + type: 'text', + name: 'datadog_env', + title: s_('DatadogIntegration|Environment'), + placeholder: 'ci', + help: ERB::Util.html_escape( + s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe, + linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, + linkClose: '</a>'.html_safe + } + }, + { + type: 'textarea', + name: 'datadog_tags', + title: s_('DatadogIntegration|Tags'), + placeholder: "tag:value\nanother_tag:value", + help: ERB::Util.html_escape( + s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe, + linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, + linkClose: '</a>'.html_safe + } + } + ] + + f end override :hook_url diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index ecabf23c90b..ec8a12e4760 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -33,10 +33,21 @@ module Integrations def default_fields [ - { type: "text", name: "webhook", placeholder: "https://discordapp.com/api/webhooks/…", help: "URL to the webhook for the Discord channel." }, - { type: "checkbox", name: "notify_only_broken_pipelines" }, + { + type: 'text', + section: SECTION_TYPE_CONNECTION, + name: 'webhook', + placeholder: 'https://discordapp.com/api/webhooks/…', + help: 'URL to the webhook for the Discord channel.' + }, + { + type: 'checkbox', + section: SECTION_TYPE_CONFIGURATION, + name: 'notify_only_broken_pipelines' + }, { type: 'select', + section: SECTION_TYPE_CONFIGURATION, name: 'branches_to_be_notified', title: s_('Integrations|Branches for which notifications are to be sent'), choices: self.class.branch_choices @@ -44,6 +55,26 @@ module Integrations ] end + def sections + [ + { + type: SECTION_TYPE_CONNECTION, + title: s_('Integrations|Connection details'), + description: help + }, + { + type: SECTION_TYPE_TRIGGER, + title: s_('Integrations|Trigger'), + description: s_('Integrations|An event will be triggered when one of the following items happen.') + }, + { + type: SECTION_TYPE_CONFIGURATION, + title: s_('Integrations|Notification settings'), + description: s_('Integrations|Configure the scope of notifications.') + } + ] + end + private def notify(message, opts) @@ -57,8 +88,8 @@ module Integrations embed.timestamp = Time.now.utc end end - rescue RestClient::Exception => error - log_error(error.message) + rescue RestClient::Exception => e + log_error(e.message) false end diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb index ed12a3a8d63..25bda8c2bf0 100644 --- a/app/models/integrations/emails_on_push.rb +++ b/app/models/integrations/emails_on_push.rb @@ -71,7 +71,7 @@ module Integrations recipients, push_data, send_from_committer_email: send_from_committer_email?, - disable_diffs: disable_diffs? + disable_diffs: disable_diffs? ) end diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb index bc2ea193a84..75fe6b6f164 100644 --- a/app/models/integrations/external_wiki.rb +++ b/app/models/integrations/external_wiki.rb @@ -5,6 +5,7 @@ module Integrations validates :external_wiki_url, presence: true, public_url: true, if: :activated? field :external_wiki_url, + section: SECTION_TYPE_CONNECTION, title: -> { s_('ExternalWikiService|External wiki URL') }, placeholder: -> { s_('ExternalWikiService|https://example.com/xxx/wiki/...') }, help: -> { s_('ExternalWikiService|Enter the URL to the external wiki.') }, @@ -28,6 +29,16 @@ module Integrations s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end + def sections + [ + { + type: SECTION_TYPE_CONNECTION, + title: s_('Integrations|Connection details'), + description: help + } + ] + end + def execute(_data) response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index 82981493822..03913a71d47 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'uri' module Integrations class Harbor < Integration @@ -20,7 +21,7 @@ module Integrations end def help - s_("HarborIntegration|After the Harbor integration is activated, global variables ‘$HARBOR_USERNAME’, ‘$HARBOR_PASSWORD’, ‘$HARBOR_URL’ and ‘$HARBOR_PROJECT’ will be created for CI/CD use.") + s_("HarborIntegration|After the Harbor integration is activated, global variables '$HARBOR_USERNAME', '$HARBOR_HOST', '$HARBOR_OCI', '$HARBOR_PASSWORD', '$HARBOR_URL' and '$HARBOR_PROJECT' will be created for CI/CD use.") end class << self @@ -78,8 +79,12 @@ module Integrations def ci_variables return [] unless activated? + oci_uri = URI.parse(url) + oci_uri.scheme = 'oci' [ { key: 'HARBOR_URL', value: url }, + { key: 'HARBOR_HOST', value: oci_uri.host }, + { key: 'HARBOR_OCI', value: oci_uri.to_s }, { key: 'HARBOR_PROJECT', value: project_name }, { key: 'HARBOR_USERNAME', value: username.gsub(/^robot\$/, 'robot$$') }, { key: 'HARBOR_PASSWORD', value: password, public: false, masked: true } diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb index ab39d1f7b77..c68b5fd2a96 100644 --- a/app/models/integrations/jenkins.rb +++ b/app/models/integrations/jenkins.rb @@ -53,8 +53,8 @@ module Integrations begin result = execute(data) return { success: false, result: result[:message] } if result[:http_status] != 200 - rescue StandardError => error - return { success: false, result: error } + rescue StandardError => e + return { success: false, result: e } end { success: true, result: result[:message] } diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 566bbc456f8..3ca514ab1fd 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -18,6 +18,8 @@ module Integrations SECTION_TYPE_JIRA_TRIGGER = 'jira_trigger' SECTION_TYPE_JIRA_ISSUES = 'jira_issues' + SNOWPLOW_EVENT_CATEGORY = self.name + validates :url, public_url: true, presence: true, if: :activated? validates :api_url, public_url: true, allow_blank: true validates :username, presence: true, if: :activated? @@ -362,13 +364,17 @@ module Integrations ) true - rescue StandardError => error - log_exception(error, message: 'Issue transition failed', client_url: client_url) + rescue StandardError => e + log_exception(e, message: 'Issue transition failed', client_url: client_url) false end def transition_issue_to_done(issue) - transitions = issue.transitions rescue [] + transitions = begin + issue.transitions + rescue StandardError + [] + end transition = transitions.find do |transition| status = transition&.to&.statusCategory @@ -384,6 +390,22 @@ module Integrations key = "i_ecosystem_jira_service_#{action}" Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user.id) + + return unless Feature.enabled?(:route_hll_to_snowplow_phase2) + + optional_arguments = { + project: project, + namespace: group || project&.namespace + }.compact + + Gitlab::Tracking.event( + SNOWPLOW_EVENT_CATEGORY, + Integration::SNOWPLOW_EVENT_ACTION, + label: Integration::SNOWPLOW_EVENT_LABEL, + property: key, + user: user, + **optional_arguments + ) end def add_issue_solved_comment(issue, commit_id, commit_url) @@ -505,7 +527,7 @@ module Integrations self.project, entity_type.to_sym ], - id: entity_id, + id: entity_id, host: Settings.gitlab.base_url ) end @@ -538,9 +560,9 @@ module Integrations # Handle errors when doing Jira API calls def jira_request yield - rescue StandardError => error - @error = error - log_exception(error, message: 'Error sending message', client_url: client_url) + rescue StandardError => e + @error = e + log_exception(e, message: 'Error sending message', client_url: client_url) nil end diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb index fda4822c19f..f91404dab23 100644 --- a/app/models/integrations/packagist.rb +++ b/app/models/integrations/packagist.rb @@ -6,14 +6,14 @@ module Integrations extend Gitlab::Utils::Override field :username, - title: -> { _('Username') }, + title: -> { s_('Username') }, help: -> { s_('Enter your Packagist username.') }, placeholder: '', required: true field :token, type: 'password', - title: -> { _('Token') }, + title: -> { s_('Token') }, help: -> { s_('Enter your Packagist token.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, @@ -21,10 +21,11 @@ module Integrations required: true field :server, - title: -> { _('Server (optional)') }, + title: -> { s_('Server (optional)') }, help: -> { s_('Enter your Packagist server. Defaults to https://packagist.org.') }, placeholder: 'https://packagist.org', - exposes_secrets: true + exposes_secrets: true, + required: false validates :username, presence: true, if: :activated? validates :token, presence: true, if: :activated? @@ -55,8 +56,8 @@ module Integrations begin result = execute(data) return { success: false, result: result[:message] } if result[:http_status] != 202 - rescue StandardError => error - return { success: false, result: error } + rescue StandardError => e + return { success: false, result: e } end { success: true, result: result[:message] } diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb index 77cbba25f2c..55a8ce0be11 100644 --- a/app/models/integrations/pipelines_email.rb +++ b/app/models/integrations/pipelines_email.rb @@ -84,8 +84,8 @@ module Integrations result = execute(data, force: true) { success: true, result: result } - rescue StandardError => error - { success: false, result: error } + rescue StandardError => e + { success: false, result: e } end def should_pipeline_be_notified?(data) diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb index e672a985810..142f466018b 100644 --- a/app/models/integrations/prometheus.rb +++ b/app/models/integrations/prometheus.rb @@ -70,8 +70,8 @@ module Integrations prometheus_client.ping { success: true, result: 'Checked API endpoint' } - rescue Gitlab::PrometheusClient::Error => err - { success: false, result: err } + rescue Gitlab::PrometheusClient::Error => e + { success: false, result: e } end def prometheus_client diff --git a/app/models/integrations/pumble.rb b/app/models/integrations/pumble.rb new file mode 100644 index 00000000000..17026410eb1 --- /dev/null +++ b/app/models/integrations/pumble.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Integrations + class Pumble < BaseChatNotification + def title + 'Pumble' + end + + def description + s_("PumbleIntegration|Send notifications about project events to Pumble.") + end + + def self.to_param + 'pumble' + end + + def help + docs_link = ActionController::Base.helpers.link_to( + _('Learn more.'), + Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pumble'), + target: '_blank', + rel: 'noopener noreferrer' + ) + # rubocop:disable Layout/LineLength + s_("PumbleIntegration|Send notifications about project events to Pumble. %{docs_link}") % { docs_link: docs_link.html_safe } + # rubocop:enable Layout/LineLength + end + + def default_channel_placeholder + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "https://api.pumble.com/workspaces/x/...", required: true }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { + type: 'select', + name: 'branches_to_be_notified', + title: s_('Integrations|Branches for which notifications are to be sent'), + choices: self.class.branch_choices + } + ] + end + + private + + def notify(message, opts) + header = { 'Content-Type' => 'application/json' } + response = Gitlab::HTTP.post(webhook, headers: header, body: { text: message.summary }.to_json) + + response if response.success? + end + end +end diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb index 93263229109..c254ea379bb 100644 --- a/app/models/integrations/slack.rb +++ b/app/models/integrations/slack.rb @@ -9,6 +9,7 @@ module Integrations push issue confidential_issue merge_request note confidential_note tag_push wiki_page deployment ].freeze + SNOWPLOW_EVENT_CATEGORY = self.name prop_accessor EVENT_CHANNEL['alert'] @@ -54,6 +55,22 @@ module Integrations key = "i_ecosystem_slack_service_#{event}_notification" Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id) + + return unless Feature.enabled?(:route_hll_to_snowplow_phase2) + + optional_arguments = { + project: project, + namespace: group || project&.namespace + }.compact + + Gitlab::Tracking.event( + SNOWPLOW_EVENT_CATEGORY, + Integration::SNOWPLOW_EVENT_ACTION, + label: Integration::SNOWPLOW_EVENT_LABEL, + property: key, + user: User.find(user_id), + **optional_arguments + ) end override :configurable_channels? diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb index e0299c9ac5f..ca7a715f4b3 100644 --- a/app/models/integrations/teamcity.rb +++ b/app/models/integrations/teamcity.rb @@ -67,11 +67,11 @@ module Integrations end def build_page(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:build_page] } + with_reactive_cache(sha, ref) { |cached| cached[:build_page] } end def commit_status(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + with_reactive_cache(sha, ref) { |cached| cached[:commit_status] } end def calculate_reactive_cache(sha, ref) diff --git a/app/models/issuable_severity.rb b/app/models/issuable_severity.rb index 928301e1da6..cd7e5fafb60 100644 --- a/app/models/issuable_severity.rb +++ b/app/models/issuable_severity.rb @@ -3,18 +3,18 @@ class IssuableSeverity < ApplicationRecord DEFAULT = 'unknown' SEVERITY_LABELS = { - unknown: 'Unknown', - low: 'Low - S4', - medium: 'Medium - S3', - high: 'High - S2', + unknown: 'Unknown', + low: 'Low - S4', + medium: 'Medium - S3', + high: 'High - S2', critical: 'Critical - S1' }.freeze SEVERITY_QUICK_ACTION_PARAMS = { - unknown: %w(Unknown 0), - low: %w(Low S4 4), - medium: %w(Medium S3 3), - high: %w(High S2 2), + unknown: %w(Unknown 0), + low: %w(Low S4 4), + medium: %w(Medium S3 3), + high: %w(High S2 2), critical: %w(Critical S1 1) }.freeze diff --git a/app/models/issue.rb b/app/models/issue.rb index cae42115bef..4114467eb25 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -99,6 +99,10 @@ class Issue < ApplicationRecord validates :project, presence: true validates :issue_type, presence: true validates :namespace, presence: true, if: -> { project.present? } + validates :work_item_type, presence: true + + validate :due_date_after_start_date + validate :parent_link_confidentiality enum issue_type: WorkItems::Type.base_types @@ -201,7 +205,7 @@ class Issue < ApplicationRecord scope :with_null_relative_position, -> { where(relative_position: nil) } scope :with_non_null_relative_position, -> { where.not(relative_position: nil) } - before_validation :ensure_namespace_id + before_validation :ensure_namespace_id, :ensure_work_item_type after_commit :expire_etag_cache, unless: :importing? after_save :ensure_metrics, unless: :importing? @@ -257,17 +261,17 @@ class Issue < ApplicationRecord order = ::Gitlab::Pagination::Keyset::Order.build([ ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: attribute_name, - column_expression: column, - order_expression: column.send(direction).send(nullable), - reversed_order_expression: column.send(reversed_direction).send(nullable), - order_direction: direction, - distinct: false, - add_to_projections: true, - nullable: nullable + column_expression: column, + order_expression: column.send(direction).send(nullable), + reversed_order_expression: column.send(reversed_direction).send(nullable), + order_direction: direction, + distinct: false, + add_to_projections: true, + nullable: nullable ), ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'id', - order_expression: arel_table['id'].desc + order_expression: arel_table['id'].desc ) ]) # rubocop: enable GitlabSecurity/PublicSend @@ -289,6 +293,16 @@ class Issue < ApplicationRecord def pg_full_text_search(search_term) super.where('issue_search_data.project_id = issues.project_id') end + + override :full_search + def full_search(query, matched_columns: nil, use_minimum_char_limit: true) + return super if query.match?(IssuableFinder::FULL_TEXT_SEARCH_TERM_REGEX) + + super.where( + 'issues.title NOT SIMILAR TO :pattern OR issues.description NOT SIMILAR TO :pattern', + pattern: IssuableFinder::FULL_TEXT_SEARCH_TERM_PATTERN + ) + end end def next_object_by_relative_position(ignoring: nil, order: :asc) @@ -660,6 +674,29 @@ class Issue < ApplicationRecord private + def due_date_after_start_date + return unless start_date.present? && due_date.present? + + if due_date < start_date + errors.add(:due_date, 'must be greater than or equal to start date') + end + end + + # Although parent/child relationship can be set only for WorkItems, we + # still need to validate it for Issue model too, because both models use + # same table. + def parent_link_confidentiality + return unless persisted? + + if confidential? && WorkItems::ParentLink.has_public_children?(id) + errors.add(:confidential, _('confidential parent can not be used if there are non-confidential children.')) + end + + if !confidential? && WorkItems::ParentLink.has_confidential_parent?(id) + errors.add(:confidential, _('associated parent is confidential and can not have non-confidential children.')) + end + end + override :persist_pg_full_text_search_vector def persist_pg_full_text_search_vector(search_vector) Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id)) @@ -696,6 +733,12 @@ class Issue < ApplicationRecord def ensure_namespace_id self.namespace = project.project_namespace if project end + + def ensure_work_item_type + return if work_item_type_id.present? || work_item_type_id_change&.last.present? + + self.work_item_type = WorkItems::Type.default_by_type(issue_type) + end end Issue.prepend_mod_with('Issue') diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb index e34543534f3..8befe9a9230 100644 --- a/app/models/jira_connect_installation.rb +++ b/app/models/jira_connect_installation.rb @@ -2,9 +2,9 @@ class JiraConnectInstallation < ApplicationRecord attr_encrypted :shared_secret, - mode: :per_attribute_iv, + mode: :per_attribute_iv, algorithm: 'aes-256-gcm', - key: Settings.attr_encrypted_db_key_base_32 + key: Settings.attr_encrypted_db_key_base_32 has_many :subscriptions, class_name: 'JiraConnectSubscription' diff --git a/app/models/key.rb b/app/models/key.rb index 9f6029cc5d4..78b0a38bcaa 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -40,6 +40,7 @@ class Key < ApplicationRecord after_destroy :refresh_user_cache alias_attribute :fingerprint_md5, :fingerprint + alias_attribute :name, :title scope :preload_users, -> { preload(:user) } scope :for_user, -> (user) { where(user: user) } diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index caeffae7bda..8aa48561e60 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -15,7 +15,7 @@ class LfsObject < ApplicationRecord scope :for_oids, -> (oids) { where(oid: oids) } scope :for_oid_and_size, -> (oid, size) { find_by(oid: oid, size: size) } - validates :oid, presence: true, uniqueness: true + validates :oid, presence: true, uniqueness: true, format: { with: /\A\h{64}\z/ } mount_file_store_uploader LfsObjectUploader diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb index 6dfd6ea2aae..94444f4b6d3 100644 --- a/app/models/loose_foreign_keys/deleted_record.rb +++ b/app/models/loose_foreign_keys/deleted_record.rb @@ -9,26 +9,23 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel self.ignored_columns = %i[partition] partitioned_by :partition, strategy: :sliding_list, - next_partition_if: -> (active_partition) do - return false if Feature.disabled?(:lfk_automatic_partition_creation) - - oldest_record_in_partition = LooseForeignKeys::DeletedRecord - .select(:id, :created_at) - .for_partition(active_partition) - .order(:id) - .limit(1) - .take - - oldest_record_in_partition.present? && oldest_record_in_partition.created_at < PARTITION_DURATION.ago - end, - detach_partition_if: -> (partition) do - return false if Feature.disabled?(:lfk_automatic_partition_dropping) - - !LooseForeignKeys::DeletedRecord - .for_partition(partition) - .status_pending - .exists? - end + next_partition_if: -> (active_partition) do + oldest_record_in_partition = LooseForeignKeys::DeletedRecord + .select(:id, :created_at) + .for_partition(active_partition) + .order(:id) + .limit(1) + .take + + oldest_record_in_partition.present? && + oldest_record_in_partition.created_at < PARTITION_DURATION.ago + end, + detach_partition_if: -> (partition) do + !LooseForeignKeys::DeletedRecord + .for_partition(partition) + .status_pending + .exists? + end scope :for_table, -> (table) { where(fully_qualified_table_name: table) } scope :for_partition, -> (partition) { where(partition: partition) } diff --git a/app/models/member.rb b/app/models/member.rb index dcca63b5691..0cd1e022617 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -28,6 +28,7 @@ class Member < ApplicationRecord belongs_to :user belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :member_namespace, inverse_of: :namespace_members, foreign_key: 'member_namespace_id', class_name: 'Namespace' + belongs_to :member_role has_one :member_task delegate :name, :username, :email, :last_activity_on, to: :user, prefix: true @@ -58,6 +59,7 @@ class Member < ApplicationRecord }, if: :project_bot? validate :access_level_inclusion + validate :validate_member_role_access_level scope :with_invited_user_state, -> do joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email') @@ -428,6 +430,14 @@ class Member < ApplicationRecord errors.add(:access_level, "is not included in the list") end + def validate_member_role_access_level + return unless member_role_id + + if access_level != member_role.base_access_level + errors.add(:member_role_id, _("role's base access level does not match the access level of the membership")) + end + end + def send_invite # override in subclass end @@ -455,6 +465,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 + + # This method is overridden in the test environment, see stubbed_member.rb def refresh_member_authorized_projects(blocking:) UserProjectAccessChangedService.new(user_id).execute(blocking: blocking) end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 87af6a9a7f7..2b35f7da7b4 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -7,7 +7,6 @@ class GroupMember < Member SOURCE_TYPE = 'Namespace' SOURCE_TYPE_FORMAT = /\ANamespace\z/.freeze - THRESHOLD_FOR_REFRESHING_AUTHORIZATIONS_VIA_PROJECTS = 1000 belongs_to :group, foreign_key: 'source_id' alias_attribute :namespace_id, :source_id @@ -67,28 +66,8 @@ class GroupMember < Member # its projects are also destroyed, so the removal of project_authorizations # will happen behind the scenes via DB foreign keys anyway. return if destroyed_by_association.present? - return unless user_id - return super if Feature.disabled?(:refresh_authorizations_via_affected_projects_on_group_membership, group) - # rubocop:disable CodeReuse/ServiceClass - projects_to_refresh = Groups::ProjectsRequiringAuthorizationsRefresh::OnDirectMembershipFinder.new(group).execute - threshold_exceeded = (projects_to_refresh.size > THRESHOLD_FOR_REFRESHING_AUTHORIZATIONS_VIA_PROJECTS) - - # We want to try the new approach only if the number of affected projects are greater than the set threshold. - return super unless threshold_exceeded - - AuthorizedProjectUpdate::ProjectAccessChangedService - .new(projects_to_refresh) - .execute(blocking: false) - - # Until we compare the inconsistency rates of the new approach - # 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) - - # rubocop:enable CodeReuse/ServiceClass + super end def send_invite diff --git a/app/models/members/last_group_owner_assigner.rb b/app/models/members/last_group_owner_assigner.rb index c85116858c7..e411a0ef5eb 100644 --- a/app/models/members/last_group_owner_assigner.rb +++ b/app/models/members/last_group_owner_assigner.rb @@ -8,7 +8,7 @@ class LastGroupOwnerAssigner end def execute - @last_blocked_owner = no_owners_in_heirarchy? && group.single_blocked_owner? + @last_blocked_owner = no_owners_in_hierarchy? && group.single_blocked_owner? @group_single_owner = owners.size == 1 members.each { |member| set_last_owner(member) } @@ -18,7 +18,7 @@ class LastGroupOwnerAssigner attr_reader :group, :members, :last_blocked_owner, :group_single_owner - def no_owners_in_heirarchy? + def no_owners_in_hierarchy? owners.empty? end diff --git a/app/models/members/member_role.rb b/app/models/members/member_role.rb new file mode 100644 index 00000000000..2e8532fa739 --- /dev/null +++ b/app/models/members/member_role.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass + has_many :members + belongs_to :namespace + + validates :namespace_id, presence: true + validates :base_access_level, presence: true +end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index c97f00364fd..8fd82fcb34a 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -111,7 +111,7 @@ class ProjectMember < Member # rubocop:disable CodeReuse/ServiceClass if blocking - AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.bulk_perform_and_wait([[project.id, user.id]]) + blocking_project_authorizations_refresh else AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.perform_async(project.id, user.id) end @@ -124,6 +124,11 @@ class ProjectMember < Member # rubocop:enable CodeReuse/ServiceClass end + # This method is overridden in the test environment, see stubbed_member.rb + def blocking_project_authorizations_refresh + AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.bulk_perform_and_wait([[project.id, user.id]]) + end + # TODO: https://gitlab.com/groups/gitlab-org/-/epics/7054 # temporary until we can we properly remove the source columns override :set_member_namespace_id diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index ec97ab0ea42..3c06e1aa983 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -37,23 +37,25 @@ class MergeRequest < ApplicationRecord SORTING_PREFERENCE_FIELD = :merge_requests_sort ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON = { - 'Ci::CompareMetricsReportsService' => ->(project) { true }, + 'Ci::CompareMetricsReportsService' => ->(project) { true }, 'Ci::CompareCodequalityReportsService' => ->(project) { true } }.freeze + MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS = 100 + belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" belongs_to :merge_user, class_name: "User" belongs_to :iteration, foreign_key: 'sprint_id' has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, - init: ->(mr, scope) do - if mr - mr.target_project&.merge_requests&.maximum(:iid) - elsif scope[:project] - where(target_project: scope[:project]).maximum(:iid) - end - end + init: ->(mr, scope) do + if mr + mr.target_project&.merge_requests&.maximum(:iid) + elsif scope[:project] + where(target_project: scope[:project]).maximum(:iid) + end + end has_many :merge_request_diffs, -> { regular }, inverse_of: :merge_request @@ -121,7 +123,8 @@ class MergeRequest < ApplicationRecord :force_remove_source_branch, :commit_message, :squash_commit_message, - :sha + :sha, + :skip_ci ].freeze serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize @@ -263,6 +266,7 @@ class MergeRequest < ApplicationRecord validate :validate_branches, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?] validate :validate_fork, unless: :closed_or_merged_without_fork? validate :validate_target_project, on: :create + validate :validate_reviewer_and_assignee_size_length, unless: :importing? scope :by_source_or_target_branch, ->(branch_name) do where("source_branch = :branch OR target_branch = :branch", branch: branch_name) @@ -427,8 +431,7 @@ class MergeRequest < ApplicationRecord def self.total_time_to_merge join_metrics .merge(MergeRequest::Metrics.with_valid_time_to_merge) - .pluck(MergeRequest::Metrics.time_to_merge_expression) - .first + .pick(MergeRequest::Metrics.time_to_merge_expression) end after_save :keep_around_commit, unless: :importing? @@ -927,9 +930,9 @@ class MergeRequest < ApplicationRecord # most recent data possible. def repository_diff_refs Gitlab::Diff::DiffRefs.new( - base_sha: branch_merge_base_sha, + base_sha: branch_merge_base_sha, start_sha: target_branch_sha, - head_sha: source_branch_sha + head_sha: source_branch_sha ) end @@ -992,6 +995,20 @@ class MergeRequest < ApplicationRecord 'Source project is not a fork of the target project' end + def self.max_number_of_assignees_or_reviewers_message + # Assignees will be included in https://gitlab.com/gitlab-org/gitlab/-/issues/368936 + _("total must be less than or equal to %{size}") % { size: MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS } + end + + def validate_reviewer_and_assignee_size_length + # Assigness will be added in a subsequent MR https://gitlab.com/gitlab-org/gitlab/-/issues/368936 + return true unless Feature.enabled?(:limit_reviewer_and_assignee_size) + return true unless reviewers.size > MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + + errors.add :reviewers, + -> (_object, _data) { MergeRequest.max_number_of_assignees_or_reviewers_message } + end + def merge_ongoing? # While the MergeRequest is locked, it should present itself as 'merge ongoing'. # The unlocking process is handled by StuckMergeJobsWorker scheduled in Cron. @@ -1170,17 +1187,30 @@ class MergeRequest < ApplicationRecord ] end + def detailed_merge_status + if cannot_be_merged_rechecking? || preparing? || checking? + return :checking + elsif unchecked? + return :unchecked + end + + checks = execute_merge_checks + + if checks.success? + :mergeable + else + checks.failure_reason + end + end + # rubocop: disable CodeReuse/ServiceClass def mergeable_state?(skip_ci_check: false, skip_discussions_check: false) if Feature.enabled?(:improved_mergeability_checks, self.project) - additional_checks = MergeRequests::Mergeability::RunChecksService.new( - merge_request: self, - params: { - skip_ci_check: skip_ci_check, - skip_discussions_check: skip_discussions_check - } - ) - additional_checks.execute.all?(&:success?) + additional_checks = execute_merge_checks(params: { + skip_ci_check: skip_ci_check, + skip_discussions_check: skip_discussions_check + }) + additional_checks.execute.success? else return false unless open? return false if draft? @@ -1500,14 +1530,14 @@ class MergeRequest < ApplicationRecord end def self.merge_train_ref?(ref) - %r{\Arefs/#{Repository::REF_MERGE_REQUEST}/\d+/train\z}.match?(ref) + %r{\Arefs/#{Repository::REF_MERGE_REQUEST}/\d+/train\z}o.match?(ref) end def in_locked_state lock_mr yield ensure - unlock_mr + unlock_mr if locked? end def update_and_mark_in_progress_merge_commit_sha(commit_id) @@ -1985,6 +2015,10 @@ class MergeRequest < ApplicationRecord target_branch == project.default_branch end + def merge_blocked_by_other_mrs? + false # Overridden in EE + end + private attr_accessor :skip_fetch_ref @@ -2038,6 +2072,12 @@ class MergeRequest < ApplicationRecord def report_type_enabled?(report_type) !!actual_head_pipeline&.batch_lookup_report_artifact_for_file_type(report_type) end + + def execute_merge_checks(params: {}) + # rubocop: disable CodeReuse/ServiceClass + MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: params).execute + # rubocop: enable CodeReuse/ServiceClass + end end MergeRequest.prepend_mod_with('MergeRequest') diff --git a/app/models/merge_request/approval_removal_settings.rb b/app/models/merge_request/approval_removal_settings.rb new file mode 100644 index 00000000000..b07242e2578 --- /dev/null +++ b/app/models/merge_request/approval_removal_settings.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class MergeRequest::ApprovalRemovalSettings # rubocop:disable Style/ClassAndModuleChildren + include ActiveModel::Validations + + attr_accessor :project + + validate :mutually_exclusive_settings + + def initialize(project, reset_approvals_on_push, selective_code_owner_removals) + @project = project + @reset_approvals_on_push = reset_approvals_on_push + @selective_code_owner_removals = selective_code_owner_removals + end + + private + + def selective_code_owner_removals + if @selective_code_owner_removals.nil? + project.project_setting.selective_code_owner_removals + else + @selective_code_owner_removals + end + end + + def reset_approvals_on_push + if @reset_approvals_on_push.nil? + project.reset_approvals_on_push + else + @reset_approvals_on_push + end + end + + def mutually_exclusive_settings + return unless selective_code_owner_removals && reset_approvals_on_push + + errors.add(:base, 'selective_code_owner_removals can only be enabled when reset_approvals_on_push is disabled') + end +end diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index b984228eb13..c546a5a0025 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -41,8 +41,7 @@ class MergeRequest::Metrics < ApplicationRecord def self.total_time_to_merge with_valid_time_to_merge - .pluck(time_to_merge_expression) - .first + .pick(time_to_merge_expression) end end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index e08b2cc2a7d..9f7e98dc04b 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -358,9 +358,9 @@ class MergeRequestDiff < ApplicationRecord return unless start_commit_sha || base_commit_sha Gitlab::Diff::DiffRefs.new( - base_sha: base_commit_sha, + base_sha: base_commit_sha, start_sha: start_commit_sha, - head_sha: head_commit_sha + head_sha: head_commit_sha ) end @@ -381,9 +381,9 @@ class MergeRequestDiff < ApplicationRecord likely_base_commit_sha = (first_commit&.parent || first_commit)&.sha Gitlab::Diff::DiffRefs.new( - base_sha: likely_base_commit_sha, + base_sha: likely_base_commit_sha, start_sha: safe_start_commit_sha, - head_sha: head_commit_sha + head_sha: head_commit_sha ) end @@ -706,8 +706,7 @@ class MergeRequestDiff < ApplicationRecord latest_id = MergeRequest .where(id: merge_request_id) .limit(1) - .pluck(:latest_merge_request_diff_id) - .first + .pick(:latest_merge_request_diff_id) latest_id && self.id < latest_id end diff --git a/app/models/ml.rb b/app/models/ml.rb new file mode 100644 index 00000000000..e426ce851eb --- /dev/null +++ b/app/models/ml.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Ml + def self.table_name_prefix + 'ml_' + end +end diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb new file mode 100644 index 00000000000..e181217f01c --- /dev/null +++ b/app/models/ml/candidate.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Ml + class Candidate < ApplicationRecord + validates :iid, :experiment, presence: true + + belongs_to :experiment, class_name: 'Ml::Experiment' + belongs_to :user + has_many :metrics, class_name: 'Ml::CandidateMetric' + has_many :params, class_name: 'Ml::CandidateParam' + end +end diff --git a/app/models/ml/candidate_metric.rb b/app/models/ml/candidate_metric.rb new file mode 100644 index 00000000000..e03a8b83ee6 --- /dev/null +++ b/app/models/ml/candidate_metric.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Ml + class CandidateMetric < ApplicationRecord + validates :candidate, presence: true + validates :name, length: { maximum: 250 }, presence: true + + belongs_to :candidate, class_name: 'Ml::Candidate' + end +end diff --git a/app/models/ml/candidate_param.rb b/app/models/ml/candidate_param.rb new file mode 100644 index 00000000000..cbdddcc8a1a --- /dev/null +++ b/app/models/ml/candidate_param.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Ml + class CandidateParam < ApplicationRecord + validates :candidate, presence: true + validates :name, :value, length: { maximum: 250 }, presence: true + + belongs_to :candidate, class_name: 'Ml::Candidate' + end +end diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb new file mode 100644 index 00000000000..7ef9c70ba7e --- /dev/null +++ b/app/models/ml/experiment.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Ml + class Experiment < ApplicationRecord + validates :name, :iid, :project, presence: true + validates :iid, :name, uniqueness: { scope: :project, message: "should be unique in the project" } + + belongs_to :project + belongs_to :user + has_many :candidates, class_name: 'Ml::Candidate' + end +end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index f23a859b119..06f49f16d66 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -40,15 +40,21 @@ class Namespace < ApplicationRecord PATH_TRAILING_VIOLATIONS = %w[.git .atom .].freeze + # The first date in https://docs.gitlab.com/ee/user/usage_quotas.html#namespace-storage-limit-enforcement-schedule + # Determines when we start enforcing namespace storage + MIN_STORAGE_ENFORCEMENT_DATE = Date.new(2022, 10, 19) + cache_markdown_field :description, pipeline: :description has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :project_statistics has_one :namespace_settings, inverse_of: :namespace, class_name: 'NamespaceSetting', autosave: true has_one :ci_cd_settings, inverse_of: :namespace, class_name: 'NamespaceCiCdSetting', autosave: true + has_one :namespace_details, inverse_of: :namespace, class_name: 'Namespace::Detail', autosave: true has_one :namespace_statistics has_one :namespace_route, foreign_key: :namespace_id, autosave: false, inverse_of: :namespace, class_name: 'Route' has_many :namespace_members, foreign_key: :member_namespace_id, inverse_of: :member_namespace, class_name: 'Member' + has_many :member_roles has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace' has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner' @@ -77,6 +83,8 @@ class Namespace < ApplicationRecord has_many :work_items, inverse_of: :namespace has_many :issues, inverse_of: :namespace + has_many :timelog_categories, class_name: 'TimeTracking::TimelogCategory' + validates :owner, presence: true, if: ->(n) { n.owner_required? } validates :name, presence: true, @@ -120,6 +128,7 @@ class Namespace < ApplicationRecord to: :namespace_settings, allow_nil: true after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_parent_id? } + after_save :reload_namespace_details after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') } @@ -559,9 +568,7 @@ class Namespace < ApplicationRecord def storage_enforcement_date return Date.current if Feature.enabled?(:namespace_storage_limit_bypass_date_check, self) - # should return something like Date.new(2022, 02, 03) - # TBD: https://gitlab.com/gitlab-org/gitlab/-/issues/350632 - nil + MIN_STORAGE_ENFORCEMENT_DATE end def certificate_based_clusters_enabled? @@ -671,6 +678,12 @@ class Namespace < ApplicationRecord end end + def reload_namespace_details + return unless !project_namespace? && (previous_changes.keys & %w(description description_html cached_markdown_version)).any? && namespace_details.present? + + namespace_details.reset + end + def sync_share_with_group_lock_with_parent if parent&.share_with_group_lock? self.share_with_group_lock = true diff --git a/app/models/namespace/detail.rb b/app/models/namespace/detail.rb new file mode 100644 index 00000000000..dbbf9f4944a --- /dev/null +++ b/app/models/namespace/detail.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Namespace::Detail < ApplicationRecord + belongs_to :namespace, inverse_of: :namespace_details + validates :namespace, presence: true + validates :description, length: { maximum: 255 } + + self.primary_key = :namespace_id +end diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb index 6f404ec12d0..81ac026d7ff 100644 --- a/app/models/namespaces/traversal/linear_scopes.rb +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -27,15 +27,9 @@ module Namespaces def self_and_ancestors(include_self: true, upto: nil, hierarchy_order: nil) return super unless use_traversal_ids_for_ancestor_scopes? - if Feature.enabled?(:use_traversal_ids_for_ancestor_scopes_with_inner_join) - self_and_ancestors_from_inner_join(include_self: include_self, - upto: upto, hierarchy_order: - hierarchy_order) - else - self_and_ancestors_from_ancestors_cte(include_self: include_self, - upto: upto, - hierarchy_order: hierarchy_order) - end + self_and_ancestors_from_inner_join(include_self: include_self, + upto: upto, hierarchy_order: + hierarchy_order) end def self_and_ancestor_ids(include_self: true) @@ -117,37 +111,6 @@ module Namespaces use_traversal_ids? end - def self_and_ancestors_from_ancestors_cte(include_self: true, upto: nil, hierarchy_order: nil) - base_cte = all.select('namespaces.id', 'namespaces.traversal_ids').as_cte(:base_ancestors_cte) - - # We have to alias id with 'AS' to avoid ambiguous column references by calling methods. - ancestors_cte = unscoped - .unscope(where: [:type]) - .select('id as base_id', - "#{unnest_func(base_cte.table['traversal_ids']).to_sql} as ancestor_id") - .from(base_cte.table) - .as_cte(:ancestors_cte) - - namespaces = Arel::Table.new(:namespaces) - - records = unscoped - .with(base_cte.to_arel, ancestors_cte.to_arel) - .distinct - .from([ancestors_cte.table, namespaces]) - .where(namespaces[:id].eq(ancestors_cte.table[:ancestor_id])) - .order_by_depth(hierarchy_order) - - unless include_self - records = records.where(ancestors_cte.table[:base_id].not_eq(ancestors_cte.table[:ancestor_id])) - end - - if upto - records = records.where.not(id: unscoped.where(id: upto).select('unnest(traversal_ids)')) - end - - records - end - def self_and_ancestors_from_inner_join(include_self: true, upto: nil, hierarchy_order: nil) base_cte = all.reselect('namespaces.traversal_ids').as_cte(:base_ancestors_cte) @@ -181,25 +144,15 @@ module Namespaces end def self_and_descendants_with_comparison_operators(include_self: true) - base = all.select(:traversal_ids) - base = base.select(:id) if Feature.enabled?(:linear_scopes_superset) + base = all.select(:id, :traversal_ids) base_cte = base.as_cte(:descendants_base_cte) namespaces = Arel::Table.new(:namespaces) - withs = [base_cte.to_arel] - froms = [] - - if Feature.enabled?(:linear_scopes_superset) - superset_cte = self.superset_cte(base_cte.table.name) - withs += [superset_cte.to_arel] - froms = [superset_cte.table] - else - froms = [base_cte.table] - end - + superset_cte = self.superset_cte(base_cte.table.name) + withs = [base_cte.to_arel, superset_cte.to_arel] # Order is important. namespace should be last to handle future joins. - froms += [namespaces] + froms = [superset_cte.table, namespaces] base_ref = froms.first diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 560ff861105..a034d97a6bb 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -211,7 +211,7 @@ module Network # Visit branching chains leaves.each do |l| - parents = l.parents(@map).select {|p| p.space == 0} + parents = l.parents(@map).select { |p| p.space == 0 } parents.each do |p| place_chain(p, l.time) end diff --git a/app/models/note.rb b/app/models/note.rb index 986a85acac6..1715f7cdc3b 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -23,6 +23,8 @@ class Note < ApplicationRecord include FromUnion include Sortable + ISSUE_TASK_SYSTEM_NOTE_PATTERN = /\A.*marked\sthe\stask.+as\s(completed|incomplete).*\z/.freeze + cache_markdown_field :note, pipeline: :note, issuable_reference_expansion_enabled: true redact_field :note @@ -685,6 +687,22 @@ class Note < ApplicationRecord Ability.users_that_can_read_internal_notes(users, resource_parent).pluck(:id) end + # Method necesary while we transition into the new format for task system notes + # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/369923 + def note + return super unless system? && for_issue? && super.match?(ISSUE_TASK_SYSTEM_NOTE_PATTERN) + + super.sub!('task', 'checklist item') + end + + # Method necesary while we transition into the new format for task system notes + # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/369923 + def note_html + return super unless system? && for_issue? && super.match?(ISSUE_TASK_SYSTEM_NOTE_PATTERN) + + super.sub!('task', 'checklist item') + end + private def system_note_viewable_by?(user) diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb index 3713be6cb91..c227626af9e 100644 --- a/app/models/notification_reason.rb +++ b/app/models/notification_reason.rb @@ -6,7 +6,6 @@ class NotificationReason OWN_ACTIVITY = 'own_activity' ASSIGNED = 'assigned' REVIEW_REQUESTED = 'review_requested' - ATTENTION_REQUESTED = 'attention_requested' MENTIONED = 'mentioned' SUBSCRIBED = 'subscribed' @@ -15,7 +14,6 @@ class NotificationReason OWN_ACTIVITY, ASSIGNED, REVIEW_REQUESTED, - ATTENTION_REQUESTED, MENTIONED, SUBSCRIBED ].freeze diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb index 20130f01d44..7d71e15d3c5 100644 --- a/app/models/oauth_access_token.rb +++ b/app/models/oauth_access_token.rb @@ -6,7 +6,6 @@ class OauthAccessToken < Doorkeeper::AccessToken alias_attribute :user, :resource_owner - scope :distinct_resource_owner_counts, ->(applications) { where(application: applications).distinct.group(:application_id).count(:resource_owner_id) } scope :latest_per_application, -> { select('distinct on(application_id) *').order(application_id: :desc, created_at: :desc) } scope :preload_application, -> { preload(:application) } @@ -17,4 +16,14 @@ class OauthAccessToken < Doorkeeper::AccessToken super end end + + # this method overrides a shortcoming upstream, more context: + # https://gitlab.com/gitlab-org/gitlab/-/issues/367888 + def self.find_by_fallback_token(attr, plain_secret) + return unless fallback_secret_strategy && fallback_secret_strategy == Doorkeeper::SecretStoring::Plain + # token is hashed, don't allow plaintext comparison + return if plain_secret.starts_with?("$") + + super + end end diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb index 7db396bcad5..e36c59366fe 100644 --- a/app/models/operations/feature_flag.rb +++ b/app/models/operations/feature_flag.rb @@ -42,7 +42,7 @@ module Operations scope :enabled, -> { where(active: true) } scope :disabled, -> { where(active: false) } - scope :new_version_only, -> { where(version: :new_version_flag)} + scope :new_version_only, -> { where(version: :new_version_flag) } enum version: { new_version_flag: 2 diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 90a1bb4bc69..afd55b4f143 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -65,7 +65,7 @@ class Packages::Package < ApplicationRecord validates :name, uniqueness: { scope: %i[project_id version package_type], - conditions: -> { not_pending_destruction} + conditions: -> { not_pending_destruction } }, unless: -> { pending_destruction? || conan? || debian_package? } @@ -327,7 +327,7 @@ class Packages::Package < ApplicationRecord def normalized_pypi_name return name unless pypi? - name.gsub(/#{Gitlab::Regex::Packages::PYPI_NORMALIZED_NAME_REGEX_STRING}/, '-').downcase + name.gsub(/#{Gitlab::Regex::Packages::PYPI_NORMALIZED_NAME_REGEX_STRING}/o, '-').downcase end private diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 9e93bff4acf..2e25839c47f 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -23,10 +23,10 @@ class PagesDomain < ApplicationRecord validates :domain, uniqueness: { case_sensitive: false } validates :certificate, :key, presence: true, if: :usage_serverless? validates :certificate, presence: { message: 'must be present if HTTPS-only is enabled' }, - if: :certificate_should_be_present? + if: :certificate_should_be_present? validates :certificate, certificate: true, if: ->(domain) { domain.certificate.present? } validates :key, presence: { message: 'must be present if HTTPS-only is enabled' }, - if: :certificate_should_be_present? + if: :certificate_should_be_present? validates :key, certificate_key: true, named_ecdsa_key: true, if: ->(domain) { domain.key.present? } validates :verification_code, presence: true, allow_blank: false diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb index 40d14aaa1de..4804f620a99 100644 --- a/app/models/performance_monitoring/prometheus_dashboard.rb +++ b/app/models/performance_monitoring/prometheus_dashboard.rb @@ -57,10 +57,10 @@ module PerformanceMonitoring self.class.from_json(reload_schema) [] - rescue Gitlab::Metrics::Dashboard::Errors::LayoutError => error - [error.message] - rescue ActiveModel::ValidationError => exception - exception.model.errors.map { |attr, error| "#{attr}: #{error}" } + rescue Gitlab::Metrics::Dashboard::Errors::LayoutError => e + [e.message] + rescue ActiveModel::ValidationError => e + e.model.errors.map { |attr, error| "#{attr}: #{error}" } end private diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 68ba3d6eab4..7e6e366f8da 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -20,7 +20,7 @@ class PersonalAccessToken < ApplicationRecord before_save :ensure_token - scope :active, -> { where("revoked = false AND (expires_at >= CURRENT_DATE OR expires_at IS NULL)") } + scope :active, -> { not_revoked.not_expired } scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) } scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) } scope :inactive, -> { where("revoked = true OR expires_at < CURRENT_DATE") } @@ -33,6 +33,7 @@ class PersonalAccessToken < ApplicationRecord scope :preload_users, -> { preload(:user) } scope :order_expires_at_asc, -> { reorder(expires_at: :asc) } scope :order_expires_at_desc, -> { reorder(expires_at: :desc) } + scope :order_expires_at_asc_id_desc, -> { reorder(expires_at: :asc, id: :desc) } scope :project_access_token, -> { includes(:user).where(user: { user_type: :project_bot }) } scope :owner_is_human, -> { includes(:user).where(user: { user_type: :human }) } @@ -57,8 +58,8 @@ class PersonalAccessToken < ApplicationRecord begin Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) - rescue StandardError => ex - logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{ex.class}" + rescue StandardError => e + logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{e.class}" encrypted_token end end @@ -77,7 +78,8 @@ class PersonalAccessToken < ApplicationRecord super.merge( { 'expires_at_asc' => -> { order_expires_at_asc }, - 'expires_at_desc' => -> { order_expires_at_desc } + 'expires_at_desc' => -> { order_expires_at_desc }, + 'expires_at_asc_id_desc' => -> { order_expires_at_asc_id_desc } } ) end diff --git a/app/models/preloaders/labels_preloader.rb b/app/models/preloaders/labels_preloader.rb index bb3206f5399..722d588d8bc 100644 --- a/app/models/preloaders/labels_preloader.rb +++ b/app/models/preloaders/labels_preloader.rb @@ -21,8 +21,8 @@ module Preloaders def preload_all preloader = ActiveRecord::Associations::Preloader.new - preloader.preload(labels.select {|l| l.is_a? ProjectLabel }, { project: [:project_feature, namespace: :route] }) - preloader.preload(labels.select {|l| l.is_a? GroupLabel }, { group: :route }) + preloader.preload(labels.select { |l| l.is_a? ProjectLabel }, { project: [:project_feature, namespace: :route] }) + preloader.preload(labels.select { |l| l.is_a? GroupLabel }, { group: :route }) labels.each do |label| label.lazy_subscription(user) label.lazy_subscription(user, project) if project.present? diff --git a/app/models/project.rb b/app/models/project.rb index ebfec34c3e1..0c49cc24a8d 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -53,6 +53,7 @@ class Project < ApplicationRecord ignore_columns :mirror_last_update_at, :mirror_last_successful_update_at, remove_after: '2021-09-22', remove_with: '14.4' ignore_columns :pull_mirror_branch_prefix, remove_after: '2021-09-22', remove_with: '14.4' + ignore_columns :build_coverage_regex, remove_after: '2022-10-22', remove_with: '15.5' STATISTICS_ATTRIBUTE = 'repositories_count' UNKNOWN_IMPORT_URL = 'http://unknown.git' @@ -131,6 +132,8 @@ class Project < ApplicationRecord after_save :save_topics + after_save :reload_project_namespace_details + after_create -> { create_or_load_association(:project_feature) } after_create -> { create_or_load_association(:ci_cd_settings) } @@ -176,7 +179,7 @@ class Project < ApplicationRecord alias_method :parent, :namespace alias_attribute :parent_id, :namespace_id - has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' + has_one :last_event, -> { order 'events.created_at DESC' }, class_name: 'Event' has_many :boards def self.integration_association_name(name) @@ -213,6 +216,7 @@ class Project < ApplicationRecord has_one :pipelines_email_integration, class_name: 'Integrations::PipelinesEmail' has_one :pivotaltracker_integration, class_name: 'Integrations::Pivotaltracker' has_one :prometheus_integration, class_name: 'Integrations::Prometheus', inverse_of: :project + has_one :pumble_integration, class_name: 'Integrations::Pumble' has_one :pushover_integration, class_name: 'Integrations::Pushover' has_one :redmine_integration, class_name: 'Integrations::Redmine' has_one :shimo_integration, class_name: 'Integrations::Shimo' @@ -288,6 +292,8 @@ class Project < ApplicationRecord has_many :project_members, -> { where(requested_at: nil) }, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :project_callouts, class_name: 'Users::ProjectCallout', foreign_key: :project_id + alias_method :members, :project_members has_many :users, through: :project_members @@ -446,7 +452,8 @@ class Project < ApplicationRecord :repository_access_level, :package_registry_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level, :operations_access_level, :security_and_compliance_access_level, - :container_registry_access_level, + :container_registry_access_level, :environments_access_level, :feature_flags_access_level, + :releases_access_level, to: :project_feature, allow_nil: true delegate :show_default_award_emojis, :show_default_award_emojis=, @@ -472,6 +479,7 @@ class Project < ApplicationRecord delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true delegate :opt_in_jwt, :opt_in_jwt=, to: :ci_cd_settings, prefix: :ci, allow_nil: true + delegate :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true delegate :separated_caches, :separated_caches=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=, to: :ci_cd_settings, allow_nil: true @@ -663,6 +671,7 @@ class Project < ApplicationRecord scope :imported_from, -> (type) { where(import_type: type) } scope :imported, -> { where.not(import_type: nil) } scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) } + scope :last_activity_before, -> (time) { where('projects.last_activity_at < ?', time) } scope :with_service_desk_key, -> (key) do # project_key is not indexed for now @@ -814,7 +823,7 @@ class Project < ApplicationRecord (?<!#{Gitlab::PathRegex::PATH_START_CHAR}) ((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)? (?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX}) - }x + }xo end def reference_postfix @@ -1041,6 +1050,7 @@ class Project < ApplicationRecord def emails_enabled? !emails_disabled? end + override :lfs_enabled? def lfs_enabled? return namespace.lfs_enabled? if self[:lfs_enabled].nil? @@ -1675,7 +1685,13 @@ class Project < ApplicationRecord end def has_active_hooks?(hooks_scope = :push_hooks) - hooks.hooks_for(hooks_scope).any? || SystemHook.hooks_for(hooks_scope).any? || Gitlab::FileHook.any? + @has_active_hooks ||= {} # rubocop: disable Gitlab/PredicateMemoization + + return @has_active_hooks[hooks_scope] if @has_active_hooks.key?(hooks_scope) + + @has_active_hooks[hooks_scope] = hooks.hooks_for(hooks_scope).any? || + SystemHook.hooks_for(hooks_scope).any? || + Gitlab::FileHook.any? end def has_active_integrations?(hooks_scope = :push_hooks) @@ -1757,8 +1773,8 @@ class Project < ApplicationRecord repository.after_create true - rescue StandardError => err - Gitlab::ErrorTracking.track_exception(err, project: { id: id, full_path: full_path, disk_path: disk_path }) + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, project: { id: id, full_path: full_path, disk_path: disk_path }) errors.add(:base, _('Failed to create repository')) false end @@ -2254,6 +2270,7 @@ class Project < ApplicationRecord .concat(dependency_proxy_variables) .concat(auto_devops_variables) .concat(api_variables) + .concat(ci_template_variables) end end @@ -2307,6 +2324,12 @@ class Project < ApplicationRecord end end + def ci_template_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_TEMPLATE_REGISTRY_HOST', value: 'registry.gitlab.com') + end + end + def dependency_proxy_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables unless Gitlab.config.dependency_proxy.enabled @@ -2651,7 +2674,7 @@ class Project < ApplicationRecord { repository_storage: repository_storage, - pool_repository: pool_repository || create_new_pool_repository + pool_repository: pool_repository || create_new_pool_repository } end @@ -2880,6 +2903,12 @@ class Project < ApplicationRecord ci_cd_settings.forward_deployment_enabled? end + def ci_allow_fork_pipelines_to_run_in_parent_project? + return false unless ci_cd_settings + + ci_cd_settings.allow_fork_pipelines_to_run_in_parent_project? + end + def ci_job_token_scope_enabled? return false unless ci_cd_settings @@ -2984,6 +3013,14 @@ class Project < ApplicationRecord group&.work_items_feature_flag_enabled? || Feature.enabled?(:work_items, self) end + def work_items_mvc_2_feature_flag_enabled? + group&.work_items_mvc_2_feature_flag_enabled? || Feature.enabled?(:work_items_mvc_2) + end + + def work_items_create_from_markdown_feature_flag_enabled? + work_items_feature_flag_enabled? && (group&.work_items_create_from_markdown_feature_flag_enabled? || Feature.enabled?(:work_items_create_from_markdown)) + end + def enqueue_record_project_target_platforms return unless Gitlab.com? return unless Feature.enabled?(:record_projects_target_platforms, self) @@ -3008,6 +3045,10 @@ class Project < ApplicationRecord licensed_feature_available?(:security_training) end + def destroy_deployment_by_id(deployment_id) + deployments.where(id: deployment_id).fast_destroy_all + end + private # overridden in EE @@ -3238,6 +3279,12 @@ class Project < ApplicationRecord project_namespace.assign_attributes(attributes_to_sync) end + def reload_project_namespace_details + return unless (previous_changes.keys & %w(description description_html cached_markdown_version)).any? && project_namespace.namespace_details.present? + + project_namespace.namespace_details.reset + end + # SyncEvents are created by PG triggers (with the function `insert_projects_sync_event`) def schedule_sync_event_worker run_after_commit do diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 0a30e125c83..8623e477c06 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -21,6 +21,9 @@ class ProjectFeature < ApplicationRecord security_and_compliance container_registry package_registry + environments + feature_flags + releases ].freeze EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze diff --git a/app/models/projects/import_export/relation_export.rb b/app/models/projects/import_export/relation_export.rb index 0a31e525ac2..15198049f87 100644 --- a/app/models/projects/import_export/relation_export.rb +++ b/app/models/projects/import_export/relation_export.rb @@ -3,6 +3,20 @@ module Projects module ImportExport class RelationExport < ApplicationRecord + DESIGN_REPOSITORY_RELATION = 'design_repository' + LFS_OBJECTS_RELATION = 'lfs_objects' + REPOSITORY_RELATION = 'repository' + ROOT_RELATION = 'project' + SNIPPETS_REPOSITORY_RELATION = 'snippets_repository' + UPLOADS_RELATION = 'uploads' + WIKI_REPOSITORY_RELATION = 'wiki_repository' + + EXTRA_RELATION_LIST = [ + DESIGN_REPOSITORY_RELATION, LFS_OBJECTS_RELATION, REPOSITORY_RELATION, ROOT_RELATION, + SNIPPETS_REPOSITORY_RELATION, UPLOADS_RELATION, WIKI_REPOSITORY_RELATION + ].freeze + private_constant :EXTRA_RELATION_LIST + self.table_name = 'project_relation_exports' belongs_to :project_export_job @@ -17,6 +31,33 @@ module Projects validates :project_export_job, presence: true validates :relation, presence: true, length: { maximum: 255 }, uniqueness: { scope: :project_export_job_id } validates :status, numericality: { only_integer: true }, presence: true + + scope :by_relation, -> (relation) { where(relation: relation) } + + state_machine :status, initial: :queued do + state :queued, value: 0 + state :started, value: 1 + state :finished, value: 2 + state :failed, value: 3 + + event :start do + transition queued: :started + end + + event :finish do + transition started: :finished + end + + event :fail_op do + transition [:queued, :started] => :failed + end + end + + def self.relation_names_list + project_tree_relation_names = ::Gitlab::ImportExport::Reader.new(shared: nil).project_relation_names.map(&:to_s) + + project_tree_relation_names + EXTRA_RELATION_LIST + end end end end diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb index bc7f94e4374..b0f138714a0 100644 --- a/app/models/projects/topic.rb +++ b/app/models/projects/topic.rb @@ -15,6 +15,7 @@ module Projects has_many :project_topics, class_name: 'Projects::ProjectTopic' has_many :projects, through: :project_topics + scope :without_assigned_projects, -> { where(total_projects_count: 0) } scope :order_by_non_private_projects_count, -> { order(non_private_projects_count: :desc).order(id: :asc) } scope :reorder_by_similarity, -> (search) do order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [ diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb index 684f50d5f58..9080f3d9de1 100644 --- a/app/models/prometheus_alert.rb +++ b/app/models/prometheus_alert.rb @@ -25,7 +25,7 @@ class PrometheusAlert < ApplicationRecord validates :environment, :project, :prometheus_metric, :threshold, :operator, presence: true validates :runbook_url, length: { maximum: 255 }, allow_blank: true, - addressable_url: { enforce_sanitization: true, ascii_only: true } + addressable_url: { enforce_sanitization: true, ascii_only: true } validate :require_valid_environment_project! validate :require_valid_metric_project! diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 7cf15439b47..76c277e4b86 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -4,8 +4,6 @@ class ProtectedBranch < ApplicationRecord include ProtectedRef include Gitlab::SQL::Pattern - CACHE_EXPIRE_IN = 1.hour - scope :requiring_code_owner_approval, -> { where(code_owner_approval_required: true) } @@ -27,10 +25,30 @@ class ProtectedBranch < ApplicationRecord end # Check if branch name is marked as protected in the system - def self.protected?(project, ref_name) + def self.protected?(project, ref_name, dry_run: true) return true if project.empty_repo? && project.default_branch_protected? return false if ref_name.blank? + new_cache_result = new_cache(project, ref_name, dry_run: dry_run) + + return new_cache_result unless new_cache_result.nil? + + deprecated_cache(project, ref_name) + end + + def self.new_cache(project, ref_name, dry_run: true) + if Feature.enabled?(:hash_based_cache_for_protected_branches, project) + ProtectedBranches::CacheService.new(project).fetch(ref_name, dry_run: dry_run) do # rubocop: disable CodeReuse/ServiceClass + self.matching(ref_name, protected_refs: protected_refs(project)).present? + end + end + end + + # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/368279 + # ---------------------------------------------------------------- + CACHE_EXPIRE_IN = 1.hour + + def self.deprecated_cache(project, ref_name) Rails.cache.fetch(protected_ref_cache_key(project, ref_name), expires_in: CACHE_EXPIRE_IN) do self.matching(ref_name, protected_refs: protected_refs(project)).present? end @@ -39,6 +57,7 @@ class ProtectedBranch < ApplicationRecord def self.protected_ref_cache_key(project, ref_name) "protected_ref-#{project.cache_key}-#{Digest::SHA1.hexdigest(ref_name)}" end + # End of deprecation -------------------------------------------- def self.allow_force_push?(project, ref_name) project.protected_branches.allowing_force_push.matching(ref_name).any? diff --git a/app/models/release.rb b/app/models/release.rb index ee5d7bab190..5ef3ff1bc6c 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -94,7 +94,7 @@ class Release < ApplicationRecord end def milestone_titles - self.milestones.order_by_dates_and_title.map {|m| m.title }.join(', ') + self.milestones.order_by_dates_and_title.map { |m| m.title }.join(', ') end def to_hook_data(action) diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb index 17a9ad7db66..c2d498ecb13 100644 --- a/app/models/release_highlight.rb +++ b/app/models/release_highlight.rb @@ -33,7 +33,7 @@ class ReleaseHighlight next unless include_item?(item) begin - item.tap {|i| i['body'] = Banzai.render(i['body'], { project: nil }) } + item.tap { |i| i['description'] = Banzai.render(i['description'], { project: nil }) } rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, file_path: file_path) @@ -116,6 +116,6 @@ class ReleaseHighlight return true unless Gitlab::CurrentSettings.current_application_settings.whats_new_variant_current_tier? - item['packages']&.include?(current_package) + item['available_in']&.include?(current_package) end end diff --git a/app/models/repository.rb b/app/models/repository.rb index 9039bdf1a20..eb8e45877f3 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -244,10 +244,10 @@ class Repository end end - def add_branch(user, branch_name, ref) + def add_branch(user, branch_name, ref, expire_cache: true) branch = raw_repository.add_branch(branch_name, user: user, target: ref) - after_create_branch + after_create_branch(expire_cache: expire_cache) branch rescue Gitlab::Git::Repository::InvalidRef @@ -337,11 +337,17 @@ class Repository def expire_branches_cache expire_method_caches(%i(branch_names merged_branch_names branch_count has_visible_content? has_ambiguous_refs?)) + expire_protected_branches_cache + @local_branches = nil @branch_exists_memo = nil @branch_names_include = nil end + def expire_protected_branches_cache + ProtectedBranches::CacheService.new(project).refresh if project # rubocop:disable CodeReuse/ServiceClass + end + def expire_statistics_caches expire_method_caches(%i(size commit_count)) end @@ -646,8 +652,8 @@ class Repository return if licensee_object.name.blank? licensee_object - rescue Licensee::InvalidLicense => ex - Gitlab::ErrorTracking.track_exception(ex) + rescue Licensee::InvalidLicense => e + Gitlab::ErrorTracking.track_exception(e) nil end memoize_method :license @@ -1072,9 +1078,9 @@ class Repository ) do |commit_id| merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil) end - rescue StandardError => error + rescue StandardError => e merge_request.update!(rebase_commit_sha: nil) - raise error + raise e end def squash(user, merge_request, message) diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index 5d7b3879d75..8fea0d6d993 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -68,7 +68,11 @@ class SentNotification < ApplicationRecord def noteable if for_commit? - project.commit(commit_id) rescue nil + begin + project.commit(commit_id) + rescue StandardError + nil + end else super end @@ -76,7 +80,11 @@ class SentNotification < ApplicationRecord def position=(new_position) if new_position.is_a?(String) - new_position = Gitlab::Json.parse(new_position) rescue nil + new_position = begin + Gitlab::Json.parse(new_position) + rescue StandardError + nil + end end if new_position.is_a?(Hash) diff --git a/app/models/serverless/domain_cluster.rb b/app/models/serverless/domain_cluster.rb index 0d54a97370e..1effabf1c22 100644 --- a/app/models/serverless/domain_cluster.rb +++ b/app/models/serverless/domain_cluster.rb @@ -17,7 +17,7 @@ module Serverless validates :pages_domain, :knative, presence: true validates :uuid, presence: true, uniqueness: true, length: { is: ::Serverless::Domain::UUID_LENGTH }, - format: { with: HEX_REGEXP, message: 'only allows hex characters' } + format: { with: HEX_REGEXP, message: 'only allows hex characters' } default_value_for(:uuid, allows_nil: false) { ::Serverless::Domain.generate_uuid } diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 47b23bbd28a..fd882633a44 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -94,8 +94,8 @@ class Snippet < ApplicationRecord attr_spammable :content, spam_description: true attr_encrypted :secret_token, - key: Settings.attr_encrypted_db_key_base_truncated, - mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + mode: :per_attribute_iv, algorithm: 'aes-256-cbc' class << self diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb index 92405a0d943..5ac159d9615 100644 --- a/app/models/snippet_repository.rb +++ b/app/models/snippet_repository.rb @@ -44,11 +44,11 @@ class SnippetRepository < ApplicationRecord Gitlab::Git::CommitError, Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError, - ArgumentError => error + ArgumentError => e - logger.error(message: "Snippet git error. Reason: #{error.message}", snippet: snippet.id) + logger.error(message: "Snippet git error. Reason: #{e.message}", snippet: snippet.id) - raise commit_error_exception(error) + raise commit_error_exception(e) end def transform_file_entries(files) diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 2643ef272d8..cc389dbe3f4 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -22,7 +22,7 @@ class SystemNoteMetadata < ApplicationRecord designs_added designs_modified designs_removed designs_discussion_added title time_tracking branch milestone discussion task moved cloned opened closed merged duplicate locked unlocked outdated reviewer - tag due_date pinned_embed cherry_pick health_status approved unapproved + tag due_date start_date_or_due_date pinned_embed cherry_pick health_status approved unapproved status alert_issue_added relate unrelate new_alert_added severity attention_requested attention_request_removed contact timeline_event ].freeze diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 59f7d852ce6..e5c8f4ab32a 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -26,7 +26,7 @@ module Terraform validates :project_id, :name, presence: true validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH }, - format: { with: HEX_REGEXP, message: 'only allows hex characters' } + format: { with: HEX_REGEXP, message: 'only allows hex characters' } default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) } diff --git a/app/models/todo.rb b/app/models/todo.rb index c698783d750..d165e60e4c3 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -19,7 +19,6 @@ class Todo < ApplicationRecord DIRECTLY_ADDRESSED = 7 MERGE_TRAIN_REMOVED = 8 # This is an EE-only feature REVIEW_REQUESTED = 9 - ATTENTION_REQUESTED = 10 ACTION_NAMES = { ASSIGNED => :assigned, @@ -30,8 +29,7 @@ class Todo < ApplicationRecord APPROVAL_REQUIRED => :approval_required, UNMERGEABLE => :unmergeable, DIRECTLY_ADDRESSED => :directly_addressed, - MERGE_TRAIN_REMOVED => :merge_train_removed, - ATTENTION_REQUESTED => :attention_requested + MERGE_TRAIN_REMOVED => :merge_train_removed }.freeze ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED].freeze @@ -195,10 +193,6 @@ class Todo < ApplicationRecord action == REVIEW_REQUESTED end - def attention_requested? - action == ATTENTION_REQUESTED - end - def merge_train_removed? action == MERGE_TRAIN_REMOVED end @@ -238,7 +232,11 @@ class Todo < ApplicationRecord # override to return commits, which are not active record def target if for_commit? - project.commit(commit_id) rescue nil + begin + project.commit(commit_id) + rescue StandardError + nil + end else super end diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb index 7c01aa7a420..ba6c1ee6af1 100644 --- a/app/models/u2f_registration.rb +++ b/app/models/u2f_registration.rb @@ -6,21 +6,7 @@ class U2fRegistration < ApplicationRecord belongs_to :user after_create :create_webauthn_registration - after_update :update_webauthn_registration, if: :counter_changed? - - def create_webauthn_registration - converter = Gitlab::Auth::U2fWebauthnConverter.new(self) - WebauthnRegistration.create!(converter.convert) - rescue StandardError => ex - Gitlab::ErrorTracking.track_exception(ex, u2f_registration_id: self.id) - end - - def update_webauthn_registration - # When we update the sign count of this registration - # we need to update the sign count of the corresponding webauthn registration - # as well if it exists already - WebauthnRegistration.find_by_credential_xid(webauthn_credential_xid)&.update_attribute(:counter, counter) - end + after_update :update_webauthn_registration, if: :saved_change_to_counter? def self.register(user, app_id, params, challenges) u2f = U2F::U2F.new(app_id) @@ -60,10 +46,22 @@ class U2fRegistration < ApplicationRecord private + def create_webauthn_registration + converter = Gitlab::Auth::U2fWebauthnConverter.new(self) + WebauthnRegistration.create!(converter.convert) + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, u2f_registration_id: self.id) + end + + def update_webauthn_registration + # When we update the sign count of this registration + # we need to update the sign count of the corresponding webauthn registration + # as well if it exists already + WebauthnRegistration.find_by_credential_xid(webauthn_credential_xid) + &.update_attribute(:counter, counter) + end + def webauthn_credential_xid - # To find the corresponding webauthn registration, we use that - # the key handle of the u2f reg corresponds to the credential xid of the webauthn reg - # (with some base64 back and forth) Base64.strict_encode64(Base64.urlsafe_decode64(key_handle)) end end diff --git a/app/models/user.rb b/app/models/user.rb index 188b27383f9..afee2d70844 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -30,6 +30,7 @@ class User < ApplicationRecord include Gitlab::Auth::Otp::Fortinet include RestrictedSignup include StripAttribute + include EachBatch DEFAULT_NOTIFICATION_LEVEL = :participating @@ -69,8 +70,8 @@ class User < ApplicationRecord default_value_for :theme_id, gitlab_config.default_theme attr_encrypted :otp_secret, - key: Gitlab::Application.secrets.otp_key_base, - mode: :per_attribute_iv_and_salt, + key: Gitlab::Application.secrets.otp_key_base, + mode: :per_attribute_iv_and_salt, insecure_mode: true, algorithm: 'aes-256-cbc' @@ -222,6 +223,7 @@ class User < ApplicationRecord has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'Users::Callout' has_many :group_callouts, class_name: 'Users::GroupCallout' + has_many :project_callouts, class_name: 'Users::ProjectCallout' has_many :namespace_callouts, class_name: 'Users::NamespaceCallout' has_many :term_agreements belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' @@ -272,10 +274,10 @@ class User < ApplicationRecord validate :check_username_format, if: :username_changed? validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids, - message: _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } } + message: _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } } validates :color_scheme_id, allow_nil: true, inclusion: { in: Gitlab::ColorSchemes.valid_ids, - message: _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } } + message: _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } } validates :website_url, allow_blank: true, url: true, if: :website_url_changed? @@ -447,6 +449,11 @@ class User < ApplicationRecord scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) } scope :by_name, -> (names) { iwhere(name: Array(names)) } + scope :by_login, -> (login) do + return none if login.blank? + + login.include?('@') ? iwhere(email: login) : iwhere(username: login) + end scope :by_user_email, -> (emails) { iwhere(email: Array(emails)) } scope :by_emails, -> (emails) { joins(:emails).where(emails: { email: Array(emails).map(&:downcase) }) } scope :for_todos, -> (todos) { where(id: todos.select(:user_id).distinct) } @@ -481,7 +488,6 @@ class User < ApplicationRecord scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) } scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) } scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) } - scope :by_id_and_login, ->(id, login) { where(id: id).where('username = LOWER(:login) OR email = LOWER(:login)', login: login) } scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) } scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) } scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } @@ -691,33 +697,29 @@ class User < ApplicationRecord scope = options[:with_private_emails] ? with_primary_or_secondary_email(query) : with_public_email(query) scope = scope.or(search_by_name_or_username(query, use_minimum_char_limit: options[:use_minimum_char_limit])) - if Feature.enabled?(:use_keyset_aware_user_search_query) - order = Gitlab::Pagination::Keyset::Order.build([ - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'users_match_priority', - order_expression: sanitized_order_sql.asc, - add_to_projections: true, - distinct: false - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'users_name', - order_expression: arel_table[:name].asc, - add_to_projections: true, - nullable: :not_nullable, - distinct: false - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'users_id', - order_expression: arel_table[:id].asc, - add_to_projections: true, - nullable: :not_nullable, - distinct: true - ) - ]) - scope.reorder(order) - else - scope.reorder(sanitized_order_sql, :name) - end + order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_match_priority', + order_expression: sanitized_order_sql.asc, + add_to_projections: true, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_name', + order_expression: arel_table[:name].asc, + add_to_projections: true, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_id', + order_expression: arel_table[:id].asc, + add_to_projections: true, + nullable: :not_nullable, + distinct: true + ) + ]) + scope.reorder(order) end # Limits the result set to users _not_ in the given query/list of IDs. @@ -768,14 +770,8 @@ class User < ApplicationRecord true end - def by_login(login) - return unless login - - if login.include?('@') - unscoped.iwhere(email: login).take - else - unscoped.iwhere(username: login).take - end + def find_by_login(login) + by_login(login).take end def find_by_username(username) @@ -991,12 +987,12 @@ class User < ApplicationRecord def disable_two_factor! transaction do update( - otp_required_for_login: false, - encrypted_otp_secret: nil, - encrypted_otp_secret_iv: nil, - encrypted_otp_secret_salt: nil, + otp_required_for_login: false, + encrypted_otp_secret: nil, + encrypted_otp_secret_iv: nil, + encrypted_otp_secret_salt: nil, otp_grace_period_started_at: nil, - otp_backup_codes: nil + otp_backup_codes: nil ) self.u2f_registrations.destroy_all # rubocop: disable Cop/DestroyAll self.webauthn_registrations.destroy_all # rubocop: disable Cop/DestroyAll @@ -1663,7 +1659,14 @@ class User < ApplicationRecord end def forkable_namespaces - @forkable_namespaces ||= [namespace] + manageable_groups(include_groups_with_developer_maintainer_access: true) + strong_memoize(:forkable_namespaces) do + personal_namespace = Namespace.where(id: namespace_id) + + Namespace.from_union([ + manageable_groups(include_groups_with_developer_maintainer_access: true), + personal_namespace + ]) + end end def manageable_groups(include_groups_with_developer_maintainer_access: false) @@ -1808,16 +1811,6 @@ class User < ApplicationRecord end end - def attention_requested_open_merge_requests_count(force: false) - if Feature.enabled?(:uncached_mr_attention_requests_count, self) - MergeRequestsFinder.new(self, attention: self.username, state: 'opened', non_archived: true).execute.count - else - Rails.cache.fetch(attention_request_cache_key, force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do - MergeRequestsFinder.new(self, attention: self.username, state: 'opened', non_archived: true).execute.count - end - end - end - def assigned_open_issues_count(force: false) Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count @@ -1861,11 +1854,6 @@ class User < ApplicationRecord def invalidate_merge_request_cache_counts Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count']) Rails.cache.delete(['users', id, 'review_requested_open_merge_requests_count']) - invalidate_attention_requested_count - end - - def invalidate_attention_requested_count - Rails.cache.delete(attention_request_cache_key) end def invalidate_todos_cache_counts @@ -1877,10 +1865,6 @@ class User < ApplicationRecord Rails.cache.delete(['users', id, 'personal_projects_count']) end - def attention_request_cache_key - ['users', id, 'attention_requested_open_merge_requests_count'] - end - # This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth # flow means we don't call that automatically (and can't conveniently do so). # @@ -2095,6 +2079,12 @@ class User < ApplicationRecord callout_dismissed?(callout, ignore_dismissal_earlier_than) end + def dismissed_callout_for_project?(feature_name:, project:, ignore_dismissal_earlier_than: nil) + callout = project_callouts.find_by(feature_name: feature_name, project: project) + + callout_dismissed?(callout, ignore_dismissal_earlier_than) + end + # Load the current highest access by looking directly at the user's memberships def current_highest_access_level members.non_request.maximum(:access_level) @@ -2126,6 +2116,11 @@ class User < ApplicationRecord .find_or_initialize_by(feature_name: ::Users::NamespaceCallout.feature_names[feature_name], namespace_id: namespace_id) end + def find_or_initialize_project_callout(feature_name, project_id) + project_callouts + .find_or_initialize_by(feature_name: ::Users::ProjectCallout.feature_names[feature_name], project_id: project_id) + end + def can_trigger_notifications? confirmed? && !blocked? && !ghost? end @@ -2160,6 +2155,10 @@ class User < ApplicationRecord Feature.enabled?(:mr_attention_requests, self) end + def account_age_in_days + (Date.current - created_at.to_date).to_i + end + protected # override, from Devise::Validatable diff --git a/app/models/user_status.rb b/app/models/user_status.rb index 7a803e8f1f6..dee976a4497 100644 --- a/app/models/user_status.rb +++ b/app/models/user_status.rb @@ -9,12 +9,12 @@ class UserStatus < ApplicationRecord CLEAR_STATUS_QUICK_OPTIONS = { '30_minutes' => 30.minutes, - '3_hours' => 3.hours, - '8_hours' => 8.hours, - '1_day' => 1.day, - '3_days' => 3.days, - '7_days' => 7.days, - '30_days' => 30.days + '3_hours' => 3.hours, + '8_hours' => 8.hours, + '1_day' => 1.day, + '3_days' => 3.days, + '7_days' => 7.days, + '30_days' => 30.days }.freeze belongs_to :user @@ -32,6 +32,10 @@ class UserStatus < ApplicationRecord def clear_status_after=(value) self.clear_status_at = CLEAR_STATUS_QUICK_OPTIONS[value]&.from_now end + + def customized? + message.present? || emoji != UserStatus::DEFAULT_EMOJI + end end UserStatus.prepend_mod_with('UserStatus') diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 570e3ae9b3c..7b5c7fef7ba 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -55,8 +55,13 @@ module Users preview_user_over_limit_free_plan_alert: 50, # EE-only user_reached_limit_free_plan_alert: 51, # EE-only submit_license_usage_data_banner: 52, # EE-only - personal_project_limitations_banner: 53, # EE-only - mr_experience_survey: 54 + personal_project_limitations_banner: 53, # EE-only + mr_experience_survey: 54, + namespace_storage_limit_banner_info_threshold: 55, # EE-only + namespace_storage_limit_banner_warning_threshold: 56, # EE-only + namespace_storage_limit_banner_alert_threshold: 57, # EE-only + namespace_storage_limit_banner_error_threshold: 58, # EE-only + project_quality_summary_feedback: 59 # EE-only } validates :feature_name, diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index 0ea7b8199aa..70498ae83e0 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -17,7 +17,13 @@ module Users storage_enforcement_banner_fourth_enforcement_threshold: 6, preview_user_over_limit_free_plan_alert: 7, # EE-only user_reached_limit_free_plan_alert: 8, # EE-only - free_group_limited_alert: 9 # EE-only + free_group_limited_alert: 9, # EE-only + namespace_storage_limit_banner_info_threshold: 10, # EE-only + namespace_storage_limit_banner_warning_threshold: 11, # EE-only + namespace_storage_limit_banner_alert_threshold: 12, # EE-only + namespace_storage_limit_banner_error_threshold: 13, # EE-only + usage_quota_trial_alert: 14, # EE-only + preview_usage_quota_free_plan_alert: 15 # EE-only } validates :group, presence: true diff --git a/app/models/users/project_callout.rb b/app/models/users/project_callout.rb new file mode 100644 index 00000000000..ddc5f8fb4de --- /dev/null +++ b/app/models/users/project_callout.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Users + class ProjectCallout < ApplicationRecord + include Users::Calloutable + + self.table_name = 'user_project_callouts' + + belongs_to :project + + enum feature_name: { + awaiting_members_banner: 1 # EE-only + } + + validates :project, presence: true + validates :feature_name, + presence: true, + uniqueness: { scope: [:user_id, :project_id] }, + inclusion: { in: ProjectCallout.feature_names.keys } + end +end diff --git a/app/models/wiki.rb b/app/models/wiki.rb index c9cb3b0b796..d28a73b644f 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -146,8 +146,8 @@ class Wiki repository.create_if_not_exists(default_branch) raise CouldNotCreateWikiError unless repository_exists? - rescue StandardError => err - Gitlab::ErrorTracking.track_exception(err, wiki: { + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, wiki: { container_type: container.class.name, container_id: container.id, full_path: full_path, @@ -335,7 +335,7 @@ class Wiki end def wiki_base_path - web_url(only_path: true).sub(%r{/#{Wiki::HOMEPAGE}\z}, '') + web_url(only_path: true).sub(%r{/#{Wiki::HOMEPAGE}\z}o, '') end # Callbacks for synchronous processing after wiki changes. @@ -364,9 +364,9 @@ class Wiki Gitlab::Git::CommitError, Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError, - ArgumentError => error + ArgumentError => e - Gitlab::ErrorTracking.log_exception(error, action: action, wiki_id: id) + Gitlab::ErrorTracking.log_exception(e, action: action, wiki_id: id) false end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index d29df0c31fc..451359c1f85 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -12,7 +12,7 @@ class WorkItem < Issue has_many :child_links, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_parent_id has_many :work_item_children, through: :child_links, class_name: 'WorkItem', - foreign_key: :work_item_id, source: :work_item + foreign_key: :work_item_id, source: :work_item scope :inc_relations_for_permission_check, -> { includes(:author, project: :project_feature) } @@ -34,9 +34,22 @@ class WorkItem < Issue private + override :parent_link_confidentiality + def parent_link_confidentiality + if confidential? && work_item_children.public_only.exists? + errors.add(:confidential, _('confidential parent can not be used if there are non-confidential children.')) + end + + if !confidential? && work_item_parent&.confidential? + errors.add(:confidential, _('associated parent is confidential and can not have non-confidential children.')) + end + end + def record_create_action super Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter.track_work_item_created_action(author: author) end end + +WorkItem.prepend_mod diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb index f5ebbfa59b8..13d6db3e08e 100644 --- a/app/models/work_items/parent_link.rb +++ b/app/models/work_items/parent_link.rb @@ -16,6 +16,20 @@ module WorkItems validate :validate_parent_type validate :validate_same_project validate :validate_max_children + validate :validate_confidentiality + + class << self + def has_public_children?(parent_id) + joins(:work_item).where(work_item_parent_id: parent_id, 'issues.confidential': false).exists? + end + + def has_confidential_parent?(id) + link = find_by_work_item_id(id) + return false unless link + + link.work_item_parent.confidential? + end + end private @@ -56,5 +70,14 @@ module WorkItems errors.add :work_item_parent, _('parent already has maximum number of children.') end end + + def validate_confidentiality + return unless work_item_parent && work_item + + if work_item_parent.confidential? && !work_item.confidential? + errors.add :work_item, _("cannot assign a non-confidential work item to a confidential "\ + "parent. Make the work item confidential and try again.") + end + end end end diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index e38d0ae153a..753fcbcb8f9 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -13,21 +13,23 @@ module WorkItems # Base types need to exist on the DB on app startup # This constant is used by the DB seeder BASE_TYPES = { - issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 }, - incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 }, - test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only + issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 }, + incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 }, + test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only - task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 } + task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 } }.freeze WIDGETS_FOR_TYPE = { - issue: [Widgets::Assignees, Widgets::Description, Widgets::Hierarchy, Widgets::Weight], + issue: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::StartAndDueDate], incident: [Widgets::Description, Widgets::Hierarchy], test_case: [Widgets::Description], requirement: [Widgets::Description], - task: [Widgets::Assignees, Widgets::Description, Widgets::Hierarchy, Widgets::Weight] + task: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::StartAndDueDate] }.freeze + WI_TYPES_WITH_CREATED_HEADER = %w[issue incident].freeze + cache_markdown_field :description, pipeline: :single_line enum base_type: BASE_TYPES.transform_values { |value| value[:enum_value] } @@ -83,3 +85,5 @@ module WorkItems end end end + +WorkItems::Type.prepend_mod diff --git a/app/models/work_items/widgets/labels.rb b/app/models/work_items/widgets/labels.rb new file mode 100644 index 00000000000..4ad8319ffac --- /dev/null +++ b/app/models/work_items/widgets/labels.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class Labels < Base + delegate :labels, to: :work_item + delegate :allows_scoped_labels?, to: :work_item + end + end +end diff --git a/app/models/work_items/widgets/start_and_due_date.rb b/app/models/work_items/widgets/start_and_due_date.rb new file mode 100644 index 00000000000..0b828c5b5a9 --- /dev/null +++ b/app/models/work_items/widgets/start_and_due_date.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class StartAndDueDate < Base + delegate :start_date, :due_date, to: :work_item + end + end +end diff --git a/app/models/work_items/widgets/weight.rb b/app/models/work_items/widgets/weight.rb deleted file mode 100644 index f589378f307..00000000000 --- a/app/models/work_items/widgets/weight.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - module Widgets - class Weight < Base - delegate :weight, to: :work_item - end - end -end diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb index 6dfe9cc496b..8a99f4d1a3e 100644 --- a/app/policies/ci/runner_policy.rb +++ b/app/policies/ci/runner_policy.rb @@ -31,3 +31,5 @@ module Ci rule { ~admin & locked }.prevent :assign_runner end end + +Ci::RunnerPolicy.prepend_mod_with('Ci::RunnerPolicy') diff --git a/app/policies/deployment_policy.rb b/app/policies/deployment_policy.rb index 1a92b735e36..70b2e864094 100644 --- a/app/policies/deployment_policy.rb +++ b/app/policies/deployment_policy.rb @@ -24,3 +24,5 @@ class DeploymentPolicy < BasePolicy prevent :update_deployment end end + +DeploymentPolicy.prepend_mod_with('DeploymentPolicy') diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 50b6f4bbe15..44393539327 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -180,7 +180,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :read_deploy_token enable :create_jira_connect_subscription enable :maintainer_access - enable :maintain_namespace + enable :read_upload + enable :destroy_upload end rule { owner }.policy do diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index f1efcb25331..3c5e1020c8a 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -44,6 +44,10 @@ class IssuablePolicy < BasePolicy rule { can?(:read_issue) & can?(:developer_access) }.policy do enable :admin_incident_management_timeline_event end + + rule { can?(:reporter_access) }.policy do + enable :create_timelog + end end IssuablePolicy.prepend_mod_with('IssuablePolicy') diff --git a/app/policies/namespaces/group_project_namespace_shared_policy.rb b/app/policies/namespaces/group_project_namespace_shared_policy.rb index 1ed9f05306f..bfb1706bc5a 100644 --- a/app/policies/namespaces/group_project_namespace_shared_policy.rb +++ b/app/policies/namespaces/group_project_namespace_shared_policy.rb @@ -2,8 +2,20 @@ module Namespaces class GroupProjectNamespaceSharedPolicy < ::NamespacePolicy - # Nothing here at the moment, but as we move policies from ProjectPolicy to ProjectNamespacePolicy, + # As we move policies from ProjectPolicy to ProjectNamespacePolicy, # anything common with GroupPolicy but not with UserNamespacePolicy can go in here. # See https://gitlab.com/groups/gitlab-org/-/epics/6689 + + condition(:timelog_categories_enabled, score: 0, scope: :subject) do + Feature.enabled?(:timelog_categories, @subject) + end + + rule { ~timelog_categories_enabled }.policy do + prevent :read_timelog_category + end + + rule { can?(:reporter_access) }.policy do + enable :read_timelog_category + end end end diff --git a/app/policies/namespaces/project_namespace_policy.rb b/app/policies/namespaces/project_namespace_policy.rb index 33aadc7c411..500c325138e 100644 --- a/app/policies/namespaces/project_namespace_policy.rb +++ b/app/policies/namespaces/project_namespace_policy.rb @@ -2,8 +2,8 @@ module Namespaces class ProjectNamespacePolicy < Namespaces::GroupProjectNamespaceSharedPolicy - # 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. + # TODO: once https://gitlab.com/gitlab-org/gitlab/-/issues/364277 is solved, this + # should not be necessary anymore, and should be replaced with `delegate(:project)`. + delegate(:reload_project) end end diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb index 26112332003..028247497e5 100644 --- a/app/policies/namespaces/user_namespace_policy.rb +++ b/app/policies/namespaces/user_namespace_policy.rb @@ -11,7 +11,6 @@ module Namespaces enable :owner_access enable :create_projects enable :admin_namespace - enable :maintain_namespace enable :read_namespace enable :read_statistics enable :create_jira_connect_subscription diff --git a/app/policies/project_hook_policy.rb b/app/policies/project_hook_policy.rb new file mode 100644 index 00000000000..c177fabb1ba --- /dev/null +++ b/app/policies/project_hook_policy.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ProjectHookPolicy < ::BasePolicy + delegate(:project) + + rule { can?(:admin_project) }.policy do + enable :read_web_hook + enable :destroy_web_hook + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 54270dc186e..f4f7275a78a 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -209,6 +209,9 @@ class ProjectPolicy < BasePolicy analytics operations security_and_compliance + environments + feature_flags + releases ] features.each do |f| @@ -366,7 +369,11 @@ class ProjectPolicy < BasePolicy prevent(:metrics_dashboard) end - rule { operations_disabled }.policy do + condition(:split_operations_visibility_permissions) do + ::Feature.enabled?(:split_operations_visibility_permissions, @subject) + end + + rule { ~split_operations_visibility_permissions & operations_disabled }.policy do prevent(*create_read_update_admin_destroy(:feature_flag)) prevent(*create_read_update_admin_destroy(:environment)) prevent(*create_read_update_admin_destroy(:sentry_issue)) @@ -379,6 +386,21 @@ class ProjectPolicy < BasePolicy prevent(:read_prometheus) end + rule { split_operations_visibility_permissions & environments_disabled }.policy do + prevent(*create_read_update_admin_destroy(:environment)) + prevent(*create_read_update_admin_destroy(:deployment)) + end + + rule { split_operations_visibility_permissions & feature_flags_disabled }.policy do + prevent(*create_read_update_admin_destroy(:feature_flag)) + prevent(:admin_feature_flags_user_lists) + prevent(:admin_feature_flags_client) + end + + rule { split_operations_visibility_permissions & releases_disabled }.policy do + prevent(*create_read_update_admin_destroy(:release)) + end + rule { can?(:metrics_dashboard) }.policy do enable :read_prometheus enable :read_deployment @@ -470,6 +492,7 @@ class ProjectPolicy < BasePolicy enable :admin_pipeline enable :admin_environment enable :admin_deployment + enable :destroy_deployment enable :admin_pages enable :read_pages enable :update_pages @@ -497,6 +520,8 @@ class ProjectPolicy < BasePolicy enable :admin_project_google_cloud enable :admin_secure_files enable :read_web_hooks + enable :read_upload + enable :destroy_upload end rule { public_project & metrics_dashboard_allowed }.policy do diff --git a/app/policies/system_hook_policy.rb b/app/policies/system_hook_policy.rb new file mode 100644 index 00000000000..ec28d39a5fa --- /dev/null +++ b/app/policies/system_hook_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class SystemHookPolicy < ::BasePolicy + rule { admin }.policy do + enable :read_web_hook + enable :destroy_web_hook + end +end diff --git a/app/policies/time_tracking/timelog_category_policy.rb b/app/policies/time_tracking/timelog_category_policy.rb new file mode 100644 index 00000000000..89161cdacfb --- /dev/null +++ b/app/policies/time_tracking/timelog_category_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module TimeTracking + class TimelogCategoryPolicy < BasePolicy + delegate { @subject.namespace } + end +end diff --git a/app/policies/upload_policy.rb b/app/policies/upload_policy.rb new file mode 100644 index 00000000000..c7fde5d9df4 --- /dev/null +++ b/app/policies/upload_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class UploadPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass + delegate { @subject.model } +end diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb index 2f3561f1135..1ccc152bc6b 100644 --- a/app/policies/work_item_policy.rb +++ b/app/policies/work_item_policy.rb @@ -3,9 +3,12 @@ class WorkItemPolicy < IssuePolicy condition(:is_member_and_author) { is_project_member? & is_author? } + rule { can?(:admin_issue) }.enable :admin_work_item + rule { can?(:destroy_issue) | is_member_and_author }.enable :delete_work_item rule { can?(:update_issue) }.enable :update_work_item + rule { can?(:set_issue_metadata) }.enable :set_work_item_metadata rule { can?(:read_issue) }.enable :read_work_item # because IssuePolicy delegates to ProjectPolicy and diff --git a/app/presenters/analytics/cycle_analytics/stage_presenter.rb b/app/presenters/analytics/cycle_analytics/stage_presenter.rb index 7b295b814bc..d023b0c5d55 100644 --- a/app/presenters/analytics/cycle_analytics/stage_presenter.rb +++ b/app/presenters/analytics/cycle_analytics/stage_presenter.rb @@ -28,7 +28,7 @@ module Analytics description: _('Time before an issue gets scheduled') }, plan: { - title: s_('CycleAnalyticsStage|Plan'), + title: s_('CycleAnalyticsStage|Plan'), description: _('Time before an issue starts implementation') }, code: { diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 015dfc16df0..71a05ef2c72 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -33,7 +33,8 @@ module Ci end def runner_variables - variables.sort_and_expand_all(keep_undefined: true).to_runner_variables + stop_expanding_file_vars = ::Feature.enabled?(:ci_stop_expanding_file_vars_for_runners, project) + variables.sort_and_expand_all(keep_undefined: true, expand_file_vars: !stop_expanding_file_vars).to_runner_variables end def refspecs diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index efab1e84923..417a2f9c51f 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -191,18 +191,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def mergeable_discussions_state - if Feature.enabled?(:change_response_code_merge_status, project) - merge_request.mergeable_discussions_state? - else - # This avoids calling MergeRequest#mergeable_discussions_state without - # considering the state of the MR first. If a MR isn't mergeable, we can - # safely short-circuit it. - if merge_request.mergeable_state?(skip_ci_check: true, skip_discussions_check: true) - merge_request.mergeable_discussions_state? - else - false - end - end + merge_request.mergeable_discussions_state? end delegator_override :subscribed? diff --git a/app/presenters/project_hook_presenter.rb b/app/presenters/project_hook_presenter.rb index a696e9fd0ec..76a3a187924 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 ::ProjectHook, as: :project_hook + presents ::ProjectHook 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 91d3ae96877..da24972775a 100644 --- a/app/presenters/project_member_presenter.rb +++ b/app/presenters/project_member_presenter.rb @@ -3,6 +3,24 @@ class ProjectMemberPresenter < MemberPresenter presents ::ProjectMember + def access_level_roles + ProjectMember.permissible_access_level_roles(current_user, source) + end + + def can_remove? + # If this user is attempting to manage an Owner member and doesn't have permission, do not allow + return can_manage_owners? if member.owner? + + super + end + + def can_update? + # If this user is attempting to manage an Owner member and doesn't have permission, do not allow + return can_manage_owners? if member.owner? + + super + end + private def admin_member_permission @@ -16,6 +34,10 @@ class ProjectMemberPresenter < MemberPresenter def destroy_member_permission :destroy_project_member end + + def can_manage_owners? + can?(current_user, :manage_owners, source) + end end ProjectMemberPresenter.prepend_mod_with('ProjectMemberPresenter') diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 84aec19cba0..209f016dc6b 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -437,9 +437,9 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated project_new_blob_path( project, default_branch_or_main, - file_name: file_name, + file_name: file_name, commit_message: commit_message, - branch_name: branch_name, + branch_name: branch_name, **additional_params ) end diff --git a/app/presenters/service_hook_presenter.rb b/app/presenters/service_hook_presenter.rb index b34679c85cf..7ec3d7c5b5c 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 ::ServiceHook, as: :service_hook + presents ::ServiceHook def logs_details_path(log) project_settings_integration_hook_log_path(integration.project, integration, log) diff --git a/app/presenters/web_hook_log_presenter.rb b/app/presenters/web_hook_log_presenter.rb index a5166589073..30941076913 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 ::WebHookLog, as: :web_hook_log + presents ::WebHookLog def details_path web_hook.present.logs_details_path(self) diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb index ca2854224a7..38b3c16dd2a 100644 --- a/app/serializers/concerns/user_status_tooltip.rb +++ b/app/serializers/concerns/user_status_tooltip.rb @@ -13,7 +13,7 @@ module UserStatusTooltip end expose :show_status do |user| - status_loaded? && show_status_emoji?(user.status) + status_loaded? && !!user.status&.customized? end expose :availability, if: -> (*) { status_loaded? } do |user| diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb index 3f236fa55df..6363d6276a7 100644 --- a/app/serializers/environment_serializer.rb +++ b/app/serializers/environment_serializer.rb @@ -34,8 +34,8 @@ class EnvironmentSerializer < BaseSerializer # rubocop: disable CodeReuse/ActiveRecord def itemize(resource) items = resource.order('folder ASC') - .group('COALESCE(environment_type, name)') - .select('COALESCE(environment_type, name) AS folder', + .group('COALESCE(environment_type, id::text)', 'COALESCE(environment_type, name)') + .select('COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS folder', 'COUNT(*) AS size', 'MAX(id) AS last_id') # It makes a difference when you call `paginate` method, because @@ -54,11 +54,7 @@ class EnvironmentSerializer < BaseSerializer def batch_load(resource) temp_deployment_associations = deployment_associations - resource = resource.preload(environment_associations.except(:last_deployment, :upcoming_deployment)) - - if ::Feature.enabled?(:batch_load_environment_last_deployment_group, resource.first&.project) - temp_deployment_associations[:deployable][:pipeline][:latest_successful_builds] = [] - end + resource = resource.preload(environment_associations) Preloaders::Environments::DeploymentPreloader.new(resource) .execute_with_union(:last_deployment, temp_deployment_associations) @@ -72,18 +68,14 @@ class EnvironmentSerializer < BaseSerializer environment.last_deployment&.commit&.try(:lazy_author) environment.upcoming_deployment&.commit&.try(:lazy_author) - if ::Feature.enabled?(:batch_load_environment_last_deployment_group, environment.project) - # Batch loading last_deployment_group which is called later by environment.stop_actions - environment.last_deployment_group - end + # Batch loading last_deployment_group which is called later by environment.stop_actions + environment.last_deployment_group end end end def environment_associations { - last_deployment: deployment_associations, - upcoming_deployment: deployment_associations, project: project_associations } end @@ -101,7 +93,8 @@ class EnvironmentSerializer < BaseSerializer metadata: [], pipeline: { manual_actions: [:metadata, :deployment], - scheduled_actions: [:metadata] + scheduled_actions: [:metadata], + latest_successful_builds: [] }, project: project_associations, deployment: [] diff --git a/app/serializers/group_access_token_entity.rb b/app/serializers/group_access_token_entity.rb new file mode 100644 index 00000000000..e832eef1188 --- /dev/null +++ b/app/serializers/group_access_token_entity.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass +class GroupAccessTokenEntity < API::Entities::PersonalAccessToken + include Gitlab::Routing + + expose :revoke_path do |token, options| + group = options.fetch(:group) + + next unless group + + revoke_group_settings_access_token_path( + id: token, + group_id: group.path) + end + + expose :access_level do |token, options| + group = options.fetch(:group) + + next unless group + next unless token.user + + group.member(token.user)&.access_level + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/group_access_token_serializer.rb b/app/serializers/group_access_token_serializer.rb new file mode 100644 index 00000000000..55f6de77844 --- /dev/null +++ b/app/serializers/group_access_token_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass +class GroupAccessTokenSerializer < BaseSerializer + entity GroupAccessTokenEntity +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/integrations/project_entity.rb b/app/serializers/integrations/project_entity.rb index ee28c7c19c1..c091133eb39 100644 --- a/app/serializers/integrations/project_entity.rb +++ b/app/serializers/integrations/project_entity.rb @@ -4,6 +4,7 @@ module Integrations class ProjectEntity < Grape::Entity include RequestAwareEntity + expose :id expose :avatar_url expose :full_name expose :name diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index ea43ed87d22..7ff75927fcd 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -47,6 +47,10 @@ class IssueEntity < IssuableEntity can?(request.current_user, :update_issue, issue) end + expose :can_set_issue_metadata do |issue| + can?(request.current_user, :set_issue_metadata, issue) + end + expose :can_award_emoji do |issue| can?(request.current_user, :award_emoji, issue) end diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index fc1534a88aa..40bb905c5c9 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -33,18 +33,7 @@ class MergeRequestPollWidgetEntity < Grape::Entity # Booleans expose :mergeable_discussions_state?, as: :mergeable_discussions_state do |merge_request| - if Feature.enabled?(:change_response_code_merge_status, merge_request.project) - merge_request.mergeable_discussions_state? - else - # This avoids calling MergeRequest#mergeable_discussions_state without - # considering the state of the MR first. If a MR isn't mergeable, we can - # safely short-circuit it. - if merge_request.mergeable_state?(skip_ci_check: true, skip_discussions_check: true) - merge_request.mergeable_discussions_state? - else - false - end - end + merge_request.mergeable_discussions_state? end expose :project_archived do |merge_request| diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb index 12c573d1a13..2e875af6531 100644 --- a/app/serializers/merge_request_user_entity.rb +++ b/app/serializers/merge_request_user_entity.rb @@ -20,10 +20,6 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic find_reviewer_or_assignee(user, options)&.reviewed? end - expose :attention_requested, if: ->(_, options) { options[:merge_request].present? && options[:merge_request].allows_reviewers? && request.current_user&.mr_attention_requests_enabled? } do |user, options| - find_reviewer_or_assignee(user, options)&.attention_requested? - end - expose :approved, if: satisfies(:present?) do |user, options| # This approach is preferred over MergeRequest#approved_by? since this # makes one query per merge request, whereas #approved_by? makes one per user diff --git a/app/serializers/personal_access_token_entity.rb b/app/serializers/personal_access_token_entity.rb new file mode 100644 index 00000000000..acd06fecd12 --- /dev/null +++ b/app/serializers/personal_access_token_entity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass +class PersonalAccessTokenEntity < API::Entities::PersonalAccessToken + include Gitlab::Routing + + expose :revoke_path do |token, options| + revoke_profile_personal_access_token_path(token) + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/personal_access_token_serializer.rb b/app/serializers/personal_access_token_serializer.rb new file mode 100644 index 00000000000..0a59fa117f9 --- /dev/null +++ b/app/serializers/personal_access_token_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass +class PersonalAccessTokenSerializer < BaseSerializer + entity PersonalAccessTokenEntity +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/project_access_token_entity.rb b/app/serializers/project_access_token_entity.rb new file mode 100644 index 00000000000..b317057c952 --- /dev/null +++ b/app/serializers/project_access_token_entity.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass +class ProjectAccessTokenEntity < API::Entities::PersonalAccessToken + include Gitlab::Routing + + expose :revoke_path do |token, options| + project = options.fetch(:project) + + next unless project + + revoke_namespace_project_settings_access_token_path( + id: token, + namespace_id: project.namespace.path, + project_id: project.path) + end + + expose :access_level do |token, options| + project = options.fetch(:project) + + next unless project + next unless token.user + + project.member(token.user)&.access_level + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/project_access_token_serializer.rb b/app/serializers/project_access_token_serializer.rb new file mode 100644 index 00000000000..97db088cf64 --- /dev/null +++ b/app/serializers/project_access_token_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass +class ProjectAccessTokenSerializer < BaseSerializer + entity ProjectAccessTokenEntity +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/rollout_status_entity.rb b/app/serializers/rollout_status_entity.rb index 9f4c844859b..f432fe98289 100644 --- a/app/serializers/rollout_status_entity.rb +++ b/app/serializers/rollout_status_entity.rb @@ -14,5 +14,5 @@ class RolloutStatusEntity < Grape::Entity expose :completion, if: -> (rollout_status, _) { rollout_status.found? } expose :complete?, as: :is_completed, if: -> (rollout_status, _) { rollout_status.found? } expose :canary_ingress, using: RolloutStatuses::IngressEntity, expose_nil: false, - if: -> (rollout_status, _) { rollout_status.found? && rollout_status.canary_ingress_exists? } + if: -> (rollout_status, _) { rollout_status.found? && rollout_status.canary_ingress_exists? } end diff --git a/app/services/audit_events/build_service.rb b/app/services/audit_events/build_service.rb new file mode 100644 index 00000000000..f5322fa5ff4 --- /dev/null +++ b/app/services/audit_events/build_service.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module AuditEvents + class BuildService + # Handle missing attributes + MissingAttributeError = Class.new(StandardError) + + # @raise [MissingAttributeError] when required attributes are blank + # + # @return [BuildService] + def initialize( + author:, scope:, target:, message:, + created_at: DateTime.current, additional_details: {}, ip_address: nil, target_details: nil) + raise MissingAttributeError if missing_attribute?(author, scope, target, message) + + @author = build_author(author) + @scope = scope + @target = build_target(target) + @ip_address = ip_address || build_ip_address + @message = build_message(message) + @created_at = created_at + @additional_details = additional_details + @target_details = target_details + end + + # Create an instance of AuditEvent + # + # @return [AuditEvent] + def execute + AuditEvent.new(payload) + end + + private + + def missing_attribute?(author, scope, target, message) + author.blank? || scope.blank? || target.blank? || message.blank? + end + + def payload + base_payload.merge(details: base_details_payload) + end + + def base_payload + { + author_id: @author.id, + author_name: @author.name, + entity_id: @scope.id, + entity_type: @scope.class.name, + created_at: @created_at + } + end + + def base_details_payload + @additional_details.merge({ + author_name: @author.name, + author_class: @author.class.name, + target_id: @target.id, + target_type: @target.type, + target_details: @target_details || @target.details, + custom_message: @message + }) + end + + def build_author(author) + author.id = -2 if author.instance_of? DeployToken + author.id = -3 if author.instance_of? DeployKey + + author + end + + def build_target(target) + return target if target.is_a? ::Gitlab::Audit::NullTarget + + ::Gitlab::Audit::Target.new(target) + end + + def build_message(message) + message + end + + def build_ip_address + Gitlab::RequestContext.instance.client_ip || @author.current_sign_in_ip + end + end +end + +AuditEvents::BuildService.prepend_mod_with('AuditEvents::BuildService') diff --git a/app/services/authorized_project_update/project_recalculate_service.rb b/app/services/authorized_project_update/project_recalculate_service.rb index 17ba48cffcd..e0b8158417c 100644 --- a/app/services/authorized_project_update/project_recalculate_service.rb +++ b/app/services/authorized_project_update/project_recalculate_service.rb @@ -47,7 +47,7 @@ module AuthorizedProjectUpdate def user_ids_to_remove strong_memoize(:user_ids_to_remove) do (current_authorizations - fresh_authorizations) - .map {|user_id, _| user_id } + .map { |user_id, _| user_id } end end diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index 9e49bd86ec0..1660ddb934f 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -59,6 +59,7 @@ module AutoMerge !merge_request.broken? && !merge_request.draft? && merge_request.mergeable_discussions_state? && + !merge_request.merge_blocked_by_other_mrs? && yield end end diff --git a/app/services/base_count_service.rb b/app/services/base_count_service.rb index ff1949ce4dd..eff3eb33c71 100644 --- a/app/services/base_count_service.rb +++ b/app/services/base_count_service.rb @@ -45,7 +45,7 @@ class BaseCountService end def update_cache_for_key(key, &block) - Rails.cache.write(key, block_given? ? yield : uncached_count, raw: raw?) + Rails.cache.write(key, block ? yield : uncached_count, raw: raw?) end end diff --git a/app/services/boards/destroy_service.rb b/app/services/boards/destroy_service.rb index 0b1cd61b119..ceda005044e 100644 --- a/app/services/boards/destroy_service.rb +++ b/app/services/boards/destroy_service.rb @@ -3,10 +3,6 @@ module Boards class DestroyService < Boards::BaseService def execute(board) - if boards.size == 1 - return ServiceResponse.error(message: "The board could not be deleted, because the parent doesn't have any other boards.") - end - board.destroy! ServiceResponse.success diff --git a/app/services/boards/lists/move_service.rb b/app/services/boards/lists/move_service.rb index 93f81837d1a..4bb7b4dbc6d 100644 --- a/app/services/boards/lists/move_service.rb +++ b/app/services/boards/lists/move_service.rb @@ -23,7 +23,7 @@ module Boards def valid_move? new_position.present? && new_position != old_position && - new_position >= 0 && new_position < board.lists.movable.size + new_position >= 0 && new_position <= board.lists.movable.last.position end def reorder_intermediate_lists diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb index 7300b31e3b3..5cbd587e546 100644 --- a/app/services/branches/create_service.rb +++ b/app/services/branches/create_service.rb @@ -2,35 +2,91 @@ module Branches class CreateService < BaseService + def initialize(project, user = nil, params = {}) + super(project, user, params) + + @errors = [] + end + def execute(branch_name, ref, create_default_branch_if_empty: true) create_default_branch if create_default_branch_if_empty && project.empty_repo? - result = ::Branches::ValidateNewService.new(project).execute(branch_name) + result = branch_validation_service.execute(branch_name) return result if result[:status] == :error - begin - new_branch = repository.add_branch(current_user, branch_name, ref) - rescue Gitlab::Git::CommandError => e - return error("Failed to create branch '#{branch_name}': #{e}") + create_branch(branch_name, ref) + end + + def bulk_create(branches) + reset_errors + + created_branches = + branches + .then { |branches| only_valid_branches(branches) } + .then { |branches| create_branches(branches) } + .then { |branches| expire_branches_cache(branches) } + + return error(errors) if errors.present? + + success(branches: created_branches) + end + + private + + attr_reader :errors + + def reset_errors + @errors = [] + end + + def only_valid_branches(branches) + branches.select do |branch_name, _ref| + result = branch_validation_service.execute(branch_name) + + if result[:status] == :error + errors << result[:message] + next + end + + true end + end + + def create_branches(branches) + branches.filter_map do |branch_name, ref| + result = create_branch(branch_name, ref, expire_cache: false) + + if result[:status] == :error + errors << result[:message] + next + end + + result[:branch] + end + end + + def expire_branches_cache(branches) + repository.expire_branches_cache if branches.present? + + branches + end + + def create_branch(branch_name, ref, expire_cache: true) + new_branch = repository.add_branch(current_user, branch_name, ref, expire_cache: expire_cache) if new_branch - success(new_branch) + success(branch: new_branch) else error("Failed to create branch '#{branch_name}': invalid reference name '#{ref}'") end + rescue Gitlab::Git::CommandError => e + error("Failed to create branch '#{branch_name}': #{e}") rescue Gitlab::Git::PreReceiveError => e Gitlab::ErrorTracking.log_exception(e, pre_receive_message: e.raw_message, branch_name: branch_name, ref: ref) error(e.message) end - def success(branch) - super().merge(branch: branch) - end - - private - def create_default_branch project.repository.create_file( current_user, @@ -40,5 +96,9 @@ module Branches branch_name: project.default_branch_or_main ) end + + def branch_validation_service + @branch_validation_service ||= ::Branches::ValidateNewService.new(project) + end end end diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb index cbf2b34b33c..31e1a822e78 100644 --- a/app/services/bulk_imports/create_service.rb +++ b/app/services/bulk_imports/create_service.rb @@ -64,7 +64,7 @@ module BulkImports bulk_import: bulk_import, source_type: entity[:source_type], source_full_path: entity[:source_full_path], - destination_name: entity[:destination_name], + destination_slug: entity[:destination_slug], destination_namespace: entity[:destination_namespace] ) end diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb index 8d6ba54cd50..a2c8ba5b1cd 100644 --- a/app/services/bulk_imports/file_download_service.rb +++ b/app/services/bulk_imports/file_download_service.rb @@ -55,12 +55,17 @@ module BulkImports bytes_downloaded = 0 http_client.stream(relative_url) do |chunk| + next if bytes_downloaded == 0 && [301, 302, 303, 307, 308].include?(chunk.code) + bytes_downloaded += chunk.size validate_size!(bytes_downloaded) - raise(ServiceError, "File download error #{chunk.code}") unless chunk.code == 200 - file.write(chunk) + if chunk.code == 200 + file.write(chunk) + else + raise(ServiceError, "File download error #{chunk.code}") + end end end rescue StandardError => e diff --git a/app/services/chat_names/authorize_user_service.rb b/app/services/chat_names/authorize_user_service.rb index f7780488923..6c28a1cea7e 100644 --- a/app/services/chat_names/authorize_user_service.rb +++ b/app/services/chat_names/authorize_user_service.rb @@ -4,8 +4,8 @@ module ChatNames class AuthorizeUserService include Gitlab::Routing - def initialize(service, params) - @service = service + def initialize(integration, params) + @integration = integration @params = params end @@ -29,11 +29,11 @@ module ChatNames def chat_name_params { - service_id: @service.id, - team_id: @params[:team_id], + integration_id: @integration.id, + team_id: @params[:team_id], team_domain: @params[:team_domain], - chat_id: @params[:user_id], - chat_name: @params[:user_name] + chat_id: @params[:user_id], + chat_name: @params[:user_name] } end end diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb index 7b1d2207460..9705a236d98 100644 --- a/app/services/ci/archive_trace_service.rb +++ b/app/services/ci/archive_trace_service.rb @@ -62,8 +62,8 @@ module Ci failed_archive_counter.increment Sidekiq.logger.warn(class: worker_name, - message: "Failed to archive trace. message: #{error.message}.", - job_id: job.id) + message: "Failed to archive trace. message: #{error.message}.", + job_id: job.id) Gitlab::ErrorTracking .track_and_raise_for_dev_exception(error, diff --git a/app/services/ci/deployments/destroy_service.rb b/app/services/ci/deployments/destroy_service.rb new file mode 100644 index 00000000000..ac51fa55537 --- /dev/null +++ b/app/services/ci/deployments/destroy_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ci + module Deployments + class DestroyService < BaseService + def execute(deployment) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_deployment, deployment) + + return ServiceResponse.error(message: 'Cannot destroy running deployment') if deployment&.running? + return ServiceResponse.error(message: 'Deployment currently deployed to environment') if deployment&.last? + + project.destroy_deployment_by_id(deployment) + + ServiceResponse.success(message: 'Deployment destroyed') + end + end + end +end diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb index d85e52e1312..1c563396162 100644 --- a/app/services/ci/destroy_pipeline_service.rb +++ b/app/services/ci/destroy_pipeline_service.rb @@ -7,7 +7,7 @@ module Ci Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true) - pipeline.cancel_running if pipeline.cancelable? + pipeline.cancel_running(cascade_to_children: true, execute_async: false) if pipeline.cancelable? # The pipeline, the builds, job and pipeline artifacts all get destroyed here. # Ci::Pipeline#destroy triggers fast destroy on job_artifacts and diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index 05f8e804c67..af56eb221d5 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -126,6 +126,8 @@ module Ci job.update_column(:artifacts_expire_at, artifact.expire_at) end + Gitlab::Ci::Artifacts::Logger.log_created(artifact) + success(artifact: artifact) rescue ActiveRecord::RecordNotUnique => error track_exception(error, params) diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb index 9d6b413ce59..54ec2c671c6 100644 --- a/app/services/ci/job_artifacts/destroy_batch_service.rb +++ b/app/services/ci/job_artifacts/destroy_batch_service.rb @@ -53,8 +53,10 @@ module Ci update_project_statistics! if update_stats increment_monitoring_statistics(artifacts_count, artifacts_bytes) + Gitlab::Ci::Artifacts::Logger.log_deleted(@job_artifacts, 'Ci::JobArtifacts::DestroyBatchService#execute') + success(destroyed_artifacts_count: artifacts_count, - statistics_updates: affected_project_statistics) + statistics_updates: affected_project_statistics) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/ci/list_config_variables_service.rb b/app/services/ci/list_config_variables_service.rb index 88dac514bb9..c791a89b804 100644 --- a/app/services/ci/list_config_variables_service.rb +++ b/app/services/ci/list_config_variables_service.rb @@ -26,8 +26,8 @@ module Ci return {} unless config result = Gitlab::Ci::YamlProcessor.new(config, project: project, - user: current_user, - sha: sha).execute + user: current_user, + sha: sha).execute result.valid? ? result.variables_with_data : {} end diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb index 40e2cd82b4f..fd13ed245cf 100644 --- a/app/services/ci/parse_dotenv_artifact_service.rb +++ b/app/services/ci/parse_dotenv_artifact_service.rb @@ -40,7 +40,7 @@ module Ci key, value = scan_line!(line) variables[key] = Ci::JobVariable.new(job_id: artifact.job_id, - source: :dotenv, key: key, value: value) + source: :dotenv, key: key, value: value) end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 8969b95b81f..b357855db12 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -4,6 +4,8 @@ module Ci # This class responsible for assigning # proper pending build to runner on runner API request class RegisterJobService + include ::Gitlab::Ci::Artifacts::Logger + attr_reader :runner, :metrics TEMPORARY_LOCK_TIMEOUT = 3.seconds @@ -220,10 +222,26 @@ module Ci # We need to use the presenter here because Gitaly calls in the presenter # may fail, and we need to ensure the response has been generated. presented_build = ::Ci::BuildRunnerPresenter.new(build) # rubocop:disable CodeReuse/Presenter + + log_artifacts_context(build) + log_build_dependencies_size(presented_build) + build_json = ::API::Entities::Ci::JobRequest::Response.new(presented_build).to_json Result.new(build, build_json, true) end + def log_build_dependencies_size(presented_build) + return unless ::Feature.enabled?(:ci_build_dependencies_artifacts_logger, type: :ops) + + presented_build.all_dependencies.then do |dependencies| + size = dependencies.sum do |build| + build.available_artifacts? ? build.artifacts_file.size : 0 + end + + log_build_dependencies(size: size, count: dependencies.size) if size > 0 + end + end + def assign_runner!(build, params) build.runner_id = runner.id build.runner_session_attributes = params[:session] if params[:session].present? diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb index e0ced3d0197..25bda8a6380 100644 --- a/app/services/ci/retry_job_service.rb +++ b/app/services/ci/retry_job_service.rb @@ -4,10 +4,10 @@ module Ci class RetryJobService < ::BaseService include Gitlab::Utils::StrongMemoize - def execute(job) + def execute(job, variables: []) if job.retryable? job.ensure_scheduling_type! - new_job = retry_job(job) + new_job = retry_job(job, variables: variables) ServiceResponse.success(payload: { job: new_job }) else @@ -19,7 +19,7 @@ module Ci end # rubocop: disable CodeReuse/ActiveRecord - def clone!(job) + def clone!(job, variables: []) # Cloning a job 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. @@ -27,7 +27,7 @@ module Ci check_access!(job) - new_job = job.clone(current_user: current_user) + new_job = job.clone(current_user: current_user, new_job_variables_attributes: variables) new_job.run_after_commit do ::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job) @@ -55,8 +55,8 @@ module Ci def check_assignable_runners!(job); end - def retry_job(job) - clone!(job).tap do |new_job| + def retry_job(job, variables: []) + clone!(job, variables: variables).tap do |new_job| check_assignable_runners!(new_job) if new_job.is_a?(Ci::Build) next if new_job.failed? diff --git a/app/services/ci/runners/assign_runner_service.rb b/app/services/ci/runners/assign_runner_service.rb index 886cd3a4e44..290f945cc72 100644 --- a/app/services/ci/runners/assign_runner_service.rb +++ b/app/services/ci/runners/assign_runner_service.rb @@ -13,9 +13,15 @@ module Ci end def execute - return false unless @user.present? && @user.can?(:assign_runner, @runner) + unless @user.present? && @user.can?(:assign_runner, @runner) + return ServiceResponse.error(message: 'user not allowed to assign runner', http_status: :forbidden) + end - @runner.assign_to(@project, @user) + if @runner.assign_to(@project, @user) + ServiceResponse.success + else + ServiceResponse.error(message: 'failed to assign runner') + end end private diff --git a/app/services/ci/runners/bulk_delete_runners_service.rb b/app/services/ci/runners/bulk_delete_runners_service.rb new file mode 100644 index 00000000000..ce07aa541c2 --- /dev/null +++ b/app/services/ci/runners/bulk_delete_runners_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Ci + module Runners + class BulkDeleteRunnersService + attr_reader :runners + + RUNNER_LIMIT = 50 + + # @param runners [Array<Ci::Runner, Integer>] the runners to unregister/destroy + def initialize(runners:) + @runners = runners + end + + def execute + if @runners + # Delete a few runners immediately + return ServiceResponse.success(payload: delete_runners) + end + + ServiceResponse.success(payload: { deleted_count: 0, deleted_ids: [] }) + end + + private + + def delete_runners + # rubocop:disable CodeReuse/ActiveRecord + runners_to_be_deleted = Ci::Runner.where(id: @runners).limit(RUNNER_LIMIT) + # rubocop:enable CodeReuse/ActiveRecord + deleted_ids = runners_to_be_deleted.destroy_all.map(&:id) # rubocop: disable Cop/DestroyAll + + { deleted_count: deleted_ids.count, deleted_ids: deleted_ids } + end + end + end +end diff --git a/app/services/ci/runners/process_runner_version_update_service.rb b/app/services/ci/runners/process_runner_version_update_service.rb new file mode 100644 index 00000000000..c8a5e42ccab --- /dev/null +++ b/app/services/ci/runners/process_runner_version_update_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Ci + module Runners + class ProcessRunnerVersionUpdateService + def initialize(version) + @version = version + end + + def execute + return ServiceResponse.error(message: 'version not present') unless @version + + _, status = upgrade_check_service.check_runner_upgrade_suggestion(@version) + return ServiceResponse.error(message: 'upgrade version check failed') if status == :error + + Ci::RunnerVersion.upsert({ version: @version, status: status }) + ServiceResponse.success(payload: { upgrade_status: status.to_s }) + end + + private + + def upgrade_check_service + @runner_upgrade_check ||= Gitlab::Ci::RunnerUpgradeCheck.new(::Gitlab::VERSION) + end + end + end +end diff --git a/app/services/ci/runners/reconcile_existing_runner_versions_service.rb b/app/services/ci/runners/reconcile_existing_runner_versions_service.rb index e04079bfe27..1950d82845b 100644 --- a/app/services/ci/runners/reconcile_existing_runner_versions_service.rb +++ b/app/services/ci/runners/reconcile_existing_runner_versions_service.rb @@ -3,8 +3,6 @@ module Ci module Runners class ReconcileExistingRunnerVersionsService - include BaseServiceUtility - VERSION_BATCH_SIZE = 100 def execute @@ -12,7 +10,7 @@ module Ci total_deleted = cleanup_runner_versions(insert_result[:versions_from_runners]) total_updated = update_status_on_outdated_runner_versions(insert_result[:versions_from_runners]) - success({ + ServiceResponse.success(payload: { total_inserted: insert_result[:new_record_count], total_updated: total_updated, total_deleted: total_deleted @@ -22,7 +20,7 @@ module Ci private def upgrade_check - Gitlab::Ci::RunnerUpgradeCheck.instance + @runner_upgrade_check ||= Gitlab::Ci::RunnerUpgradeCheck.new(::Gitlab::VERSION) end # rubocop: disable CodeReuse/ActiveRecord @@ -74,13 +72,11 @@ module Ci end def runner_version_with_updated_status(runner_version) - version = runner_version['version'] - suggestion = upgrade_check.check_runner_upgrade_status(version) - new_status = suggestion.each_key.first + _, new_status = upgrade_check.check_runner_upgrade_suggestion(runner_version.version) - if new_status != :error && new_status != runner_version['status'].to_sym + if new_status != :error && new_status != runner_version.status.to_sym { - version: version, + version: runner_version.version, status: Ci::RunnerVersion.statuses[new_status] } end diff --git a/app/services/ci/runners/register_runner_service.rb b/app/services/ci/runners/register_runner_service.rb index 6588cd7e248..ae9b8bc8a16 100644 --- a/app/services/ci/runners/register_runner_service.rb +++ b/app/services/ci/runners/register_runner_service.rb @@ -6,7 +6,7 @@ module Ci def execute(registration_token, attributes) runner_type_attrs = extract_runner_type_attrs(registration_token) - return unless runner_type_attrs + return ServiceResponse.error(message: 'invalid token supplied', http_status: :forbidden) unless runner_type_attrs runner = ::Ci::Runner.new(attributes.merge(runner_type_attrs)) @@ -20,7 +20,7 @@ module Ci end end - runner + ServiceResponse.success(payload: { runner: runner }) end private diff --git a/app/services/ci/runners/reset_registration_token_service.rb b/app/services/ci/runners/reset_registration_token_service.rb index 81a70a771cf..dddbfb78d44 100644 --- a/app/services/ci/runners/reset_registration_token_service.rb +++ b/app/services/ci/runners/reset_registration_token_service.rb @@ -11,15 +11,19 @@ module Ci end def execute - return unless @user.present? && @user.can?(:update_runners_registration_token, scope) + unless @user.present? && @user.can?(:update_runners_registration_token, scope) + return ServiceResponse.error(message: 'user not allowed to update runners registration token') + end if scope.respond_to?(:runners_registration_token) scope.reset_runners_registration_token! - scope.runners_registration_token + runners_token = scope.runners_registration_token else scope.reset_runners_token! - scope.runners_token + runners_token = scope.runners_token end + + ServiceResponse.success(payload: { new_registration_token: runners_token }) end private diff --git a/app/services/ci/runners/unassign_runner_service.rb b/app/services/ci/runners/unassign_runner_service.rb index 1e46cf6add8..c40e5e0d44e 100644 --- a/app/services/ci/runners/unassign_runner_service.rb +++ b/app/services/ci/runners/unassign_runner_service.rb @@ -13,9 +13,15 @@ module Ci end def execute - return false unless @user.present? && @user.can?(:assign_runner, @runner) + unless @user.present? && @user.can?(:assign_runner, @runner) + return ServiceResponse.error(message: 'user not allowed to assign runner') + end - @runner_project.destroy + if @runner_project.destroy + ServiceResponse.success + else + ServiceResponse.error(message: 'failed to destroy runner project') + end end private diff --git a/app/services/ci/runners/unregister_runner_service.rb b/app/services/ci/runners/unregister_runner_service.rb index 4ee1e73c458..742b21f77df 100644 --- a/app/services/ci/runners/unregister_runner_service.rb +++ b/app/services/ci/runners/unregister_runner_service.rb @@ -14,6 +14,7 @@ module Ci def execute @runner&.destroy + ServiceResponse.success end end end diff --git a/app/services/ci/stuck_builds/drop_helpers.rb b/app/services/ci/stuck_builds/drop_helpers.rb index 048b52c6e13..dca50963883 100644 --- a/app/services/ci/stuck_builds/drop_helpers.rb +++ b/app/services/ci/stuck_builds/drop_helpers.rb @@ -56,12 +56,12 @@ module Ci def log_dropping_message(type, build, reason) Gitlab::AppLogger.info(class: self.class.name, - message: "Dropping #{type} build", - build_stuck_type: type, - build_id: build.id, - runner_id: build.runner_id, - build_status: build.status, - build_failure_reason: reason) + message: "Dropping #{type} build", + build_stuck_type: type, + build_id: build.id, + runner_id: build.runner_id, + build_status: build.status, + build_failure_reason: reason) end end end diff --git a/app/services/ci/track_failed_build_service.rb b/app/services/ci/track_failed_build_service.rb new file mode 100644 index 00000000000..caf7034234c --- /dev/null +++ b/app/services/ci/track_failed_build_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# This service tracks failed CI builds using Snowplow. +# +# @param build [Ci::Build] the build that failed. +# @param exit_code [Int] the resulting exit code. +module Ci + class TrackFailedBuildService + SCHEMA_URL = 'iglu:com.gitlab/ci_build_failed/jsonschema/1-0-0' + + def initialize(build:, exit_code:, failure_reason:) + @build = build + @exit_code = exit_code + @failure_reason = failure_reason + end + + def execute + # rubocop:disable Style/IfUnlessModifier + unless @build.failed? + return ServiceResponse.error(message: 'Attempted to track a non-failed CI build') + end + + # rubocop:enable Style/IfUnlessModifier + + context = SnowplowTracker::SelfDescribingJson.new(SCHEMA_URL, payload) + + ::Gitlab::Tracking.event( + 'ci::build', + 'failed', + context: [context], + user: @build.user, + project: @build.project_id) + + ServiceResponse.success + end + + private + + def payload + { + build_id: @build.id, + build_name: @build.name, + build_artifact_types: @build.job_artifact_types, + exit_code: @exit_code, + failure_reason: @failure_reason + } + end + end +end diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb index a74ddcfaf06..835d5f9a16c 100644 --- a/app/services/ci/update_build_state_service.rb +++ b/app/services/ci/update_build_state_service.rb @@ -105,7 +105,7 @@ module Ci Result.new(status: 200) when 'failed' - build.drop_with_exit_code!(params[:failure_reason] || :unknown_failure, params[:exit_code]) + build.drop_with_exit_code!(params[:failure_reason], params[:exit_code]) Result.new(status: 200) else diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb index f10ff4e6f19..8c6c7b15d28 100644 --- a/app/services/concerns/alert_management/alert_processing.rb +++ b/app/services/concerns/alert_management/alert_processing.rb @@ -39,12 +39,6 @@ module AlertManagement SystemNoteService.change_alert_status(alert, User.alert_bot) close_issue(alert.issue_id) if auto_close_incident? - else - logger.warn( - message: 'Unable to update AlertManagement::Alert status to resolved', - project_id: project.id, - alert_id: alert.id - ) end end @@ -64,13 +58,18 @@ module AlertManagement if alert.save alert.execute_integrations SystemNoteService.create_new_alert(alert, alert_source) + elsif alert.errors[:fingerprint].any? + refind_and_increment_alert else logger.warn( - message: "Unable to create AlertManagement::Alert from #{alert_source}", + message: "Unable to create AlertManagement::Alert", project_id: project.id, - alert_errors: alert.errors.messages + alert_errors: alert.errors.messages, + alert_source: alert_source ) end + rescue ActiveRecord::RecordNotUnique + refind_and_increment_alert end def process_incident_issues @@ -107,6 +106,12 @@ module AlertManagement AlertManagement::Alert.new(**incoming_payload.alert_params, ended_at: nil) end + def refind_and_increment_alert + clear_memoization(:alert) + + process_firing_alert + end + def resolving_alert? incoming_payload.ends_at.present? end diff --git a/app/services/concerns/work_items/widgetable_service.rb b/app/services/concerns/work_items/widgetable_service.rb index 5665b07dce1..beb614c7b76 100644 --- a/app/services/concerns/work_items/widgetable_service.rb +++ b/app/services/concerns/work_items/widgetable_service.rb @@ -18,7 +18,7 @@ module WorkItems # rubocop:enable Gitlab/ModuleWithInstanceVariables def widget_service_class(widget) - "WorkItems::Widgets::#{widget.type.capitalize}Service::#{self.class.name.demodulize}".constantize + "WorkItems::Widgets::#{widget.type.to_s.camelize}Service::#{self.class.name.demodulize}".constantize rescue NameError nil end diff --git a/app/services/database/consistency_check_service.rb b/app/services/database/consistency_check_service.rb index e39bc8f25b8..fee2e79a6cb 100644 --- a/app/services/database/consistency_check_service.rb +++ b/app/services/database/consistency_check_service.rb @@ -80,7 +80,7 @@ module Database end def max_id - @max_id ||= source_model.minimum(source_sort_column) + @max_id ||= source_model.maximum(source_sort_column) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/deployments/update_environment_service.rb b/app/services/deployments/update_environment_service.rb index b0eb153a7af..3cacedc7d6e 100644 --- a/app/services/deployments/update_environment_service.rb +++ b/app/services/deployments/update_environment_service.rb @@ -58,11 +58,7 @@ module Deployments def expanded_environment_url return unless environment_url - if ::Feature.enabled?(:ci_expand_environment_name_and_url, deployment.project) - ExpandVariables.expand(environment_url, -> { variables.sort_and_expand_all }) - else - ExpandVariables.expand(environment_url, -> { variables }) - end + ExpandVariables.expand(environment_url, -> { variables.sort_and_expand_all }) end def environment_url @@ -88,7 +84,7 @@ module Deployments def renew_deployment_tier return unless deployable - if (tier = deployable.environment_deployment_tier) + if (tier = deployable.environment_tier_from_options) environment.tier = tier end end diff --git a/app/services/design_management/generate_image_versions_service.rb b/app/services/design_management/generate_image_versions_service.rb index e56d163c461..3ff239b59cc 100644 --- a/app/services/design_management/generate_image_versions_service.rb +++ b/app/services/design_management/generate_image_versions_service.rb @@ -43,7 +43,7 @@ module DesignManagement end # Skip attempting to process images that would be rejected by CarrierWave. - return unless DesignManagement::DesignV432x230Uploader::MIME_TYPE_WHITELIST.include?(raw_file.content_type) + return unless DesignManagement::DesignV432x230Uploader::MIME_TYPE_ALLOWLIST.include?(raw_file.content_type) # Store and process the file action.image_v432x230.store!(raw_file) diff --git a/app/services/error_tracking/base_service.rb b/app/services/error_tracking/base_service.rb index d2ecd0a6d5a..8458eb1f3b8 100644 --- a/app/services/error_tracking/base_service.rb +++ b/app/services/error_tracking/base_service.rb @@ -25,7 +25,7 @@ module ErrorTracking errors = parse_errors(response) return errors if errors - yield if block_given? + yield if block track_usage_event(params[:tracking_event], current_user.id) if params[:tracking_event] diff --git a/app/services/google_cloud/base_service.rb b/app/services/google_cloud/base_service.rb index 016ab15408f..01aee2231c9 100644 --- a/app/services/google_cloud/base_service.rb +++ b/app/services/google_cloud/base_service.rb @@ -22,7 +22,7 @@ module GoogleCloud def unique_gcp_project_ids filter_params = { key: 'GCP_PROJECT_ID' } - ::Ci::VariablesFinder.new(project, filter_params).execute.map(&:value).uniq + @unique_gcp_project_ids ||= ::Ci::VariablesFinder.new(project, filter_params).execute.map(&:value).uniq end def group_vars_by_environment(keys) diff --git a/app/services/google_cloud/create_cloudsql_instance_service.rb b/app/services/google_cloud/create_cloudsql_instance_service.rb new file mode 100644 index 00000000000..f7fca277c52 --- /dev/null +++ b/app/services/google_cloud/create_cloudsql_instance_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module GoogleCloud + DEFAULT_REGION = 'us-east1' + + class CreateCloudsqlInstanceService < ::GoogleCloud::BaseService + WORKER_INTERVAL = 30.seconds + + def execute + create_cloud_instance + trigger_instance_setup_worker + success + rescue Google::Apis::Error => err + error(err.to_json) + end + + private + + def create_cloud_instance + google_api_client.create_cloudsql_instance(gcp_project_id, + instance_name, + root_password, + database_version, + region, + tier) + end + + def trigger_instance_setup_worker + GoogleCloud::CreateCloudsqlInstanceWorker.perform_in(WORKER_INTERVAL, + current_user.id, + project.id, + { + 'google_oauth2_token': google_oauth2_token, + 'gcp_project_id': gcp_project_id, + 'instance_name': instance_name, + 'database_version': database_version, + 'environment_name': environment_name, + 'is_protected': protected? + }) + end + + def protected? + project.protected_for?(environment_name) + end + + def instance_name + # Generates an `instance_name` for the to-be-created Cloud SQL instance + # Example: `gitlab-34647-postgres-14-staging` + environment_alias = environment_name == '*' ? 'ALL' : environment_name + name = "gitlab-#{project.id}-#{database_version}-#{environment_alias}" + name.tr("_", "-").downcase + end + + def root_password + SecureRandom.hex(16) + end + + def database_version + params[:database_version] + end + + def region + region = ::Ci::VariablesFinder + .new(project, { key: Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY, + environment_scope: environment_name }) + .execute.first + region&.value || DEFAULT_REGION + end + + def tier + params[:tier] + end + end +end diff --git a/app/services/google_cloud/enable_cloudsql_service.rb b/app/services/google_cloud/enable_cloudsql_service.rb new file mode 100644 index 00000000000..a466b2f3696 --- /dev/null +++ b/app/services/google_cloud/enable_cloudsql_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module GoogleCloud + class EnableCloudsqlService < ::GoogleCloud::BaseService + def execute + return no_projects_error if unique_gcp_project_ids.empty? + + unique_gcp_project_ids.each do |gcp_project_id| + google_api_client.enable_cloud_sql_admin(gcp_project_id) + google_api_client.enable_compute(gcp_project_id) + google_api_client.enable_service_networking(gcp_project_id) + end + + success({ gcp_project_ids: unique_gcp_project_ids }) + end + + private + + def no_projects_error + error("No GCP projects found. Configure a service account or GCP_PROJECT_ID CI variable.") + end + end +end diff --git a/app/services/google_cloud/get_cloudsql_instances_service.rb b/app/services/google_cloud/get_cloudsql_instances_service.rb new file mode 100644 index 00000000000..701e83d556d --- /dev/null +++ b/app/services/google_cloud/get_cloudsql_instances_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module GoogleCloud + class GetCloudsqlInstancesService < ::GoogleCloud::BaseService + CLOUDSQL_KEYS = %w[GCP_PROJECT_ID GCP_CLOUDSQL_INSTANCE_NAME GCP_CLOUDSQL_VERSION].freeze + + def execute + group_vars_by_environment(CLOUDSQL_KEYS).map do |environment_scope, value| + { + ref: environment_scope, + gcp_project: value['GCP_PROJECT_ID'], + instance_name: value['GCP_CLOUDSQL_INSTANCE_NAME'], + version: value['GCP_CLOUDSQL_VERSION'] + } + end + end + end +end diff --git a/app/services/google_cloud/setup_cloudsql_instance_service.rb b/app/services/google_cloud/setup_cloudsql_instance_service.rb index 73650ee752f..10237f83b37 100644 --- a/app/services/google_cloud/setup_cloudsql_instance_service.rb +++ b/app/services/google_cloud/setup_cloudsql_instance_service.rb @@ -16,29 +16,29 @@ module GoogleCloud return error("CloudSQL instance not RUNNABLE: #{get_instance_response.to_json}") end - database_response = google_api_client.create_cloudsql_database(gcp_project_id, instance_name, database_name) + save_instance_ci_vars(get_instance_response) - if database_response.status != OPERATION_STATE_DONE - return error("Database creation failed: #{database_response.to_json}") - end + list_database_response = google_api_client.list_cloudsql_databases(gcp_project_id, instance_name) + list_user_response = google_api_client.list_cloudsql_users(gcp_project_id, instance_name) - user_response = google_api_client.create_cloudsql_user(gcp_project_id, instance_name, username, password) + existing_database = list_database_response.items.find { |database| database.name == database_name } + existing_user = list_user_response.items.find { |user| user.name == username } - if user_response.status != OPERATION_STATE_DONE - return error("User creation failed: #{user_response.to_json}") + if existing_database && existing_user + save_database_ci_vars + save_user_ci_vars(existing_user) + return success end - primary_ip_address = get_instance_response.ip_addresses.first.ip_address - connection_name = get_instance_response.connection_name + database_response = execute_database_setup(existing_database) + return database_response if database_response[:status] == :error - save_ci_var('GCP_PROJECT_ID', gcp_project_id) - save_ci_var('GCP_CLOUDSQL_INSTANCE_NAME', instance_name) - save_ci_var('GCP_CLOUDSQL_CONNECTION_NAME', connection_name) - save_ci_var('GCP_CLOUDSQL_PRIMARY_IP_ADDRESS', primary_ip_address) - save_ci_var('GCP_CLOUDSQL_VERSION', database_version) - save_ci_var('GCP_CLOUDSQL_DATABASE_NAME', database_name) - save_ci_var('GCP_CLOUDSQL_DATABASE_USER', username) - save_ci_var('GCP_CLOUDSQL_DATABASE_PASS', password, true) + save_database_ci_vars + + user_response = execute_user_setup(existing_user) + return user_response if user_response[:status] == :error + + save_user_ci_vars(existing_user) success rescue Google::Apis::Error => err @@ -64,11 +64,55 @@ module GoogleCloud end def password - SecureRandom.hex(16) + @password ||= SecureRandom.hex(16) end def save_ci_var(key, value, is_masked = false) create_or_replace_project_vars(environment_name, key, value, @params[:is_protected], is_masked) end + + def save_instance_ci_vars(cloudsql_instance) + primary_ip_address = cloudsql_instance.ip_addresses.first.ip_address + connection_name = cloudsql_instance.connection_name + + save_ci_var('GCP_PROJECT_ID', gcp_project_id) + save_ci_var('GCP_CLOUDSQL_INSTANCE_NAME', instance_name) + save_ci_var('GCP_CLOUDSQL_CONNECTION_NAME', connection_name) + save_ci_var('GCP_CLOUDSQL_PRIMARY_IP_ADDRESS', primary_ip_address) + save_ci_var('GCP_CLOUDSQL_VERSION', database_version) + end + + def save_database_ci_vars + save_ci_var('GCP_CLOUDSQL_DATABASE_NAME', database_name) + end + + def save_user_ci_vars(user_exists) + save_ci_var('GCP_CLOUDSQL_DATABASE_USER', username) + save_ci_var('GCP_CLOUDSQL_DATABASE_PASS', user_exists ? user_exists.password : password, true) + end + + def execute_database_setup(database_exists) + return success if database_exists + + database_response = google_api_client.create_cloudsql_database(gcp_project_id, instance_name, database_name) + + if database_response.status != OPERATION_STATE_DONE + return error("Database creation failed: #{database_response.to_json}") + end + + success + end + + def execute_user_setup(existing_user) + return success if existing_user + + user_response = google_api_client.create_cloudsql_user(gcp_project_id, instance_name, username, password) + + if user_response.status != OPERATION_STATE_DONE + return error("User creation failed: #{user_response.to_json}") + end + + success + end end end diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb index bcf3110ca21..02a760ccf29 100644 --- a/app/services/groups/destroy_service.rb +++ b/app/services/groups/destroy_service.rb @@ -45,6 +45,8 @@ module Groups .execute(blocking: true) end + publish_event + group end # rubocop: enable CodeReuse/ActiveRecord @@ -91,6 +93,17 @@ module Groups end end # rubocop:enable CodeReuse/ActiveRecord + + def publish_event + event = Groups::GroupDeletedEvent.new( + data: { + group_id: group.id, + root_namespace_id: group.root_ancestor.id + } + ) + + Gitlab::EventStore.publish(event) + end end end diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb index 2bfd5a5ebab..bd54b48c5f4 100644 --- a/app/services/groups/import_export/export_service.rb +++ b/app/services/groups/import_export/export_service.rb @@ -49,13 +49,23 @@ module Groups # We cannot include the file_saver with the other savers because # it removes the tmp dir. This means that if we want to add new savers # in EE the data won't be available. - if savers.all?(&:save) && file_saver.save + if save_exporters && file_saver.save notify_success else notify_error! end end + def save_exporters + log_info('Group export started') + + savers.all? do |exporter| + log_info("#{exporter.class.name} saver started") + + exporter.save + end + end + def savers [version_saver, tree_exporter] end @@ -99,12 +109,16 @@ module Groups raise Gitlab::ImportExport::Error, shared.errors.to_sentence end - def notify_success + def log_info(message) @logger.info( - message: 'Group Export succeeded', + message: message, group_id: group.id, group_name: group.name ) + end + + def notify_success + log_info('Group Export succeeded') notification_service.group_was_exported(group, current_user) end diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb index f026f1698a9..db52a272bf2 100644 --- a/app/services/groups/import_export/import_service.rb +++ b/app/services/groups/import_export/import_service.rb @@ -97,17 +97,17 @@ module Groups def notify_success @logger.info( - group_id: group.id, + group_id: group.id, group_name: group.name, - message: 'Group Import/Export: Import succeeded' + message: 'Group Import/Export: Import succeeded' ) end def notify_error @logger.error( - group_id: group.id, + group_id: group.id, group_name: group.name, - message: "Group Import/Export: Errors occurred, see '#{Gitlab::ErrorTracking::Logger.file_name}' for details" + message: "Group Import/Export: Errors occurred, see '#{Gitlab::ErrorTracking::Logger.file_name}' for details" ) end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 29e3a9473ab..6fbf7daeb81 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -36,7 +36,7 @@ module Groups update_crm_objects(was_root_group) end - post_update_hooks(@updated_project_ids) + post_update_hooks(@updated_project_ids, old_root_ancestor_id) propagate_integrations update_pending_builds @@ -44,9 +44,10 @@ module Groups end # Overridden in EE - def post_update_hooks(updated_project_ids) + def post_update_hooks(updated_project_ids, old_root_ancestor_id) refresh_project_authorizations refresh_descendant_groups if @new_parent_group + publish_event(old_root_ancestor_id) end def ensure_allowed_transfer @@ -266,6 +267,18 @@ module Groups CustomerRelations::IssueContact.delete_for_group(@group) end + + def publish_event(old_root_ancestor_id) + event = ::Groups::GroupTransferedEvent.new( + data: { + group_id: group.id, + old_root_namespace_id: old_root_ancestor_id, + new_root_namespace_id: group.root_ancestor.id + } + ) + + Gitlab::EventStore.publish(event) + end end end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index b3b0397eac3..2135892a95a 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -61,15 +61,18 @@ module Groups end def before_assignment_hook(group, params) - # overridden in EE + @full_path_before = group.full_path + @path_before = group.path end def renaming_group_with_container_registry_images? + renaming? && group.has_container_repository_including_subgroups? + end + + def renaming? new_path = params[:path] - new_path && - new_path != group.path && - group.has_container_repository_including_subgroups? + new_path && new_path != @path_before end def container_images_error @@ -83,6 +86,8 @@ module Groups end update_two_factor_requirement_for_subgroups + + publish_event end def update_two_factor_requirement_for_subgroups @@ -154,6 +159,21 @@ module Groups group.errors.add(:update_shared_runners, result[:message]) false end + + def publish_event + return unless renaming? + + event = Groups::GroupPathChangedEvent.new( + data: { + group_id: group.id, + root_namespace_id: group.root_ancestor.id, + old_path: @full_path_before, + new_path: group.full_path + } + ) + + Gitlab::EventStore.publish(event) + end end end diff --git a/app/services/import/prepare_service.rb b/app/services/import/prepare_service.rb new file mode 100644 index 00000000000..278bd463dcd --- /dev/null +++ b/app/services/import/prepare_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Import + class PrepareService < ::BaseService + def execute + uploader = UploadService.new(project, params[:file]).execute + + if uploader + enqueue_import(uploader.upload) + + ServiceResponse.success(message: success_message) + else + ServiceResponse.error(message: _('File upload error.')) + end + end + + private + + def enqueue_import(upload) + worker.perform_async(current_user.id, project.id, upload.id) + end + + def worker + raise NotImplementedError + end + + def success_message + raise NotImplementedError + end + end +end diff --git a/app/services/incident_management/timeline_events/create_service.rb b/app/services/incident_management/timeline_events/create_service.rb index 3cb67ccf2b1..40ce9097c88 100644 --- a/app/services/incident_management/timeline_events/create_service.rb +++ b/app/services/incident_management/timeline_events/create_service.rb @@ -48,6 +48,26 @@ module IncidentManagement new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute end + + def change_labels(incident, user, added_labels: [], removed_labels: []) + return if Feature.disabled?(:incident_timeline_events_from_labels, incident.project) + + if added_labels.blank? && removed_labels.blank? + return ServiceResponse.error(message: _('There are no changed labels')) + end + + labels_note = -> (verb, labels) { + "#{verb} #{labels.map(&:to_reference).join(' ')} #{'label'.pluralize(labels.count)}" if labels.present? + } + + added_note = labels_note.call('added', added_labels) + removed_note = labels_note.call('removed', removed_labels) + note = "@#{user.username} #{[added_note, removed_note].compact.join(' and ')}" + occurred_at = incident.updated_at + action = 'label' + + new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute + end end def execute diff --git a/app/services/incident_management/timeline_events/update_service.rb b/app/services/incident_management/timeline_events/update_service.rb index 8217c8125c2..5c5de4717bc 100644 --- a/app/services/incident_management/timeline_events/update_service.rb +++ b/app/services/incident_management/timeline_events/update_service.rb @@ -34,7 +34,7 @@ module IncidentManagement attr_reader :timeline_event, :incident, :user, :note, :occurred_at def update_params - { updated_by_user: user, note: note.presence, occurred_at: occurred_at.presence }.compact + { updated_by_user: user, note: note, occurred_at: occurred_at }.compact end def add_system_note(timeline_event) diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb index 98c50347719..3c13944cfbc 100644 --- a/app/services/issuable/clone/base_service.rb +++ b/app/services/issuable/clone/base_service.rb @@ -16,6 +16,7 @@ module Issuable # ApplicationRecord.transaction do @new_entity = create_new_entity + @new_entity.system_note_timestamp = nil update_new_entity update_old_entity diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index 5cf32ee3e40..db28be864a7 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -21,7 +21,7 @@ module Issuable create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked') end - create_due_date_note if issuable.previous_changes.include?('due_date') + handle_start_date_or_due_date_change_note create_milestone_change_event(old_milestone) if issuable.previous_changes.include?('milestone_id') create_labels_note(old_labels) if old_labels && issuable.labels != old_labels end @@ -29,6 +29,13 @@ module Issuable private + def handle_start_date_or_due_date_change_note + # Type check needed as some issuables do their own date change handling for date fields other than due_date + change_date_fields = issuable.is_a?(Issue) ? %w[due_date start_date] : %w[due_date] + changed_dates = issuable.previous_changes.slice(*change_date_fields) + create_start_date_or_due_date_note(changed_dates) + end + def handle_time_tracking_note if issuable.previous_changes.include?('time_estimate') create_time_estimate_note @@ -99,8 +106,10 @@ module Issuable .execute end - def create_due_date_note - SystemNoteService.change_due_date(issuable, issuable.project, current_user, issuable.due_date) + def create_start_date_or_due_date_note(changed_dates) + return if changed_dates.blank? + + SystemNoteService.change_start_date_or_due_date(issuable, issuable.project, current_user, changed_dates) end def create_discussion_lock_note diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb index 9b41c88159f..822e3cd787c 100644 --- a/app/services/issuable/import_csv/base_service.rb +++ b/app/services/issuable/import_csv/base_service.rb @@ -21,13 +21,9 @@ module Issuable def process_csv with_csv_lines.each do |row, line_no| - issuable_attributes = { - title: row[:title], - description: row[:description], - due_date: row[:due_date] - } + attributes = issuable_attributes_for(row) - if create_issuable(issuable_attributes).persisted? + if create_issuable(attributes).persisted? @results[:success] += 1 else @results[:error_lines].push(line_no) @@ -37,6 +33,14 @@ module Issuable @results[:parse_error] = true end + def issuable_attributes_for(row) + { + title: row[:title], + description: row[:description], + due_date: row[:due_date] + } + end + def with_csv_lines csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8) validate_headers_presence!(csv_data.lines.first) diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb index 896b15a14b8..07dd9a98f89 100644 --- a/app/services/issues/clone_service.rb +++ b/app/services/issues/clone_service.rb @@ -41,7 +41,6 @@ module Issues 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 - copy_award_emoji if with_notes copy_notes @@ -96,9 +95,14 @@ module Issues end def add_note_from - SystemNoteService.noteable_cloned(new_entity, target_project, - original_entity, current_user, - direction: :from) + SystemNoteService.noteable_cloned( + new_entity, + target_project, + original_entity, + current_user, + direction: :from, + created_at: new_entity.created_at + ) end def add_note_to diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 30d4cb68840..92cf4811439 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -45,7 +45,7 @@ module Issues # current_user (defined in BaseService) is not available within run_after_commit block user = current_user issue.run_after_commit do - NewIssueWorker.perform_async(issue.id, user.id) + NewIssueWorker.perform_async(issue.id, user.id, issue.class.to_s) Issues::PlacementWorker.perform_async(nil, issue.project_id) Namespaces::OnboardingIssueCreatedWorker.perform_async(issue.project.namespace_id) end diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb index 7076e858155..6209127bd86 100644 --- a/app/services/issues/export_csv_service.rb +++ b/app/services/issues/export_csv_service.rb @@ -25,24 +25,24 @@ module Issues { 'Title' => 'title', 'Description' => 'description', - 'Issue ID' => 'iid', - 'URL' => -> (issue) { issue_url(issue) }, - 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' }, - 'Author' => 'author_name', - 'Author Username' => -> (issue) { issue.author&.username }, - 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') }, - 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') }, - 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' }, - 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' }, - 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) }, - 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) }, - 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) }, - 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) }, - 'Milestone' => -> (issue) { issue.milestone&.title }, - 'Weight' => -> (issue) { issue.weight }, - 'Labels' => -> (issue) { issue_labels(issue) }, - 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) }, - 'Time Spent' => -> (issue) { issue_time_spent(issue) } + 'Issue ID' => 'iid', + 'URL' => -> (issue) { issue_url(issue) }, + 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' }, + 'Author' => 'author_name', + 'Author Username' => -> (issue) { issue.author&.username }, + 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') }, + 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') }, + 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' }, + 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' }, + 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) }, + 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) }, + 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) }, + 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) }, + 'Milestone' => -> (issue) { issue.milestone&.title }, + 'Weight' => -> (issue) { issue.weight }, + 'Labels' => -> (issue) { issue_labels(issue) }, + 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) }, + 'Time Spent' => -> (issue) { issue_time_spent(issue) } } end diff --git a/app/services/issues/prepare_import_csv_service.rb b/app/services/issues/prepare_import_csv_service.rb new file mode 100644 index 00000000000..7afe363117e --- /dev/null +++ b/app/services/issues/prepare_import_csv_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Issues + class PrepareImportCsvService < Import::PrepareService + extend ::Gitlab::Utils::Override + + private + + override :worker + def worker + ImportIssuesCsvWorker + end + + override :success_message + def success_message + _("Your issues are being imported. Once finished, you'll get a confirmation email.") + end + end +end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index afc61eed287..46c28d82ddc 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -70,6 +70,7 @@ module Issues handle_severity_change(issue, old_severity) handle_escalation_status_change(issue) handle_issue_type_change(issue) + handle_date_changes(issue) end def handle_assignee_changes(issue, old_assignees) @@ -116,6 +117,12 @@ module Issues attr_reader :spam_params + def handle_date_changes(issue) + return unless issue.previous_changes.slice('due_date', 'start_date').any? + + GraphqlTriggers.issuable_dates_updated(issue) + end + def clone_issue(issue) target_project = params.delete(:target_clone_project) with_notes = params.delete(:clone_with_notes) diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb index 8e8511e5180..d0ca8863c29 100644 --- a/app/services/jira/requests/base.rb +++ b/app/services/jira/requests/base.rb @@ -9,10 +9,10 @@ module Jira ERRORS = { connection: [Errno::ECONNRESET, Errno::ECONNREFUSED], - jira_ruby: JIRA::HTTPError, - ssl: OpenSSL::SSL::SSLError, - timeout: [Timeout::Error, Errno::ETIMEDOUT], - uri: [URI::InvalidURIError, SocketError] + jira_ruby: JIRA::HTTPError, + ssl: OpenSSL::SSL::SSLError, + timeout: [Timeout::Error, Errno::ETIMEDOUT], + uri: [URI::InvalidURIError, SocketError] }.freeze ALL_ERRORS = ERRORS.values.flatten.freeze diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index b8d817a15f3..dcc4cf4bb1e 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -10,14 +10,27 @@ module MergeRequests return success unless save_approval(approval) reset_approvals_cache(merge_request) - create_event(merge_request) - stream_audit_event(merge_request) - create_approval_note(merge_request) - mark_pending_todos_as_done(merge_request) - execute_approval_hooks(merge_request, current_user) - remove_attention_requested(merge_request) merge_request_activity_counter.track_approve_mr_action(user: current_user, merge_request: merge_request) + # Approval side effects (things not required to be done immediately but + # should happen after a successful approval) should be done asynchronously + # utilizing the `Gitlab::EventStore`. + # + # Workers can subscribe to the `MergeRequests::ApprovedEvent`. + if Feature.enabled?(:async_after_approval, project) + Gitlab::EventStore.publish( + MergeRequests::ApprovedEvent.new( + data: { current_user_id: current_user.id, merge_request_id: merge_request.id } + ) + ) + else + create_event(merge_request) + stream_audit_event(merge_request) + create_approval_note(merge_request) + mark_pending_todos_as_done(merge_request) + execute_approval_hooks(merge_request, current_user) + end + success end @@ -27,21 +40,22 @@ module MergeRequests current_user.can?(:approve_merge_request, merge_request) end + def save_approval(approval) + Approval.safe_ensure_unique do + approval.save + end + end + def reset_approvals_cache(merge_request) merge_request.approvals.reset end - def execute_approval_hooks(merge_request, current_user) - # Only one approval is required for a merge request to be approved - notification_service.async.approve_mr(merge_request, current_user) - - execute_hooks(merge_request, 'approved') + def create_event(merge_request) + event_service.approve_mr(merge_request, current_user) end - def save_approval(approval) - Approval.safe_ensure_unique do - approval.save - end + def stream_audit_event(merge_request) + # Defined in EE end def create_approval_note(merge_request) @@ -52,12 +66,11 @@ module MergeRequests todo_service.resolve_todos_for_target(merge_request, current_user) end - def create_event(merge_request) - event_service.approve_mr(merge_request, current_user) - end + def execute_approval_hooks(merge_request, current_user) + # Only one approval is required for a merge request to be approved + notification_service.async.approve_mr(merge_request, current_user) - def stream_audit_event(merge_request) - # Defined in EE + execute_hooks(merge_request, 'approved') end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 9bd38478796..bda8dc64ac0 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -61,10 +61,6 @@ module MergeRequests merge_request_activity_counter.track_users_review_requested(users: new_reviewers) merge_request_activity_counter.track_reviewers_changed_action(user: current_user) bulk_update_reviewers_state(merge_request, new_reviewers) - - unless new_reviewers.include?(current_user) - remove_attention_requested(merge_request) - end end def cleanup_environments(merge_request) @@ -252,20 +248,6 @@ module MergeRequests Milestones::MergeRequestsCountService.new(milestone).delete_cache end - def remove_all_attention_requests(merge_request) - return unless current_user.mr_attention_requests_enabled? - - users = merge_request.reviewers + merge_request.assignees - - ::MergeRequests::BulkRemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, users: users.uniq).execute - end - - def remove_attention_requested(merge_request) - return unless current_user.mr_attention_requests_enabled? - - ::MergeRequests::RemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, user: current_user).execute - end - def bulk_update_assignees_state(merge_request, new_assignees) return unless current_user.mr_attention_requests_enabled? return if new_assignees.empty? diff --git a/app/services/merge_requests/bulk_remove_attention_requested_service.rb b/app/services/merge_requests/bulk_remove_attention_requested_service.rb deleted file mode 100644 index 774f2c2ee35..00000000000 --- a/app/services/merge_requests/bulk_remove_attention_requested_service.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class BulkRemoveAttentionRequestedService < MergeRequests::BaseService - attr_accessor :merge_request - attr_accessor :users - - def initialize(project:, current_user:, merge_request:, users:) - super(project: project, current_user: current_user) - - @merge_request = merge_request - @users = users - end - - # rubocop: disable CodeReuse/ActiveRecord - def execute - return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) - - merge_request.merge_request_assignees.where(user_id: users).update_all(state: :reviewed) - merge_request.merge_request_reviewers.where(user_id: users).update_all(state: :reviewed) - - users.each { |user| user.invalidate_attention_requested_count } - - success - end - # rubocop: enable CodeReuse/ActiveRecord - end -end diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index e9b253129b4..f83b14c7269 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -17,7 +17,6 @@ module MergeRequests create_note(merge_request) notification_service.async.close_mr(merge_request, current_user) todo_service.close_merge_request(merge_request, current_user) - remove_all_attention_requests(merge_request) execute_hooks(merge_request, 'close') invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers) merge_request.update_project_counter_caches diff --git a/app/services/merge_requests/create_approval_event_service.rb b/app/services/merge_requests/create_approval_event_service.rb new file mode 100644 index 00000000000..1678bf31139 --- /dev/null +++ b/app/services/merge_requests/create_approval_event_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module MergeRequests + class CreateApprovalEventService < MergeRequests::BaseService + def execute(merge_request) + event_service.approve_mr(merge_request, current_user) + end + end +end + +MergeRequests::CreateApprovalEventService.prepend_mod diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb index c6a91a3b61e..4f20ade2a42 100644 --- a/app/services/merge_requests/create_pipeline_service.rb +++ b/app/services/merge_requests/create_pipeline_service.rb @@ -50,7 +50,8 @@ module MergeRequests end def can_create_pipeline_in_target_project?(merge_request) - can?(current_user, :create_pipeline, merge_request.target_project) && + merge_request.target_project.ci_allow_fork_pipelines_to_run_in_parent_project? && + can?(current_user, :create_pipeline, merge_request.target_project) && can_update_source_branch_in_target_project?(merge_request) end diff --git a/app/services/merge_requests/execute_approval_hooks_service.rb b/app/services/merge_requests/execute_approval_hooks_service.rb new file mode 100644 index 00000000000..7beeb9ea3f9 --- /dev/null +++ b/app/services/merge_requests/execute_approval_hooks_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module MergeRequests + class ExecuteApprovalHooksService < MergeRequests::BaseService + def execute(merge_request) + # Only one approval is required for a merge request to be approved + notification_service.async.approve_mr(merge_request, current_user) + execute_hooks(merge_request, 'approved') + end + end +end + +MergeRequests::ExecuteApprovalHooksService.prepend_mod diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb index 78c93d10f2a..87cd6544406 100644 --- a/app/services/merge_requests/handle_assignees_change_service.rb +++ b/app/services/merge_requests/handle_assignees_change_service.rb @@ -22,10 +22,6 @@ module MergeRequests merge_request_activity_counter.track_assignees_changed_action(user: current_user) execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks] - - unless new_assignees.include?(current_user) - remove_attention_requested(merge_request) - end end private diff --git a/app/services/merge_requests/mergeability/check_base_service.rb b/app/services/merge_requests/mergeability/check_base_service.rb index d5ddcb4b828..e614a7c27fe 100644 --- a/app/services/merge_requests/mergeability/check_base_service.rb +++ b/app/services/merge_requests/mergeability/check_base_service.rb @@ -24,12 +24,12 @@ module MergeRequests private - def success(*args) - Gitlab::MergeRequests::Mergeability::CheckResult.success(*args) + def success(**args) + Gitlab::MergeRequests::Mergeability::CheckResult.success(payload: args) end - def failure(*args) - Gitlab::MergeRequests::Mergeability::CheckResult.failed(*args) + def failure(**args) + Gitlab::MergeRequests::Mergeability::CheckResult.failed(payload: args) end end end diff --git a/app/services/merge_requests/mergeability/check_broken_status_service.rb b/app/services/merge_requests/mergeability/check_broken_status_service.rb index 9a54a4292c8..6fe4eb4a57f 100644 --- a/app/services/merge_requests/mergeability/check_broken_status_service.rb +++ b/app/services/merge_requests/mergeability/check_broken_status_service.rb @@ -4,7 +4,7 @@ module MergeRequests class CheckBrokenStatusService < CheckBaseService def execute if merge_request.broken? - failure + failure(reason: failure_reason) else success end @@ -17,6 +17,12 @@ module MergeRequests def cacheable? false end + + private + + def failure_reason + :broken_status + 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 index c0ef5ba1c30..9e09b513c57 100644 --- a/app/services/merge_requests/mergeability/check_ci_status_service.rb +++ b/app/services/merge_requests/mergeability/check_ci_status_service.rb @@ -6,7 +6,7 @@ module MergeRequests if merge_request.mergeable_ci_state? success else - failure + failure(reason: failure_reason) end end @@ -17,6 +17,12 @@ module MergeRequests def cacheable? false end + + private + + def failure_reason + :ci_must_pass + end end end end diff --git a/app/services/merge_requests/mergeability/check_discussions_status_service.rb b/app/services/merge_requests/mergeability/check_discussions_status_service.rb index 9b4eab9d399..3421d96e8ae 100644 --- a/app/services/merge_requests/mergeability/check_discussions_status_service.rb +++ b/app/services/merge_requests/mergeability/check_discussions_status_service.rb @@ -6,7 +6,7 @@ module MergeRequests if merge_request.mergeable_discussions_state? success else - failure + failure(reason: failure_reason) end end @@ -17,6 +17,12 @@ module MergeRequests def cacheable? false end + + private + + def failure_reason + :discussions_not_resolved + end end end end diff --git a/app/services/merge_requests/mergeability/check_draft_status_service.rb b/app/services/merge_requests/mergeability/check_draft_status_service.rb index bc940e2116d..a1524317155 100644 --- a/app/services/merge_requests/mergeability/check_draft_status_service.rb +++ b/app/services/merge_requests/mergeability/check_draft_status_service.rb @@ -5,7 +5,7 @@ module MergeRequests class CheckDraftStatusService < CheckBaseService def execute if merge_request.draft? - failure + failure(reason: failure_reason) else success end @@ -18,6 +18,12 @@ module MergeRequests def cacheable? false end + + private + + def failure_reason + :draft_status + end end end end diff --git a/app/services/merge_requests/mergeability/check_open_status_service.rb b/app/services/merge_requests/mergeability/check_open_status_service.rb index 361af946e3f..29f3d0d3ccb 100644 --- a/app/services/merge_requests/mergeability/check_open_status_service.rb +++ b/app/services/merge_requests/mergeability/check_open_status_service.rb @@ -7,7 +7,7 @@ module MergeRequests if merge_request.open? success else - failure + failure(reason: failure_reason) end end @@ -18,6 +18,12 @@ module MergeRequests def cacheable? false end + + private + + def failure_reason + :not_open + 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 index 1d4b96b3090..68f842b3322 100644 --- a/app/services/merge_requests/mergeability/run_checks_service.rb +++ b/app/services/merge_requests/mergeability/run_checks_service.rb @@ -10,36 +10,50 @@ module MergeRequests end def execute - merge_request.mergeability_checks.each_with_object([]) do |check_class, results| + @results = merge_request.mergeability_checks.each_with_object([]) do |check_class, result_hash| check = check_class.new(merge_request: merge_request, params: params) next if check.skip? check_result = run_check(check) - results << check_result + result_hash << check_result - break results if check_result.failed? + break result_hash if check_result.failed? end + + self + end + + def success? + raise 'Execute needs to be called before' if results.nil? + + results.all?(&:success?) + end + + def failure_reason + raise 'Execute needs to be called before' if results.nil? + + results.find(&:failed?)&.payload&.fetch(:reason) end private - attr_reader :merge_request, :params + attr_reader :merge_request, :params, :results def run_check(check) return check.execute unless Feature.enabled?(:mergeability_caching, merge_request.project) return check.execute unless check.cacheable? - cached_result = results.read(merge_check: check) + cached_result = cached_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) + cached_results.write(merge_check: check, result_hash: result.to_hash) end end - def results - strong_memoize(:results) do + def cached_results + strong_memoize(:cached_results) do Gitlab::MergeRequests::Mergeability::ResultsStore.new(merge_request: merge_request) end end diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb index 30531fcc17b..1ce44f465cd 100644 --- a/app/services/merge_requests/mergeability_check_service.rb +++ b/app/services/merge_requests/mergeability_check_service.rb @@ -78,8 +78,8 @@ module MergeRequests lease_key = "mergeability_check:#{merge_request.id}" lease_opts = { - ttl: 1.minute, - retries: retry_lease ? 10 : 0, + ttl: 1.minute, + retries: retry_lease ? 10 : 0, sleep_sec: retry_lease ? 1.second : 0 } diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index 286c082ac8a..9fca2b0d19e 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -28,7 +28,6 @@ module MergeRequests notification_service.merge_mr(merge_request, current_user) invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers) merge_request.update_project_counter_caches - remove_all_attention_requests(merge_request) delete_non_latest_diffs(merge_request) cancel_review_app_jobs!(merge_request) cleanup_environments(merge_request) diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb index 076fe8c3b21..ef251f121ae 100644 --- a/app/services/merge_requests/push_options_handler_service.rb +++ b/app/services/merge_requests/push_options_handler_service.rb @@ -105,7 +105,7 @@ module MergeRequests project: project, current_user: current_user, params: merge_request.attributes.merge(assignees: merge_request.assignees, - label_ids: merge_request.label_ids) + label_ids: merge_request.label_ids) ).execute end diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb index d9bb17a7b1b..52628729519 100644 --- a/app/services/merge_requests/remove_approval_service.rb +++ b/app/services/merge_requests/remove_approval_service.rb @@ -17,7 +17,6 @@ module MergeRequests reset_approvals_cache(merge_request) create_note(merge_request) merge_request_activity_counter.track_unapprove_mr_action(user: current_user) - remove_attention_requested(merge_request) end success diff --git a/app/services/merge_requests/remove_attention_requested_service.rb b/app/services/merge_requests/remove_attention_requested_service.rb deleted file mode 100644 index 8a410fda691..00000000000 --- a/app/services/merge_requests/remove_attention_requested_service.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class RemoveAttentionRequestedService < MergeRequests::BaseService - attr_accessor :merge_request, :user - - def initialize(project:, current_user:, merge_request:, user:) - super(project: project, current_user: current_user) - - @merge_request = merge_request - @user = user - end - - def execute - return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) - - if reviewer || assignee - return success if reviewer&.reviewed? || assignee&.reviewed? - - update_state(reviewer) - update_state(assignee) - - user.invalidate_attention_requested_count - create_remove_attention_request_note - - success - else - error("User is not a reviewer or assignee of the merge request") - end - end - - private - - def assignee - @assignee ||= merge_request.find_assignee(user) - end - - def reviewer - @reviewer ||= merge_request.find_reviewer(user) - end - - def update_state(reviewer_or_assignee) - reviewer_or_assignee&.update(state: :reviewed) - end - - def create_remove_attention_request_note - SystemNoteService.remove_attention_request(merge_request, merge_request.project, current_user, user) - end - end -end diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index 4612688f78b..d2247a6d4c1 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -20,8 +20,6 @@ module MergeRequests merge_request.cache_merge_request_closes_issues!(current_user) merge_request.cleanup_schedule&.destroy merge_request.update_column(:merge_ref_sha, nil) - - users.each { |user| user.invalidate_attention_requested_count } end merge_request diff --git a/app/services/merge_requests/request_attention_service.rb b/app/services/merge_requests/request_attention_service.rb deleted file mode 100644 index 07e9996f87b..00000000000 --- a/app/services/merge_requests/request_attention_service.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class RequestAttentionService < MergeRequests::BaseService - attr_accessor :merge_request, :user - - def initialize(project:, current_user:, merge_request:, user:) - super(project: project, current_user: current_user) - - @merge_request = merge_request - @user = user - end - - def execute - return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) - - if reviewer || assignee - return success if reviewer&.attention_requested? || assignee&.attention_requested? - - update_state(reviewer) - update_state(assignee) - - user.invalidate_attention_requested_count - create_attention_request_note - notity_user - - if current_user.id != user.id - remove_attention_requested(merge_request) - end - - success - else - error("User is not a reviewer or assignee of the merge request") - end - end - - private - - def notity_user - notification_service.async.attention_requested_of_merge_request(merge_request, current_user, user) - todo_service.create_attention_requested_todo(merge_request, current_user, user) - end - - def create_attention_request_note - SystemNoteService.request_attention(merge_request, merge_request.project, current_user, user) - end - - def assignee - @assignee ||= merge_request.find_assignee(user) - end - - def reviewer - @reviewer ||= merge_request.find_reviewer(user) - end - - def update_state(reviewer_or_assignee) - reviewer_or_assignee&.update(state: :attention_requested, updated_state_by: current_user) - end - end -end diff --git a/app/services/merge_requests/toggle_attention_requested_service.rb b/app/services/merge_requests/toggle_attention_requested_service.rb deleted file mode 100644 index 64cdcd725a2..00000000000 --- a/app/services/merge_requests/toggle_attention_requested_service.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class ToggleAttentionRequestedService < MergeRequests::BaseService - attr_accessor :merge_request, :user - - def initialize(project:, current_user:, merge_request:, user:) - super(project: project, current_user: current_user) - - @merge_request = merge_request - @user = user - end - - def execute - return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) - - if reviewer || assignee - update_state(reviewer) - update_state(assignee) - - user.invalidate_attention_requested_count - - if reviewer&.attention_requested? || assignee&.attention_requested? - create_attention_request_note - notity_user - - if current_user.id != user.id - remove_attention_requested(merge_request) - end - else - create_remove_attention_request_note - end - - success - else - error("User is not a reviewer or assignee of the merge request") - end - end - - private - - def notity_user - notification_service.async.attention_requested_of_merge_request(merge_request, current_user, user) - todo_service.create_attention_requested_todo(merge_request, current_user, user) - end - - def create_attention_request_note - SystemNoteService.request_attention(merge_request, merge_request.project, current_user, user) - end - - def create_remove_attention_request_note - SystemNoteService.remove_attention_request(merge_request, merge_request.project, current_user, user) - end - - def assignee - merge_request.find_assignee(user) - end - - def reviewer - merge_request.find_reviewer(user) - end - - def update_state(reviewer_or_assignee) - reviewer_or_assignee&.update(state: reviewer_or_assignee&.attention_requested? ? :reviewed : :attention_requested, - updated_state_by: current_user) - end - end -end diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb index 5b23f69ac4a..a6b0235c525 100644 --- a/app/services/merge_requests/update_assignees_service.rb +++ b/app/services/merge_requests/update_assignees_service.rb @@ -11,7 +11,7 @@ module MergeRequests old_assignees = merge_request.assignees.to_a old_ids = old_assignees.map(&:id) - new_ids = new_assignee_ids(merge_request) + new_ids = new_user_ids(merge_request, update_attrs[:assignee_ids], :assignees) return merge_request if merge_request.errors.any? return merge_request if new_ids.size != update_attrs[:assignee_ids].size @@ -32,27 +32,8 @@ module MergeRequests private - def new_assignee_ids(merge_request) - # prime the cache - prevent N+1 lookup during authorization loop. - user_ids = update_attrs[:assignee_ids] - return [] if user_ids.empty? - - merge_request.project.team.max_member_access_for_user_ids(user_ids) - User.id_in(user_ids).map do |user| - if user.can?(:read_merge_request, merge_request) - user.id - else - merge_request.errors.add( - :assignees, - "Cannot assign #{user.to_reference} to #{merge_request.to_reference}" - ) - nil - end - end.compact - end - def assignee_ids - params.fetch(:assignee_ids).reject { _1 == 0 }.first(1) + filter_sentinel_values(params.fetch(:assignee_ids)).first(1) end def params diff --git a/app/services/merge_requests/update_reviewers_service.rb b/app/services/merge_requests/update_reviewers_service.rb new file mode 100644 index 00000000000..8e974d75676 --- /dev/null +++ b/app/services/merge_requests/update_reviewers_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module MergeRequests + class UpdateReviewersService < UpdateService + def execute(merge_request) + return merge_request unless current_user&.can?(:update_merge_request, merge_request) + + old_reviewers = merge_request.reviewers.to_a + old_ids = old_reviewers.map(&:id) + new_ids = new_user_ids(merge_request, update_attrs[:reviewer_ids], :reviewers) + + return merge_request if merge_request.errors.any? + return merge_request if new_ids.size != update_attrs[:reviewer_ids].size + return merge_request if old_ids.to_set == new_ids.to_set # no-change + + merge_request.update!(update_attrs.merge(reviewer_ids: new_ids)) + handle_reviewers_change(merge_request, old_reviewers) + resolve_todos_for(merge_request) + execute_reviewers_hooks(merge_request, old_reviewers) + + merge_request + end + + private + + def reviewer_ids + filter_sentinel_values(params.fetch(:reviewer_ids)).first(1) + end + + def update_attrs + @attrs ||= { updated_by: current_user, reviewer_ids: reviewer_ids } + end + + def execute_reviewers_hooks(merge_request, old_reviewers) + execute_hooks( + merge_request, + 'update', + old_associations: { reviewers: old_reviewers } + ) + end + end +end + +MergeRequests::UpdateReviewersService.prepend_mod diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 603da4ef535..0902b5195a1 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -155,11 +155,7 @@ module MergeRequests def resolve_todos(merge_request, old_labels, old_assignees, old_reviewers) return unless has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees, old_reviewers: old_reviewers) - service_user = current_user - - merge_request.run_after_commit_or_now do - ::MergeRequests::ResolveTodosService.new(merge_request, service_user).async_execute - end + resolve_todos_for(merge_request) end def handle_target_branch_change(merge_request) @@ -296,6 +292,36 @@ module MergeRequests def add_time_spent_service @add_time_spent_service ||= ::MergeRequests::AddSpentTimeService.new(project: project, current_user: current_user, params: params) end + + def new_user_ids(merge_request, user_ids, attribute) + # prime the cache - prevent N+1 lookup during authorization loop. + return [] if user_ids.empty? + + merge_request.project.team.max_member_access_for_user_ids(user_ids) + User.id_in(user_ids).map do |user| + if user.can?(:read_merge_request, merge_request) + user.id + else + merge_request.errors.add( + attribute, + "Cannot assign #{user.to_reference} to #{merge_request.to_reference}" + ) + nil + end + end.compact + end + + def resolve_todos_for(merge_request) + service_user = current_user + + merge_request.run_after_commit_or_now do + ::MergeRequests::ResolveTodosService.new(merge_request, service_user).async_execute + end + end + + def filter_sentinel_values(param) + param.reject { _1 == 0 } + end end end diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb index 8c250526efc..cc5c81cf280 100644 --- a/app/services/notes/build_service.rb +++ b/app/services/notes/build_service.rb @@ -16,6 +16,14 @@ module Notes params.merge!(discussion.reply_attributes) end + # The `confidential` param for notes is deprecated with 15.3 + # and renamed to `internal`. + # We still accept `confidential` until the param gets removed from the API. + # Until we have not migrated the database column to `internal` we need to rename + # the parameter. Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/367923. + params[:confidential] = params[:internal] || params[:confidential] + params.delete(:internal) + new_note(params, discussion) end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 4074b1d1182..b7e6a50fa5c 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -88,8 +88,13 @@ module Notes return if quick_actions_service.commands_executed_count.to_i == 0 if update_params.present? - quick_actions_service.apply_updates(update_params, note) - note.commands_changes = update_params + if check_for_reviewer_validity(message, update_params) + quick_actions_service.apply_updates(update_params, note) + note.commands_changes = update_params + else + message = "Reviewers #{MergeRequest.max_number_of_assignees_or_reviewers_message}" + note.errors.add(:validation, message) + end end # We must add the error after we call #save because errors are reset @@ -109,6 +114,18 @@ module Notes } end + def check_for_reviewer_validity(message, update_params) + return true unless Feature.enabled?(:limit_reviewer_and_assignee_size) + + if update_params.key?(:reviewer_ids) + possible_reviewers = update_params[:reviewer_ids]&.uniq&.size + + return false if possible_reviewers > MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS + end + + true + end + def track_event(note, user) track_note_creation_usage_for_issues(note) if note.for_issue? track_note_creation_usage_for_merge_requests(note) if note.for_merge_request? @@ -130,7 +147,8 @@ module Notes end def track_note_creation_usage_for_issues(note) - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_added_action(author: note.author) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_added_action(author: note.author, + project: project) end def track_note_creation_usage_for_merge_requests(note) diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb index c25b1ab0379..eda8bbcbc2e 100644 --- a/app/services/notes/destroy_service.rb +++ b/app/services/notes/destroy_service.rb @@ -15,7 +15,8 @@ module Notes private def track_note_removal_usage_for_issues(note) - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_removed_action(author: note.author) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_removed_action(author: note.author, + project: project) end def track_note_removal_usage_for_merge_requests(note) diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 04fc4c7c944..2dae76feb0b 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -86,7 +86,8 @@ module Notes end def track_note_edit_usage_for_issues(note) - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_edited_action(author: note.author) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_edited_action(author: note.author, + project: project) end def track_note_edit_usage_for_merge_requests(note) diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb index e63e19e365c..bdeebc641b8 100644 --- a/app/services/notification_recipients/build_service.rb +++ b/app/services/notification_recipients/build_service.rb @@ -36,9 +36,5 @@ module NotificationRecipients def self.build_requested_review_recipients(*args) ::NotificationRecipients::Builder::RequestReview.new(*args).notification_recipients end - - def self.build_attention_requested_recipients(*args) - ::NotificationRecipients::Builder::AttentionRequested.new(*args).notification_recipients - end end end diff --git a/app/services/notification_recipients/builder/attention_requested.rb b/app/services/notification_recipients/builder/attention_requested.rb deleted file mode 100644 index cdc371fcece..00000000000 --- a/app/services/notification_recipients/builder/attention_requested.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module NotificationRecipients - module Builder - class AttentionRequested < Base - attr_reader :merge_request, :current_user, :user - - def initialize(merge_request, current_user, user) - @merge_request = merge_request - @current_user = current_user - @user = user - end - - def target - merge_request - end - - def build! - add_recipients(user, :mention, NotificationReason::ATTENTION_REQUESTED) - end - end - end -end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 2477fcd02e5..5a92adfd25a 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -333,14 +333,6 @@ class NotificationService end end - def attention_requested_of_merge_request(merge_request, current_user, user) - recipients = NotificationRecipients::BuildService.build_attention_requested_recipients(merge_request, current_user, user) - - recipients.each do |recipient| - mailer.attention_requested_merge_request_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason).deliver_later - end - end - # When we add labels to a merge request we should send an email to: # # * watchers of the mr's labels @@ -799,7 +791,7 @@ class NotificationService end recipients = NotificationRecipients::BuildService.build_recipients(target, current_user, action: "new") - recipients = recipients.select {|r| new_mentioned_users.include?(r.user) } + recipients = recipients.select { |r| new_mentioned_users.include?(r.user) } recipients.each do |recipient| mailer.send(method, recipient.user.id, target.id, current_user.id, recipient.reason).deliver_later diff --git a/app/services/packages/conan/create_package_file_service.rb b/app/services/packages/conan/create_package_file_service.rb index 1bde9606492..904a1d10bcb 100644 --- a/app/services/packages/conan/create_package_file_service.rb +++ b/app/services/packages/conan/create_package_file_service.rb @@ -13,11 +13,11 @@ module Packages def execute package_file = package.package_files.build( - file: file, - size: params['file.size'], + file: file, + size: params['file.size'], file_name: params[:file_name], file_sha1: params['file.sha1'], - file_md5: params['file.md5'], + file_md5: params['file.md5'], conan_file_metadatum_attributes: { recipe_revision: params[:recipe_revision], package_revision: params[:package_revision], diff --git a/app/services/packages/create_package_file_service.rb b/app/services/packages/create_package_file_service.rb index 5723b0b4717..6e1a5672a52 100644 --- a/app/services/packages/create_package_file_service.rb +++ b/app/services/packages/create_package_file_service.rb @@ -10,12 +10,12 @@ module Packages def execute package_file = package.package_files.build( - file: params[:file], - size: params[:size], - file_name: params[:file_name], - file_sha1: params[:file_sha1], + file: params[:file], + size: params[:size], + file_name: params[:file_name], + file_sha1: params[:file_sha1], file_sha256: params[:file_sha256], - file_md5: params[:file_md5] + file_md5: params[:file_md5] ) if params[:build].present? diff --git a/app/services/packages/debian/create_package_file_service.rb b/app/services/packages/debian/create_package_file_service.rb index fbbc8159ca0..53275fdc9bb 100644 --- a/app/services/packages/debian/create_package_file_service.rb +++ b/app/services/packages/debian/create_package_file_service.rb @@ -17,12 +17,12 @@ module Packages # Debian package file are first uploaded to incoming with empty metadata, # and are moved later by Packages::Debian::ProcessChangesService package.package_files.create!( - file: params[:file], - size: params[:file]&.size, - file_name: params[:file_name], - file_sha1: params[:file_sha1], + file: params[:file], + size: params[:file]&.size, + file_name: params[:file_name], + file_sha1: params[:file_sha1], file_sha256: params[:file]&.sha256, - file_md5: params[:file_md5], + file_md5: params[:file_md5], debian_file_metadatum_attributes: { file_type: 'unknown', architecture: nil, diff --git a/app/services/packages/debian/extract_metadata_service.rb b/app/services/packages/debian/extract_metadata_service.rb index f94587919b9..eb8227d1296 100644 --- a/app/services/packages/debian/extract_metadata_service.rb +++ b/app/services/packages/debian/extract_metadata_service.rb @@ -61,12 +61,12 @@ module Packages def fields strong_memoize(:fields) do if file_type_debian? - package_file.file.use_file do |file_path| - ::Packages::Debian::ExtractDebMetadataService.new(file_path).execute + package_file.file.use_open_file(unlink_early: false) do |file| + ::Packages::Debian::ExtractDebMetadataService.new(file.file_path).execute end elsif file_type_meta? - package_file.file.use_file do |file_path| - ::Packages::Debian::ParseDebian822Service.new(File.read(file_path)).execute.each_value.first + package_file.file.use_open_file do |file| + ::Packages::Debian::ParseDebian822Service.new(file.read).execute.each_value.first end end end diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb index b0a5f37cfa3..a3596314199 100644 --- a/app/services/packages/npm/create_package_service.rb +++ b/app/services/packages/npm/create_package_service.rb @@ -87,11 +87,11 @@ module Packages def file_params { - file: CarrierWaveStringFile.new(Base64.decode64(attachment['data'])), - size: calculated_package_file_size, + file: CarrierWaveStringFile.new(Base64.decode64(attachment['data'])), + size: calculated_package_file_size, file_sha1: version_data[:dist][:shasum], file_name: package_file_name, - build: params[:build] + build: params[:build] } end diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index e5d40b60747..c21a61bcb52 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -32,7 +32,7 @@ module Projects attr_reader :project, :payload, :integration def valid_payload_size? - Gitlab::Utils::DeepSize.new(payload).valid? + Gitlab::Utils::DeepSize.new(payload.to_h).valid? end override :alert_source diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 9bc8bb428fb..6381ee67ce7 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -26,7 +26,7 @@ module Projects return ::Projects::CreateFromTemplateService.new(current_user, params).execute end - @project = Project.new(params) + @project = Project.new(params.merge(creator: current_user)) validate_import_source_enabled! @@ -45,20 +45,14 @@ module Projects set_project_name_from_path # get namespace id - namespace_id = params[:namespace_id] - - if namespace_id - # Find matching namespace and check if it allowed - # for current user if namespace_id passed. - unless current_user.can?(:create_projects, parent_namespace) - @project.namespace_id = nil - deny_namespace - return @project - end - else - # Set current user namespace if namespace_id is nil - @project.namespace_id = current_user.namespace_id - end + namespace_id = params[:namespace_id] || current_user.namespace_id + @project.namespace_id = namespace_id.to_i + + @project.check_personal_projects_limit + return @project if @project.errors.any? + + validate_create_permissions + return @project if @project.errors.any? @relations_block&.call(@project) yield(@project) if block_given? @@ -92,7 +86,9 @@ module Projects protected - def deny_namespace + def validate_create_permissions + return if current_user.can?(:create_projects, parent_namespace) + @project.errors.add(:namespace, "is not valid") end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 70a04cd556a..5fce816064b 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -46,15 +46,15 @@ module Projects def new_fork_params new_params = { - forked_from_project: @project, - visibility_level: target_visibility_level, - description: target_description, - name: target_name, - path: target_path, - shared_runners_enabled: @project.shared_runners_enabled, - namespace_id: target_namespace.id, - fork_network: fork_network, - ci_config_path: @project.ci_config_path, + forked_from_project: @project, + visibility_level: target_visibility_level, + description: target_description, + name: target_name, + path: target_path, + shared_runners_enabled: @project.shared_runners_enabled, + namespace_id: target_namespace.id, + fork_network: fork_network, + ci_config_path: @project.ci_config_path, # We need to set ci_default_git_depth to 0 for the forked project when # @project.ci_default_git_depth is nil in order to keep the same behaviour # and not get ProjectCiCdSetting::DEFAULT_GIT_DEPTH set on create @@ -63,8 +63,8 @@ module Projects # been instantiated to avoid ActiveRecord trying to create it when # initializing the project, as that would cause a foreign key constraint # exception. - relations_block: -> (project) { build_fork_network_member(project) }, - skip_disk_validation: skip_disk_validation, + relations_block: -> (project) { build_fork_network_member(project) }, + skip_disk_validation: skip_disk_validation, external_authorization_classification_label: @project.external_authorization_classification_label, suggestion_commit_message: @project.suggestion_commit_message, merge_commit_template: @project.merge_commit_template, diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index d8d35422590..ddbcfbb675c 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -54,15 +54,21 @@ module Projects end def save_all! + log_info('Project export started') + if save_exporters && save_export_archive - notify_success + log_info('Project successfully exported') else notify_error! end end def save_exporters - exporters.all?(&:save) + exporters.all? do |exporter| + log_info("#{exporter.class.name} saver started") + + exporter.save + end end def save_export_archive @@ -78,11 +84,12 @@ module Projects end def project_tree_saver - @project_tree_saver ||= tree_saver_class.new(project: project, - current_user: current_user, - shared: shared, - params: params, - logger: logger) + @project_tree_saver ||= tree_saver_class.new( + project: project, + current_user: current_user, + shared: shared, + params: params, + logger: logger) end def tree_saver_class @@ -127,11 +134,10 @@ module Projects raise Gitlab::ImportExport::Error, shared.errors.to_sentence end - def notify_success + def log_info(message) logger.info( - message: 'Project successfully exported', - project_name: project.name, - project_id: project.id + message: message, + **log_base_data ) end @@ -139,8 +145,7 @@ module Projects logger.error( message: 'Project export error', export_errors: shared.errors.join(', '), - project_name: project.name, - project_id: project.id + **log_base_data ) user = current_user @@ -150,6 +155,10 @@ module Projects NotificationService.new.project_not_exported(project, user, errors) end end + + def log_base_data + @log_base_data ||= Gitlab::ImportExport::LogUtil.exportable_to_log_payload(project) + end end end end diff --git a/app/services/projects/import_export/relation_export_service.rb b/app/services/projects/import_export/relation_export_service.rb new file mode 100644 index 00000000000..dce40cf18ba --- /dev/null +++ b/app/services/projects/import_export/relation_export_service.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class RelationExportService + include Gitlab::ImportExport::CommandLineUtil + + def initialize(relation_export, jid) + @relation_export = relation_export + @jid = jid + @logger = Gitlab::Export::Logger.build + end + + def execute + relation_export.update!(status_event: :start, jid: jid) + + mkdir_p(shared.export_path) + mkdir_p(shared.archive_path) + + if relation_saver.save + compress_export_path + upload_compressed_file + relation_export.finish! + else + fail_export(shared.errors.join(', ')) + end + rescue StandardError => e + fail_export(e.message) + ensure + FileUtils.remove_entry(shared.export_path) if File.exist?(shared.export_path) + FileUtils.remove_entry(shared.archive_path) if File.exist?(shared.archive_path) + end + + private + + attr_reader :relation_export, :jid, :logger + + delegate :relation, :project_export_job, to: :relation_export + delegate :project, to: :project_export_job + + def shared + project.import_export_shared + end + + def relation_saver + case relation + when Projects::ImportExport::RelationExport::UPLOADS_RELATION + Gitlab::ImportExport::UploadsSaver.new(project: project, shared: shared) + when Projects::ImportExport::RelationExport::REPOSITORY_RELATION + Gitlab::ImportExport::RepoSaver.new(exportable: project, shared: shared) + when Projects::ImportExport::RelationExport::WIKI_REPOSITORY_RELATION + Gitlab::ImportExport::WikiRepoSaver.new(exportable: project, shared: shared) + when Projects::ImportExport::RelationExport::LFS_OBJECTS_RELATION + Gitlab::ImportExport::LfsSaver.new(project: project, shared: shared) + when Projects::ImportExport::RelationExport::SNIPPETS_REPOSITORY_RELATION + Gitlab::ImportExport::SnippetsRepoSaver.new(project: project, shared: shared, current_user: nil) + when Projects::ImportExport::RelationExport::DESIGN_REPOSITORY_RELATION + Gitlab::ImportExport::DesignRepoSaver.new(exportable: project, shared: shared) + else + Gitlab::ImportExport::Project::RelationSaver.new( + project: project, + shared: shared, + relation: relation + ) + end + end + + def upload_compressed_file + upload = relation_export.build_upload + File.open(archive_file_full_path) { |file| upload.export_file = file } + upload.save! + end + + def compress_export_path + tar_czf(archive: archive_file_full_path, dir: shared.export_path) + end + + def archive_file_full_path + @archive_file ||= File.join(shared.archive_path, "#{relation}.tar.gz") + end + + def fail_export(error_message) + relation_export.update!(status_event: :fail_op, export_error: error_message.truncate(300)) + + logger.error( + message: 'Project relation export failed', + export_error: error_message, + project_export_job_id: project_export_job.id, + project_name: project.name, + project_id: project.id + ) + end + end + end +end diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb index c032fbf1508..eaf73b78c1c 100644 --- a/app/services/projects/lfs_pointers/lfs_download_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_service.rb @@ -50,7 +50,7 @@ module Projects def find_or_create_lfs_object(tmp_file) lfs_obj = LfsObject.safe_find_or_create_by!( - oid: lfs_oid, + oid: lfs_oid, size: lfs_size ) diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index bc517ee3d6f..6265a74fad2 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -56,7 +56,7 @@ module Projects attr_reader :project, :payload def valid_payload_size? - Gitlab::Utils::DeepSize.new(payload).valid? + Gitlab::Utils::DeepSize.new(payload.to_h).valid? end def max_alerts_exceeded? diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 666227951c6..3cb5a564ba5 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -121,6 +121,8 @@ module Projects # Overridden in EE def post_update_hooks(project) ensure_personal_project_owner_membership(project) + + publish_event end # Overridden in EE @@ -268,6 +270,18 @@ module Projects CustomerRelations::IssueContact.delete_for_project(project.id) end + + def publish_event + event = ::Projects::ProjectTransferedEvent.new(data: { + project_id: project.id, + old_namespace_id: old_namespace.id, + old_root_namespace_id: old_namespace.root_ancestor.id, + new_namespace_id: new_namespace.id, + new_root_namespace_id: new_namespace.root_ancestor.id + }) + + Gitlab::EventStore.publish(event) + end end end diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index 705d23ec704..f686f14b5b3 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -76,11 +76,11 @@ module Projects if message.present? Gitlab::AppJsonLogger.info(message: "Error synching remote mirror", - project_id: project.id, - project_path: project.full_path, - remote_mirror_id: remote_mirror.id, - lfs_sync_failed: lfs_sync_failed, - divergent_ref_list: response.divergent_refs) + project_id: project.id, + project_path: project.full_path, + remote_mirror_id: remote_mirror.id, + lfs_sync_failed: lfs_sync_failed, + divergent_ref_list: response.divergent_refs) end [failed, message] diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 5708421014a..d757b0700b9 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -121,6 +121,8 @@ module Projects end update_pending_builds if runners_settings_toggled? + + publish_event end def after_rename_service(project) @@ -209,6 +211,18 @@ module Projects [] end end + + def publish_event + return unless project.archived_previously_changed? + + event = Projects::ProjectArchivedEvent.new(data: { + project_id: @project.id, + namespace_id: @project.namespace_id, + root_namespace_id: @project.root_namespace.id + }) + + Gitlab::EventStore.publish(event) + end end end diff --git a/app/services/protected_branches/base_service.rb b/app/services/protected_branches/base_service.rb index f48e02ab4b5..d26c1b148bf 100644 --- a/app/services/protected_branches/base_service.rb +++ b/app/services/protected_branches/base_service.rb @@ -13,5 +13,9 @@ module ProtectedBranches def after_execute(*) # overridden in EE::ProtectedBranches module end + + def refresh_cache + CacheService.new(@project, @current_user, @params).refresh + end end end diff --git a/app/services/protected_branches/cache_service.rb b/app/services/protected_branches/cache_service.rb new file mode 100644 index 00000000000..8c521f4ebcb --- /dev/null +++ b/app/services/protected_branches/cache_service.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module ProtectedBranches + class CacheService < ProtectedBranches::BaseService + CACHE_ROOT_KEY = 'cache:gitlab:protected_branch' + TTL_UNSET = -1 + CACHE_EXPIRE_IN = 1.day + CACHE_LIMIT = 1000 + + def fetch(ref_name, dry_run: false) + record = OpenSSL::Digest::SHA256.hexdigest(ref_name) + + Gitlab::Redis::Cache.with do |redis| + cached_result = redis.hget(redis_key, record) + + decoded_result = Gitlab::Redis::Boolean.decode(cached_result) unless cached_result.nil? + + # If we're dry-running, don't break because we need to check against + # the real value to ensure the cache is working properly. + # If the result is nil we'll need to run the block, so don't break yet. + break decoded_result unless dry_run || decoded_result.nil? + + calculated_value = yield + + check_and_log_discrepancy(decoded_result, calculated_value, ref_name) if dry_run + + redis.hset(redis_key, record, Gitlab::Redis::Boolean.encode(calculated_value)) + + # We don't want to extend cache expiration time + if redis.ttl(redis_key) == TTL_UNSET + redis.expire(redis_key, CACHE_EXPIRE_IN) + end + + # If the cache record has too many elements, then something went wrong and + # it's better to drop the cache key. + if redis.hlen(redis_key) > CACHE_LIMIT + redis.unlink(redis_key) + end + + calculated_value + end + end + + def refresh + Gitlab::Redis::Cache.with { |redis| redis.unlink(redis_key) } + end + + private + + def check_and_log_discrepancy(cached_value, real_value, ref_name) + return if cached_value.nil? + return if cached_value == real_value + + encoded_ref_name = Gitlab::EncodingHelper.encode_utf8_with_replacement_character(ref_name) + + log_error( + 'class' => self.class.name, + 'message' => "Cache mismatch '#{encoded_ref_name}': cached value: #{cached_value}, real value: #{real_value}", + 'project_id' => @project.id, + 'project_path' => @project.full_path + ) + end + + def redis_key + @redis_key ||= [CACHE_ROOT_KEY, @project.id].join(':') + end + end +end diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb index dada449989a..903addf7afc 100644 --- a/app/services/protected_branches/create_service.rb +++ b/app/services/protected_branches/create_service.rb @@ -7,6 +7,8 @@ module ProtectedBranches save_protected_branch + refresh_cache + protected_branch end diff --git a/app/services/protected_branches/destroy_service.rb b/app/services/protected_branches/destroy_service.rb index 47332ace417..01d3b68314f 100644 --- a/app/services/protected_branches/destroy_service.rb +++ b/app/services/protected_branches/destroy_service.rb @@ -5,7 +5,7 @@ module ProtectedBranches def execute(protected_branch) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_protected_branch, protected_branch) - protected_branch.destroy + protected_branch.destroy.tap { refresh_cache } end end end diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb index 1e70f2d9793..c155e0022f5 100644 --- a/app/services/protected_branches/update_service.rb +++ b/app/services/protected_branches/update_service.rb @@ -10,6 +10,8 @@ module ProtectedBranches if protected_branch.update(params) after_execute(protected_branch: protected_branch, old_merge_access_levels: old_merge_access_levels, old_push_access_levels: old_push_access_levels) + + refresh_cache end protected_branch diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index e3134070231..2588d2187a5 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -19,10 +19,6 @@ module Releases create_release(tag, evidence_pipeline) end - def find_or_build_release - release || build_release(existing_tag) - end - private def ensure_tag diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb index 03ac839c509..04f917ec8ef 100644 --- a/app/services/resource_events/change_labels_service.rb +++ b/app/services/resource_events/change_labels_service.rb @@ -24,6 +24,9 @@ module ResourceEvents end ApplicationRecord.legacy_bulk_insert(ResourceLabelEvent.table_name, labels) # rubocop:disable Gitlab/BulkInsert + + create_timeline_events_from(added_labels: added_labels, removed_labels: removed_labels) + resource.expire_note_etag_cache Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user) if resource.is_a?(Issue) @@ -41,6 +44,17 @@ module ResourceEvents raise ArgumentError, "Unknown resource type #{resource.class.name}" end end + + def create_timeline_events_from(added_labels: [], removed_labels: []) + return unless resource.incident? + + IncidentManagement::TimelineEvents::CreateService.change_labels( + resource, + user, + added_labels: added_labels, + removed_labels: removed_labels + ) + end end end diff --git a/app/services/security/ci_configuration/sast_parser_service.rb b/app/services/security/ci_configuration/sast_parser_service.rb index cae9a90f0a0..16a9efcefdf 100644 --- a/app/services/security/ci_configuration/sast_parser_service.rb +++ b/app/services/security/ci_configuration/sast_parser_service.rb @@ -75,7 +75,11 @@ module Security def sast_excluded_analyzers strong_memoize(:sast_excluded_analyzers) do excluded_analyzers = gitlab_ci_yml_attributes["SAST_EXCLUDED_ANALYZERS"] || sast_template_attributes["SAST_EXCLUDED_ANALYZERS"] - excluded_analyzers.split(',').map(&:strip) rescue [] + begin + excluded_analyzers.split(',').map(&:strip) + rescue StandardError + [] + end end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index d7e4b53b5de..9de73a00eac 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -57,7 +57,7 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).unrelate_issuable(noteable_ref) end - # Called when the due_date of a Noteable is changed + # Called when the due_date or start_date of a Noteable is changed # # noteable - Noteable object # project - Project owning noteable @@ -68,11 +68,15 @@ module SystemNoteService # # "removed due date" # - # "changed due date to September 20, 2018" + # "changed due date to September 20, 2018 and changed start date to September 25, 2018" # # Returns the created Note object - def change_due_date(noteable, project, author, due_date) - ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_due_date(due_date) + def change_start_date_or_due_date(noteable, project, author, changed_dates) + ::SystemNotes::TimeTrackingService.new( + noteable: noteable, + project: project, + author: author + ).change_start_date_or_due_date(changed_dates) end # Called when the estimated time of a Noteable is changed @@ -111,6 +115,24 @@ module SystemNoteService ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_spent end + # Called when a timelog is added to an issuable + # + # issuable - Issuable object (Issue, WorkItem or MergeRequest) + # project - Project owning the issuable + # author - User performing the change + # timelog - Created timelog + # + # Example Note text: + # + # "subtracted 1h 15m of time spent" + # + # "added 2h 30m of time spent" + # + # Returns the created Note object + def created_timelog(issuable, project, author, timelog) + ::SystemNotes::TimeTrackingService.new(noteable: issuable, project: project, author: author).created_timelog(timelog) + end + # Called when a timelog is removed from a Noteable # # noteable - Noteable object @@ -134,14 +156,6 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_status(status, source) end - def request_attention(noteable, project, author, user) - ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).request_attention(user) - end - - def remove_attention_request(noteable, project, author, user) - ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).remove_attention_request(user) - end - # Called when 'merge when pipeline succeeds' is executed def merge_when_pipeline_succeeds(noteable, project, author, sha) ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).merge_when_pipeline_succeeds(sha) @@ -256,8 +270,8 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_moved(noteable_ref, direction) end - def noteable_cloned(noteable, project, noteable_ref, author, direction:) - ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_cloned(noteable_ref, direction) + def noteable_cloned(noteable, project, noteable_ref, author, direction:, created_at: nil) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_cloned(noteable_ref, direction, created_at: created_at) end def mark_duplicate_issue(noteable, project, author, canonical_issue) @@ -280,6 +294,18 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: mentioned).cross_reference_disallowed?(mentioned_in) end + def relate_work_item(noteable, work_item, user) + ::SystemNotes::IssuablesService + .new(noteable: noteable, project: noteable.project, author: user) + .hierarchy_changed(work_item, 'relate') + end + + def unrelate_work_item(noteable, work_item, user) + ::SystemNotes::IssuablesService + .new(noteable: noteable, project: noteable.project, author: user) + .hierarchy_changed(work_item, 'unrelate') + end + def zoom_link_added(issue, project, author) ::SystemNotes::ZoomService.new(noteable: issue, project: project, author: author).zoom_link_added end diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index f9e5c3725d8..75903fde39e 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -178,6 +178,24 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end + # Called when the hierarchy of a work item is changed + # + # noteable - Noteable object that responds to `work_item_parent` and `work_item_children` + # project - Project owning noteable + # author - User performing the change + # + # Example Note text: + # + # "added #1 as child Task" + # + # Returns the created Note object + def hierarchy_changed(work_item, action) + params = hierarchy_note_params(action, noteable, work_item) + + create_note(NoteSummary.new(noteable, project, author, params[:parent_note_body], action: params[:parent_action])) + create_note(NoteSummary.new(work_item, project, author, params[:child_note_body], action: params[:child_action])) + end + # Called when the description of a Noteable is changed # # noteable - Noteable object that responds to `description` @@ -255,12 +273,12 @@ module SystemNotes # # Example Note text: # - # "marked the task Whatever as completed." + # "marked the checklist item Whatever as completed." # # Returns the created Note object def change_task_status(new_task) status_label = new_task.complete? ? Taskable::COMPLETED : Taskable::INCOMPLETE - body = "marked the task **#{new_task.source}** as #{status_label}" + body = "marked the checklist item **#{new_task.source}** as #{status_label}" issue_activity_counter.track_issue_description_changed_action(author: author) if noteable.is_a?(Issue) @@ -294,13 +312,14 @@ module SystemNotes # # noteable_ref - Referenced noteable # direction - symbol, :to or :from + # created_at - timestamp for the system note, defaults to current time # # Example Note text: # # "cloned to some_namespace/project_new#11" # # Returns the created Note object - def noteable_cloned(noteable_ref, direction) + def noteable_cloned(noteable_ref, direction, created_at: nil) unless [:to, :from].include?(direction) raise ArgumentError, "Invalid direction `#{direction}`" end @@ -308,9 +327,11 @@ module SystemNotes cross_reference = noteable_ref.to_reference(project) body = "cloned #{direction} #{cross_reference}" - issue_activity_counter.track_issue_cloned_action(author: author) if noteable.is_a?(Issue) && direction == :to + if noteable.is_a?(Issue) && direction == :to + issue_activity_counter.track_issue_cloned_action(author: author, project: project) + end - create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned')) + create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned', created_at: created_at)) end # Called when the confidentiality changes @@ -367,36 +388,6 @@ module SystemNotes existing_mentions_for(mentioned_in, noteable, notes).exists? end - # Called when a user's attention has been requested for a Notable - # - # user - User's whos attention has been requested - # - # Example Note text: - # - # "requested attention from @eli.wisoky" - # - # Returns the created Note object - def request_attention(user) - body = "requested attention from #{user.to_reference}" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'attention_requested')) - end - - # Called when a user's attention request has been removed for a Notable - # - # user - User's whos attention request has been removed - # - # Example Note text: - # - # "removed attention request from @eli.wisoky" - # - # Returns the created Note object - def remove_attention_request(user) - body = "removed attention request from #{user.to_reference}" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'attention_request_removed')) - end - # Called when a Noteable has been marked as the canonical Issue of a duplicate # # duplicate_issue - Issue that was a duplicate of this @@ -506,6 +497,29 @@ module SystemNotes def track_cross_reference_action issue_activity_counter.track_issue_cross_referenced_action(author: author) if noteable.is_a?(Issue) end + + def hierarchy_note_params(action, parent, child) + return {} unless child && parent + + child_type = child.issue_type.humanize(capitalize: false) + parent_type = parent.issue_type.humanize(capitalize: false) + + if action == 'relate' + { + parent_note_body: "added #{child.to_reference} as child #{child_type}", + child_note_body: "added #{parent.to_reference} as parent #{parent_type}", + parent_action: 'relate_to_child', + child_action: 'relate_to_parent' + } + else + { + parent_note_body: "removed child #{child_type} #{child.to_reference}", + child_note_body: "removed parent #{parent_type} #{parent.to_reference}", + parent_action: 'unrelate_from_child', + child_action: 'unrelate_from_parent' + } + end + end end end diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb index a9b1f6d3d37..68df52a03c7 100644 --- a/app/services/system_notes/time_tracking_service.rb +++ b/app/services/system_notes/time_tracking_service.rb @@ -2,8 +2,9 @@ module SystemNotes class TimeTrackingService < ::SystemNotes::BaseService - # Called when the due_date of a Noteable is changed + # Called when the start_date or due_date of an Issue/WorkItem is changed # + # start_date - Start date being assigned, or nil # due_date - Due date being assigned, or nil # # Example Note text: @@ -11,14 +12,23 @@ module SystemNotes # "removed due date" # # "changed due date to September 20, 2018" + + # "changed start date to September 20, 2018 and changed due date to September 25, 2018" # # Returns the created Note object - def change_due_date(due_date) - body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date' + def change_start_date_or_due_date(changed_dates = {}) + return if changed_dates.empty? + + # Using instance_of because WorkItem < Issue. We don't want to track work item updates as issue updates + if noteable.instance_of?(Issue) && changed_dates.key?('due_date') + issue_activity_counter.track_issue_due_date_changed_action(author: author) + end - issue_activity_counter.track_issue_due_date_changed_action(author: author) if noteable.is_a?(Issue) + work_item_activity_counter.track_work_item_date_changed_action(author: author) if noteable.is_a?(WorkItem) - create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date')) + create_note( + NoteSummary.new(noteable, project, author, changed_date_body(changed_dates), action: 'start_date_or_due_date') + ) end # Called when the estimated time of a Noteable is changed @@ -76,6 +86,32 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) end + # Called when a timelog is added to an issuable + # + # timelog - Added timelog + # + # Example Note text: + # + # "subtracted 1h 15m of time spent" + # + # "added 2h 30m of time spent" + # + # Returns the created Note object + def created_timelog(timelog) + time_spent = timelog.time_spent + spent_at = timelog.spent_at&.to_date + parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs) + action = time_spent > 0 ? 'added' : 'subtracted' + + text_parts = ["#{action} #{parsed_time} of time spent"] + text_parts << "at #{spent_at}" if spent_at && spent_at != DateTime.current.to_date + body = text_parts.join(' ') + + issue_activity_counter.track_issue_time_spent_changed_action(author: author) if noteable.is_a?(Issue) + + create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + end + def remove_timelog(timelog) time_spent = timelog.time_spent spent_at = timelog.spent_at&.to_date @@ -90,8 +126,33 @@ module SystemNotes private + def changed_date_body(changed_dates) + %w[start_date due_date].each_with_object([]) do |date_field, word_array| + next unless changed_dates.key?(date_field) + + word_array << 'and' if word_array.any? + + word_array << message_for_changed_date(changed_dates, date_field) + end.join(' ') + end + + def message_for_changed_date(changed_dates, date_key) + changed_date = changed_dates[date_key].last + readable_date = date_key.humanize.downcase + + if changed_date.nil? + "removed #{readable_date}" + else + "changed #{readable_date} to #{changed_date.to_s(:long)}" + end + end + def issue_activity_counter Gitlab::UsageDataCounters::IssueActivityUniqueCounter end + + def work_item_activity_counter + Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter + end end end diff --git a/app/services/timelogs/base_service.rb b/app/services/timelogs/base_service.rb index be46c26e047..e09264864fd 100644 --- a/app/services/timelogs/base_service.rb +++ b/app/services/timelogs/base_service.rb @@ -5,11 +5,26 @@ module Timelogs include BaseServiceUtility include Gitlab::Utils::StrongMemoize - attr_accessor :timelog, :current_user + attr_accessor :current_user - def initialize(timelog, user) - @timelog = timelog + def initialize(user) @current_user = user end + + def success(timelog) + ServiceResponse.success(payload: { + timelog: timelog + }) + end + + def error(message, http_status = nil) + ServiceResponse.error(message: message, http_status: http_status) + end + + def error_in_save(timelog) + return error(_("Failed to save timelog")) if timelog.errors.empty? + + error(timelog.errors.full_messages.to_sentence) + end end end diff --git a/app/services/timelogs/create_service.rb b/app/services/timelogs/create_service.rb new file mode 100644 index 00000000000..12181cec20a --- /dev/null +++ b/app/services/timelogs/create_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Timelogs + class CreateService < Timelogs::BaseService + attr_accessor :issuable, :time_spent, :spent_at, :summary + + def initialize(issuable, time_spent, spent_at, summary, user) + super(user) + + @issuable = issuable + @time_spent = time_spent + @spent_at = spent_at + @summary = summary + end + + def execute + unless can?(current_user, :create_timelog, issuable) + return error( + _("%{issuable_class_name} doesn't exist or you don't have permission to add timelog to it.") % { + issuable_class_name: issuable.nil? ? 'Issuable' : issuable.base_class_name + }, 404) + end + + issue = issuable if issuable.is_a?(Issue) + merge_request = issuable if issuable.is_a?(MergeRequest) + + timelog = Timelog.new( + time_spent: time_spent, + spent_at: spent_at, + summary: summary, + user: current_user, + issue: issue, + merge_request: merge_request, + note: nil + ) + + if !timelog.save + error_in_save(timelog) + else + SystemNoteService.created_timelog(issuable, issuable.project, current_user, timelog) + success(timelog) + end + end + end +end diff --git a/app/services/timelogs/delete_service.rb b/app/services/timelogs/delete_service.rb index 0df888a3706..e72dfd98494 100644 --- a/app/services/timelogs/delete_service.rb +++ b/app/services/timelogs/delete_service.rb @@ -2,11 +2,17 @@ module Timelogs class DeleteService < Timelogs::BaseService + attr_accessor :timelog + + def initialize(timelog, user) + super(user) + + @timelog = timelog + end + def execute unless can?(current_user, :admin_timelog, timelog) - return ServiceResponse.error( - message: "Timelog doesn't exist or you don't have permission to delete it", - http_status: 404) + return error(_("Timelog doesn't exist or you don't have permission to delete it"), 404) end if timelog.destroy @@ -17,9 +23,9 @@ module Timelogs SystemNoteService.remove_timelog(issuable, issuable.project, current_user, timelog) end - ServiceResponse.success(payload: timelog) + success(timelog) else - ServiceResponse.error(message: 'Failed to remove timelog', http_status: 400) + error(_('Failed to remove timelog'), 400) end end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 14cf264cc51..6ae394072c6 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -218,11 +218,6 @@ class TodoService create_todos(reviewers, attributes) end - def create_attention_requested_todo(target, author, users) - attributes = attributes_for_todo(target.project, target, author, Todo::ATTENTION_REQUESTED) - create_todos(users, attributes) - end - private def create_todos(users, attributes) diff --git a/app/services/todos/destroy/destroyed_issuable_service.rb b/app/services/todos/destroy/destroyed_issuable_service.rb index 7a85b59eeea..759c430ec7a 100644 --- a/app/services/todos/destroy/destroyed_issuable_service.rb +++ b/app/services/todos/destroy/destroyed_issuable_service.rb @@ -5,9 +5,14 @@ module Todos class DestroyedIssuableService BATCH_SIZE = 100 + # Since we are moving towards work items, in some instances we create todos with + # `target_type: WorkItem` in other instances we still create todos with `target_type: Issue` + # So when an issue/work item is deleted, we just make sure to delete todos for both target types + BOUND_TARGET_TYPES = %w(Issue WorkItem).freeze + def initialize(target_id, target_type) @target_id = target_id - @target_type = target_type + @target_type = BOUND_TARGET_TYPES.include?(target_type) ? BOUND_TARGET_TYPES : target_type end def execute diff --git a/app/services/topics/merge_service.rb b/app/services/topics/merge_service.rb new file mode 100644 index 00000000000..0d256579fe0 --- /dev/null +++ b/app/services/topics/merge_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Topics + class MergeService + attr_accessor :source_topic, :target_topic + + def initialize(source_topic, target_topic) + @source_topic = source_topic + @target_topic = target_topic + end + + def execute + validate_parameters! + + ::Projects::ProjectTopic.transaction do + move_project_topics + refresh_target_topic_counters + delete_source_topic + end + end + + private + + def validate_parameters! + raise ArgumentError, 'The source topic is not a topic.' unless source_topic.is_a?(Projects::Topic) + raise ArgumentError, 'The target topic is not a topic.' unless target_topic.is_a?(Projects::Topic) + raise ArgumentError, 'The source topic and the target topic are identical.' if source_topic == target_topic + end + + # rubocop: disable CodeReuse/ActiveRecord + def move_project_topics + project_ids_for_projects_currently_using_source_and_target = ::Projects::ProjectTopic + .where(topic_id: target_topic).select(:project_id) + # Only update for projects that exclusively use the source topic + ::Projects::ProjectTopic.where(topic_id: source_topic.id) + .where.not(project_id: project_ids_for_projects_currently_using_source_and_target) + .update_all(topic_id: target_topic.id) + + # Delete source topic for projects that were using source and target + ::Projects::ProjectTopic.where(topic_id: source_topic.id).delete_all + end + + def refresh_target_topic_counters + target_topic.update!( + total_projects_count: total_projects_count(target_topic.id), + non_private_projects_count: non_private_projects_count(target_topic.id) + ) + end + + def delete_source_topic + source_topic.destroy! + end + + def total_projects_count(topic_id) + ::Projects::ProjectTopic.where(topic_id: topic_id).count + end + + def non_private_projects_count(topic_id) + ::Projects::ProjectTopic.joins(:project).where(topic_id: topic_id).where('projects.visibility_level in (10, 20)') + .count + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/services/uploads/destroy_service.rb b/app/services/uploads/destroy_service.rb new file mode 100644 index 00000000000..1f0d99ff7bb --- /dev/null +++ b/app/services/uploads/destroy_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Uploads + class DestroyService < BaseService + attr_accessor :model, :current_user + + def initialize(model, user = nil) + @model = model + @current_user = user + end + + def execute(secret, filename) + upload = find_upload(secret, filename) + + unless current_user && upload && current_user.can?(:destroy_upload, upload) + return error(_("The resource that you are attempting to access does not "\ + "exist or you don't have permission to perform this action.")) + end + + if upload.destroy + success(upload: upload) + else + error(_('Upload could not be deleted.')) + end + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def find_upload(secret, filename) + uploader = uploader_class.new(model, secret: secret) + upload_paths = uploader.upload_paths(filename) + + Upload.find_by(model: model, uploader: uploader_class.to_s, path: upload_paths) + rescue FileUploader::InvalidSecret + nil + end + # rubocop: enable CodeReuse/ActiveRecord + + def uploader_class + case model + when Group + NamespaceFileUploader + when Project + FileUploader + else + raise ArgumentError, "unknown uploader for #{model.class.name}" + end + end + end +end diff --git a/app/services/users/dismiss_namespace_callout_service.rb b/app/services/users/dismiss_namespace_callout_service.rb new file mode 100644 index 00000000000..51261a93e20 --- /dev/null +++ b/app/services/users/dismiss_namespace_callout_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Users + class DismissNamespaceCalloutService < DismissCalloutService + private + + def callout + current_user.find_or_initialize_namespace_callout(params[:feature_name], params[:namespace_id]) + end + end +end diff --git a/app/services/users/dismiss_project_callout_service.rb b/app/services/users/dismiss_project_callout_service.rb new file mode 100644 index 00000000000..23549b3b194 --- /dev/null +++ b/app/services/users/dismiss_project_callout_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Users + class DismissProjectCalloutService < DismissCalloutService + private + + def callout + current_user.find_or_initialize_project_callout(params[:feature_name], params[:project_id]) + end + end +end diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index c3df9b153a1..cb2711b6fee 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -17,7 +17,7 @@ module Users end def execute(validate: true, check_password: false, &block) - yield(@user) if block_given? + yield(@user) if block user_exists = @user.persisted? @user.user_detail # prevent assignment diff --git a/app/services/web_hooks/admin_destroy_service.rb b/app/services/web_hooks/admin_destroy_service.rb new file mode 100644 index 00000000000..e9835801a39 --- /dev/null +++ b/app/services/web_hooks/admin_destroy_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module WebHooks + # A variant of the destroy service that can only be used by an administrator + # from a rake task. + class AdminDestroyService < WebHooks::DestroyService + def initialize(rake_task:) + super(nil) + @rake_task = rake_task + end + + def authorized?(web_hook) + @rake_task.present? # Not impossible to circumvent, but you need to provide something + end + + def log_message(hook) + "An administrator scheduled a deletion of logs for hook ID #{hook.id} from #{@rake_task.name}" + end + end +end diff --git a/app/services/web_hooks/destroy_service.rb b/app/services/web_hooks/destroy_service.rb index 54c6c7ea71b..dbd164ab20e 100644 --- a/app/services/web_hooks/destroy_service.rb +++ b/app/services/web_hooks/destroy_service.rb @@ -1,25 +1,41 @@ # frozen_string_literal: true module WebHooks + # Destroy a hook, and schedule the logs for deletion. class DestroyService + include Services::ReturnServiceResponses + attr_accessor :current_user + DENIED = 'Insufficient permissions' + def initialize(current_user) @current_user = current_user end - # Destroy the hook immediately, schedule the logs for deletion def execute(web_hook) + return error(DENIED, 401) unless authorized?(web_hook) + hook_id = web_hook.id if web_hook.destroy WebHooks::LogDestroyWorker.perform_async({ 'hook_id' => hook_id }) - Gitlab::AppLogger.info("User #{current_user&.id} scheduled a deletion of logs for hook ID #{hook_id}") + Gitlab::AppLogger.info(log_message(web_hook)) - ServiceResponse.success(payload: { async: false }) + success({ async: false }) else - ServiceResponse.error(message: "Unable to destroy #{web_hook.model_name.human}") + error("Unable to destroy #{web_hook.model_name.human}", 500) end end + + private + + def log_message(hook) + "User #{current_user&.id} scheduled a deletion of logs for hook ID #{hook.id}" + end + + def authorized?(web_hook) + Ability.allowed?(current_user, :destroy_web_hook, web_hook) + end end end diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb index 17dcf615830..5be8aee3ae8 100644 --- a/app/services/web_hooks/log_execution_service.rb +++ b/app/services/web_hooks/log_execution_service.rb @@ -14,7 +14,6 @@ module WebHooks @hook = hook @log_data = log_data.transform_keys(&:to_sym) @response_category = response_category - @prev_state = hook.active_state(ignore_flag: true) end def execute @@ -43,36 +42,12 @@ module WebHooks hook.failed! end - log_state_change hook.update_last_failure end rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError raise if raise_lock_error? end - def log_state_change - new_state = hook.active_state(ignore_flag: true) - - return if @prev_state == new_state - - Gitlab::AuthLogger.info( - message: 'WebHook change active_state', - # identification - hook_id: hook.id, - hook_type: hook.type, - project_id: hook.project_id, - group_id: hook.group_id, - # relevant data - prev_state: @prev_state, - new_state: new_state, - duration: log_data[:execution_duration], - response_status: log_data[:response_status], - recent_hook_failures: hook.recent_failures, - # context - **Gitlab::ApplicationContext.current - ) - end - def lock_name "web_hooks:update_hook_failure_state:#{hook.id}" end diff --git a/app/services/webauthn/authenticate_service.rb b/app/services/webauthn/authenticate_service.rb index a575a853995..52437a77df8 100644 --- a/app/services/webauthn/authenticate_service.rb +++ b/app/services/webauthn/authenticate_service.rb @@ -30,6 +30,8 @@ module Webauthn false end + private + ## # Validates that webauthn_credential is syntactically valid # diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb index 6a773a84225..5cc358c4b4f 100644 --- a/app/services/work_items/create_and_link_service.rb +++ b/app/services/work_items/create_and_link_service.rb @@ -7,19 +7,20 @@ module WorkItems # new work items that were never associated with other work items as expected. class CreateAndLinkService def initialize(project:, current_user: nil, params: {}, spam_params:, link_params: {}) - @create_service = CreateService.new( - project: project, - current_user: current_user, - params: params, - spam_params: spam_params - ) @project = project @current_user = current_user + @params = params @link_params = link_params + @spam_params = spam_params end def execute - create_result = @create_service.execute + create_result = CreateService.new( + project: @project, + current_user: @current_user, + params: @params.merge(title: @params[:title].strip).reverse_merge(confidential: confidential_parent), + spam_params: @spam_params + ).execute return create_result if create_result.error? work_item = create_result[:work_item] @@ -40,6 +41,10 @@ module WorkItems private + def confidential_parent + !!@link_params[:parent_work_item]&.confidential + end + def payload(work_item) { work_item: work_item } end diff --git a/app/services/work_items/parent_links/create_service.rb b/app/services/work_items/parent_links/create_service.rb index 9940776e367..e7906f1fcdd 100644 --- a/app/services/work_items/parent_links/create_service.rb +++ b/app/services/work_items/parent_links/create_service.rb @@ -41,10 +41,8 @@ module WorkItems params[:issuable_references] end - # TODO: Create system notes when work item's parent or children are updated - # See https://gitlab.com/gitlab-org/gitlab/-/issues/362213 def create_notes(work_item) - # no-op + SystemNoteService.relate_work_item(issuable, work_item, current_user) end def target_issuable_type diff --git a/app/services/work_items/parent_links/destroy_service.rb b/app/services/work_items/parent_links/destroy_service.rb index 55870d44db9..19770b3e4b5 100644 --- a/app/services/work_items/parent_links/destroy_service.rb +++ b/app/services/work_items/parent_links/destroy_service.rb @@ -14,10 +14,8 @@ module WorkItems private - # TODO: Create system notes when work item's parent or children are removed - # See https://gitlab.com/gitlab-org/gitlab/-/issues/362213 def create_notes - # no-op + SystemNoteService.unrelate_work_item(parent, child, current_user) end def not_found_message diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb index 98818fda263..2deb8c82741 100644 --- a/app/services/work_items/update_service.rb +++ b/app/services/work_items/update_service.rb @@ -26,8 +26,8 @@ module WorkItems private - def update(work_item) - execute_widgets(work_item: work_item, callback: :update, widget_params: @widget_params) + def before_update(work_item, skip_spam_check: false) + execute_widgets(work_item: work_item, callback: :before_update_callback, widget_params: @widget_params) super end diff --git a/app/services/work_items/widgets/assignees_service/update_service.rb b/app/services/work_items/widgets/assignees_service/update_service.rb new file mode 100644 index 00000000000..9176b71c85e --- /dev/null +++ b/app/services/work_items/widgets/assignees_service/update_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module AssigneesService + class UpdateService < WorkItems::Widgets::BaseService + def before_update_in_transaction(params:) + return unless params.present? && params.has_key?(:assignee_ids) + return unless has_permission?(:set_work_item_metadata) + + assignee_ids = filter_assignees_count(params[:assignee_ids]) + assignee_ids = filter_assignee_permissions(assignee_ids) + + return if assignee_ids.sort == work_item.assignee_ids.sort + + work_item.assignee_ids = assignee_ids + work_item.touch + end + + private + + def filter_assignees_count(assignee_ids) + return assignee_ids if work_item.allows_multiple_assignees? + + assignee_ids.first(1) + end + + def filter_assignee_permissions(assignee_ids) + assignees = User.id_in(assignee_ids) + + assignees.select { |assignee| assignee.can?(:read_work_item, work_item) }.map(&:id) + end + end + end + end +end diff --git a/app/services/work_items/widgets/base_service.rb b/app/services/work_items/widgets/base_service.rb index 037733bbed5..37ed2bf4b05 100644 --- a/app/services/work_items/widgets/base_service.rb +++ b/app/services/work_items/widgets/base_service.rb @@ -5,12 +5,19 @@ module WorkItems class BaseService < ::BaseService WidgetError = Class.new(StandardError) - attr_reader :widget, :current_user + attr_reader :widget, :work_item, :current_user def initialize(widget:, current_user:) @widget = widget + @work_item = widget.work_item @current_user = current_user end + + private + + def has_permission?(permission) + can?(current_user, permission, widget.work_item) + end end end end diff --git a/app/services/work_items/widgets/description_service/update_service.rb b/app/services/work_items/widgets/description_service/update_service.rb index e63b6b2ee6c..fe591ba605e 100644 --- a/app/services/work_items/widgets/description_service/update_service.rb +++ b/app/services/work_items/widgets/description_service/update_service.rb @@ -4,10 +4,12 @@ module WorkItems module Widgets module DescriptionService class UpdateService < WorkItems::Widgets::BaseService - def update(params: {}) - return unless params.present? && params[:description] + def before_update_callback(params: {}) + return unless params.present? && params.key?(:description) + return unless has_permission?(:update_work_item) - widget.work_item.description = params[:description] + work_item.description = params[:description] + work_item.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user) end end end diff --git a/app/services/work_items/widgets/hierarchy_service/base_service.rb b/app/services/work_items/widgets/hierarchy_service/base_service.rb index 085d6c6b0e7..bb681ef0083 100644 --- a/app/services/work_items/widgets/hierarchy_service/base_service.rb +++ b/app/services/work_items/widgets/hierarchy_service/base_service.rb @@ -15,7 +15,7 @@ module WorkItems elsif params.key?(:children) update_work_item_children(params.delete(:children)) else - invalid_args_error + invalid_args_error(params) end end @@ -29,13 +29,13 @@ module WorkItems def set_parent(parent) ::WorkItems::ParentLinks::CreateService - .new(parent, current_user, { target_issuable: widget.work_item }) + .new(parent, current_user, { target_issuable: work_item }) .execute end # rubocop: disable CodeReuse/ActiveRecord def remove_parent - link = ::WorkItems::ParentLink.find_by(work_item: widget.work_item) + link = ::WorkItems::ParentLink.find_by(work_item: work_item) return success unless link.present? ::WorkItems::ParentLinks::DestroyService.new(link, current_user).execute @@ -44,12 +44,12 @@ module WorkItems def update_work_item_children(children) ::WorkItems::ParentLinks::CreateService - .new(widget.work_item, current_user, { issuable_references: children }) + .new(work_item, current_user, { issuable_references: children }) .execute end def feature_flag_enabled? - Feature.enabled?(:work_items_hierarchy, widget.work_item&.project) + Feature.enabled?(:work_items_hierarchy, work_item&.project) end def incompatible_args?(params) @@ -64,11 +64,14 @@ module WorkItems error(_('A Work Item can be a parent or a child, but not both.')) end - def invalid_args_error + def invalid_args_error(params) error(_("One or more arguments are invalid: %{args}." % { args: params.keys.to_sentence } )) end def service_response!(result) + work_item.reload_work_item_parent + work_item.work_item_children.reset + return result unless result[:status] == :error raise WidgetError, result[:message] diff --git a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb new file mode 100644 index 00000000000..6a5dc0d5ef3 --- /dev/null +++ b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module StartAndDueDateService + class UpdateService < WorkItems::Widgets::BaseService + def before_update_callback(params: {}) + return if params.blank? + + widget.work_item.assign_attributes(params.slice(:start_date, :due_date)) + end + end + end + end +end diff --git a/app/services/work_items/widgets/weight_service/update_service.rb b/app/services/work_items/widgets/weight_service/update_service.rb deleted file mode 100644 index cd62a25358f..00000000000 --- a/app/services/work_items/widgets/weight_service/update_service.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - module Widgets - module WeightService - class UpdateService < WorkItems::Widgets::BaseService - def update(params: {}) - return unless params.present? && params[:weight] - - widget.work_item.weight = params[:weight] - end - end - end - end -end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 73dafaefb41..ac7b05bc7ea 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -6,7 +6,7 @@ class AvatarUploader < GitlabUploader include ObjectStorage::Concern prepend ObjectStorage::Extension::RecordsUploads - MIME_WHITELIST = %w[image/png image/jpeg image/gif image/bmp image/tiff image/vnd.microsoft.icon].freeze + MIME_ALLOWLIST = %w[image/png image/jpeg image/gif image/bmp image/tiff image/vnd.microsoft.icon].freeze def exists? model.avatar.file && model.avatar.file.present? @@ -29,7 +29,7 @@ class AvatarUploader < GitlabUploader end def content_type_whitelist - MIME_WHITELIST + MIME_ALLOWLIST end private diff --git a/app/uploaders/design_management/design_v432x230_uploader.rb b/app/uploaders/design_management/design_v432x230_uploader.rb index ba48f381bbd..975050c26e4 100644 --- a/app/uploaders/design_management/design_v432x230_uploader.rb +++ b/app/uploaders/design_management/design_v432x230_uploader.rb @@ -20,13 +20,13 @@ module DesignManagement # # We currently choose not to resize `image/svg+xml` for security reasons. # See https://gitlab.com/gitlab-org/gitlab/issues/207740#note_302766171 - MIME_TYPE_WHITELIST = %w(image/png image/jpeg image/bmp image/gif).freeze + MIME_TYPE_ALLOWLIST = %w(image/png image/jpeg image/bmp image/gif).freeze process resize_to_fit: [432, 230] # Allow CarrierWave to reject files without correct mimetypes. def content_type_whitelist - MIME_TYPE_WHITELIST + MIME_TYPE_ALLOWLIST end # Override `GitlabUploader` and always return false, otherwise local diff --git a/app/uploaders/favicon_uploader.rb b/app/uploaders/favicon_uploader.rb index c9be55e001c..a21b21de101 100644 --- a/app/uploaders/favicon_uploader.rb +++ b/app/uploaders/favicon_uploader.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true class FaviconUploader < AttachmentUploader - EXTENSION_WHITELIST = %w[png ico].freeze - MIME_WHITELIST = %w[image/png image/vnd.microsoft.icon].freeze + EXTENSION_ALLOWLIST = %w[png ico].freeze + MIME_ALLOWLIST = %w[image/png image/vnd.microsoft.icon].freeze def extension_whitelist - EXTENSION_WHITELIST + EXTENSION_ALLOWLIST end def content_type_whitelist - MIME_WHITELIST + MIME_ALLOWLIST end private diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index bd959b14648..bf5be708060 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -142,8 +142,8 @@ class FileUploader < GitlabUploader def to_h { - alt: markdown_name, - url: secure_url, + alt: markdown_name, + url: secure_url, markdown: markdown_link } end diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index 891df5180d8..063aca7937c 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -224,6 +224,10 @@ module ObjectStorage def initialize(file) @file = file end + + def file_path + @file.path + end end # allow to configure and overwrite the filename @@ -275,9 +279,9 @@ module ObjectStorage end end - def use_open_file(&blk) + def use_open_file(unlink_early: true) Tempfile.open(path) do |file| - file.unlink + file.unlink if unlink_early file.binmode if file_storage? @@ -291,6 +295,8 @@ module ObjectStorage file.seek(0, IO::SEEK_SET) yield OpenFile.new(file) + ensure + file.unlink unless unlink_early end end diff --git a/app/validators/json_schemas/build_metadata_id_tokens.json b/app/validators/json_schemas/build_metadata_id_tokens.json new file mode 100644 index 00000000000..7f39c7274f3 --- /dev/null +++ b/app/validators/json_schemas/build_metadata_id_tokens.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "CI builds metadata ID tokens", + "type": "object", + "patternProperties": { + ".*": { + "type": "object", + "patternProperties": { + "^id_token$": { + "type": "object", + "required": ["aud"], + "properties": { + "aud": { "type": "string" }, + "field": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } +} diff --git a/app/validators/json_schemas/cyclonedx_report.json b/app/validators/json_schemas/cyclonedx_report.json new file mode 100644 index 00000000000..65c3c3c0cb9 --- /dev/null +++ b/app/validators/json_schemas/cyclonedx_report.json @@ -0,0 +1,1697 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "type": "object", + "title": "CycloneDX Software Bill of Materials Standard", + "$comment" : "CycloneDX JSON schema is published under the terms of the Apache License 2.0.", + "required": [ + "bomFormat", + "specVersion", + "version" + ], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "enum": [ + "http://cyclonedx.org/schema/bom-1.4.schema.json" + ] + }, + "bomFormat": { + "type": "string", + "title": "BOM Format", + "description": "Specifies the format of the BOM. This helps to identify the file as CycloneDX since BOMs do not have a filename convention nor does JSON schema support namespaces. This value MUST be \"CycloneDX\".", + "enum": [ + "CycloneDX" + ] + }, + "specVersion": { + "type": "string", + "title": "CycloneDX Specification Version", + "description": "The version of the CycloneDX specification a BOM conforms to (starting at version 1.2).", + "examples": ["1.4"] + }, + "serialNumber": { + "type": "string", + "title": "BOM Serial Number", + "description": "Every BOM generated SHOULD have a unique serial number, even if the contents of the BOM have not changed over time. If specified, the serial number MUST conform to RFC-4122. Use of serial numbers are RECOMMENDED.", + "examples": ["urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79"], + "pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "version": { + "type": "integer", + "title": "BOM Version", + "description": "Whenever an existing BOM is modified, either manually or through automated processes, the version of the BOM SHOULD be incremented by 1. When a system is presented with multiple BOMs with identical serial numbers, the system SHOULD use the most recent version of the BOM. The default version is '1'.", + "default": 1, + "examples": [1] + }, + "metadata": { + "$ref": "#/definitions/metadata", + "title": "BOM Metadata", + "description": "Provides additional information about a BOM." + }, + "components": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/component"}, + "uniqueItems": true, + "title": "Components", + "description": "A list of software and hardware components." + }, + "services": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/service"}, + "uniqueItems": true, + "title": "Services", + "description": "A list of services. This may include microservices, function-as-a-service, and other types of network or intra-process services." + }, + "externalReferences": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM." + }, + "dependencies": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/dependency"}, + "uniqueItems": true, + "title": "Dependencies", + "description": "Provides the ability to document dependency relationships." + }, + "compositions": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/compositions"}, + "uniqueItems": true, + "title": "Compositions", + "description": "Compositions describe constituent parts (including components, services, and dependency relationships) and their completeness." + }, + "vulnerabilities": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/vulnerability"}, + "uniqueItems": true, + "title": "Vulnerabilities", + "description": "Vulnerabilities identified in components or services." + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + }, + "definitions": { + "refType": { + "$comment": "Identifier-DataType for interlinked elements.", + "type": "string" + }, + "metadata": { + "type": "object", + "title": "BOM Metadata Object", + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The date and time (timestamp) when the BOM was created." + }, + "tools": { + "type": "array", + "title": "Creation Tools", + "description": "The tool(s) used in the creation of the BOM.", + "additionalItems": false, + "items": {"$ref": "#/definitions/tool"} + }, + "authors" :{ + "type": "array", + "title": "Authors", + "description": "The person(s) who created the BOM. Authors are common in BOMs created through manual processes. BOMs created through automated means may not have authors.", + "additionalItems": false, + "items": {"$ref": "#/definitions/organizationalContact"} + }, + "component": { + "title": "Component", + "description": "The component that the BOM describes.", + "$ref": "#/definitions/component" + }, + "manufacture": { + "title": "Manufacture", + "description": "The organization that manufactured the component that the BOM describes.", + "$ref": "#/definitions/organizationalEntity" + }, + "supplier": { + "title": "Supplier", + "description": " The organization that supplied the component that the BOM describes. The supplier may often be the manufacturer, but may also be a distributor or repackager.", + "$ref": "#/definitions/organizationalEntity" + }, + "licenses": { + "type": "array", + "title": "BOM License(s)", + "additionalItems": false, + "items": {"$ref": "#/definitions/licenseChoice"} + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "additionalItems": false, + "items": {"$ref": "#/definitions/property"} + } + } + }, + "tool": { + "type": "object", + "title": "Tool", + "description": "Information about the automated or manual tool used", + "additionalProperties": false, + "properties": { + "vendor": { + "type": "string", + "title": "Tool Vendor", + "description": "The name of the vendor who created the tool" + }, + "name": { + "type": "string", + "title": "Tool Name", + "description": "The name of the tool" + }, + "version": { + "type": "string", + "title": "Tool Version", + "description": "The version of the tool" + }, + "hashes": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/hash"}, + "title": "Hashes", + "description": "The hashes of the tool (if applicable)." + }, + "externalReferences": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM." + } + } + }, + "organizationalEntity": { + "type": "object", + "title": "Organizational Entity Object", + "description": "", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the organization", + "examples": [ + "Example Inc." + ] + }, + "url": { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + }, + "title": "URL", + "description": "The URL of the organization. Multiple URLs are allowed.", + "examples": ["https://example.com"] + }, + "contact": { + "type": "array", + "title": "Contact", + "description": "A contact at the organization. Multiple contacts are allowed.", + "additionalItems": false, + "items": {"$ref": "#/definitions/organizationalContact"} + } + } + }, + "organizationalContact": { + "type": "object", + "title": "Organizational Contact Object", + "description": "", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of a contact", + "examples": ["Contact name"] + }, + "email": { + "type": "string", + "format": "idn-email", + "title": "Email Address", + "description": "The email address of the contact.", + "examples": ["firstname.lastname@example.com"] + }, + "phone": { + "type": "string", + "title": "Phone", + "description": "The phone number of the contact.", + "examples": ["800-555-1212"] + } + } + }, + "component": { + "type": "object", + "title": "Component Object", + "required": [ + "type", + "name" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "application", + "framework", + "library", + "container", + "operating-system", + "device", + "firmware", + "file" + ], + "title": "Component Type", + "description": "Specifies the type of component. For software components, classify as application if no more specific appropriate classification is available or cannot be determined for the component. Types include:\n\n* __application__ = A software application. Refer to [https://en.wikipedia.org/wiki/Application_software](https://en.wikipedia.org/wiki/Application_software) for information about applications.\n* __framework__ = A software framework. Refer to [https://en.wikipedia.org/wiki/Software_framework](https://en.wikipedia.org/wiki/Software_framework) for information on how frameworks vary slightly from libraries.\n* __library__ = A software library. Refer to [https://en.wikipedia.org/wiki/Library_(computing)](https://en.wikipedia.org/wiki/Library_(computing))\n for information about libraries. All third-party and open source reusable components will likely be a library. If the library also has key features of a framework, then it should be classified as a framework. If not, or is unknown, then specifying library is RECOMMENDED.\n* __container__ = A packaging and/or runtime format, not specific to any particular technology, which isolates software inside the container from software outside of a container through virtualization technology. Refer to [https://en.wikipedia.org/wiki/OS-level_virtualization](https://en.wikipedia.org/wiki/OS-level_virtualization)\n* __operating-system__ = A software operating system without regard to deployment model (i.e. installed on physical hardware, virtual machine, image, etc) Refer to [https://en.wikipedia.org/wiki/Operating_system](https://en.wikipedia.org/wiki/Operating_system)\n* __device__ = A hardware device such as a processor, or chip-set. A hardware device containing firmware SHOULD include a component for the physical hardware itself, and another component of type 'firmware' or 'operating-system' (whichever is relevant), describing information about the software running on the device.\n* __firmware__ = A special type of software that provides low-level control over a devices hardware. Refer to [https://en.wikipedia.org/wiki/Firmware](https://en.wikipedia.org/wiki/Firmware)\n* __file__ = A computer file. Refer to [https://en.wikipedia.org/wiki/Computer_file](https://en.wikipedia.org/wiki/Computer_file) for information about files.", + "examples": ["library"] + }, + "mime-type": { + "type": "string", + "title": "Mime-Type", + "description": "The optional mime-type of the component. When used on file components, the mime-type can provide additional context about the kind of file being represented such as an image, font, or executable. Some library or framework components may also have an associated mime-type.", + "examples": ["image/jpeg"], + "pattern": "^[-+a-z0-9.]+/[-+a-z0-9.]+$" + }, + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "supplier": { + "title": "Component Supplier", + "description": " The organization that supplied the component. The supplier may often be the manufacturer, but may also be a distributor or repackager.", + "$ref": "#/definitions/organizationalEntity" + }, + "author": { + "type": "string", + "title": "Component Author", + "description": "The person(s) or organization(s) that authored the component", + "examples": ["Acme Inc"] + }, + "publisher": { + "type": "string", + "title": "Component Publisher", + "description": "The person(s) or organization(s) that published the component", + "examples": ["Acme Inc"] + }, + "group": { + "type": "string", + "title": "Component Group", + "description": "The grouping name or identifier. This will often be a shortened, single name of the company or project that produced the component, or the source package or domain name. Whitespace and special characters should be avoided. Examples include: apache, org.apache.commons, and apache.org.", + "examples": ["com.acme"] + }, + "name": { + "type": "string", + "title": "Component Name", + "description": "The name of the component. This will often be a shortened, single name of the component. Examples: commons-lang3 and jquery", + "examples": ["tomcat-catalina"] + }, + "version": { + "type": "string", + "title": "Component Version", + "description": "The component version. The version should ideally comply with semantic versioning but is not enforced.", + "examples": ["9.0.14"] + }, + "description": { + "type": "string", + "title": "Component Description", + "description": "Specifies a description for the component" + }, + "scope": { + "type": "string", + "enum": [ + "required", + "optional", + "excluded" + ], + "title": "Component Scope", + "description": "Specifies the scope of the component. If scope is not specified, 'required' scope SHOULD be assumed by the consumer of the BOM.", + "default": "required" + }, + "hashes": { + "type": "array", + "title": "Component Hashes", + "additionalItems": false, + "items": {"$ref": "#/definitions/hash"} + }, + "licenses": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/licenseChoice"}, + "title": "Component License(s)" + }, + "copyright": { + "type": "string", + "title": "Component Copyright", + "description": "A copyright notice informing users of the underlying claims to copyright ownership in a published work.", + "examples": ["Acme Inc"] + }, + "cpe": { + "type": "string", + "title": "Component Common Platform Enumeration (CPE)", + "description": "Specifies a well-formed CPE name that conforms to the CPE 2.2 or 2.3 specification. See [https://nvd.nist.gov/products/cpe](https://nvd.nist.gov/products/cpe)", + "examples": ["cpe:2.3:a:acme:component_framework:-:*:*:*:*:*:*:*"] + }, + "purl": { + "type": "string", + "title": "Component Package URL (purl)", + "description": "Specifies the package-url (purl). The purl, if specified, MUST be valid and conform to the specification defined at: [https://github.com/package-url/purl-spec](https://github.com/package-url/purl-spec)", + "examples": ["pkg:maven/com.acme/tomcat-catalina@9.0.14?packaging=jar"] + }, + "swid": { + "$ref": "#/definitions/swid", + "title": "SWID Tag", + "description": "Specifies metadata and content for [ISO-IEC 19770-2 Software Identification (SWID) Tags](https://www.iso.org/standard/65666.html)." + }, + "modified": { + "type": "boolean", + "title": "Component Modified From Original", + "description": "[Deprecated] - DO NOT USE. This will be removed in a future version. Use the pedigree element instead to supply information on exactly how the component was modified. A boolean value indicating if the component has been modified from the original. A value of true indicates the component is a derivative of the original. A value of false indicates the component has not been modified from the original." + }, + "pedigree": { + "type": "object", + "title": "Component Pedigree", + "description": "Component pedigree is a way to document complex supply chain scenarios where components are created, distributed, modified, redistributed, combined with other components, etc. Pedigree supports viewing this complex chain from the beginning, the end, or anywhere in the middle. It also provides a way to document variants where the exact relation may not be known.", + "additionalProperties": false, + "properties": { + "ancestors": { + "type": "array", + "title": "Ancestors", + "description": "Describes zero or more components in which a component is derived from. This is commonly used to describe forks from existing projects where the forked version contains a ancestor node containing the original component it was forked from. For example, Component A is the original component. Component B is the component being used and documented in the BOM. However, Component B contains a pedigree node with a single ancestor documenting Component A - the original component from which Component B is derived from.", + "additionalItems": false, + "items": {"$ref": "#/definitions/component"} + }, + "descendants": { + "type": "array", + "title": "Descendants", + "description": "Descendants are the exact opposite of ancestors. This provides a way to document all forks (and their forks) of an original or root component.", + "additionalItems": false, + "items": {"$ref": "#/definitions/component"} + }, + "variants": { + "type": "array", + "title": "Variants", + "description": "Variants describe relations where the relationship between the components are not known. For example, if Component A contains nearly identical code to Component B. They are both related, but it is unclear if one is derived from the other, or if they share a common ancestor.", + "additionalItems": false, + "items": {"$ref": "#/definitions/component"} + }, + "commits": { + "type": "array", + "title": "Commits", + "description": "A list of zero or more commits which provide a trail describing how the component deviates from an ancestor, descendant, or variant.", + "additionalItems": false, + "items": {"$ref": "#/definitions/commit"} + }, + "patches": { + "type": "array", + "title": "Patches", + "description": ">A list of zero or more patches describing how the component deviates from an ancestor, descendant, or variant. Patches may be complimentary to commits or may be used in place of commits.", + "additionalItems": false, + "items": {"$ref": "#/definitions/patch"} + }, + "notes": { + "type": "string", + "title": "Notes", + "description": "Notes, observations, and other non-structured commentary describing the components pedigree." + } + } + }, + "externalReferences": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM." + }, + "components": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/component"}, + "uniqueItems": true, + "title": "Components", + "description": "A list of software and hardware components included in the parent component. This is not a dependency tree. It provides a way to specify a hierarchical representation of component assemblies, similar to system → subsystem → parts assembly in physical supply chains." + }, + "evidence": { + "$ref": "#/definitions/componentEvidence", + "title": "Evidence", + "description": "Provides the ability to document evidence collected through various forms of extraction or analysis." + }, + "releaseNotes": { + "$ref": "#/definitions/releaseNotes", + "title": "Release notes", + "description": "Specifies optional release notes." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "additionalItems": false, + "items": {"$ref": "#/definitions/property"} + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "swid": { + "type": "object", + "title": "SWID Tag", + "description": "Specifies metadata and content for ISO-IEC 19770-2 Software Identification (SWID) Tags.", + "required": [ + "tagId", + "name" + ], + "additionalProperties": false, + "properties": { + "tagId": { + "type": "string", + "title": "Tag ID", + "description": "Maps to the tagId of a SoftwareIdentity." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Maps to the name of a SoftwareIdentity." + }, + "version": { + "type": "string", + "title": "Version", + "default": "0.0", + "description": "Maps to the version of a SoftwareIdentity." + }, + "tagVersion": { + "type": "integer", + "title": "Tag Version", + "default": 0, + "description": "Maps to the tagVersion of a SoftwareIdentity." + }, + "patch": { + "type": "boolean", + "title": "Patch", + "default": false, + "description": "Maps to the patch of a SoftwareIdentity." + }, + "text": { + "title": "Attachment text", + "description": "Specifies the metadata and content of the SWID tag.", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "URL", + "description": "The URL to the SWID file.", + "format": "iri-reference" + } + } + }, + "attachment": { + "type": "object", + "title": "Attachment", + "description": "Specifies the metadata and content for an attachment.", + "required": [ + "content" + ], + "additionalProperties": false, + "properties": { + "contentType": { + "type": "string", + "title": "Content-Type", + "description": "Specifies the content type of the text. Defaults to text/plain if not specified.", + "default": "text/plain" + }, + "encoding": { + "type": "string", + "title": "Encoding", + "description": "Specifies the optional encoding the text is represented in.", + "enum": [ + "base64" + ] + }, + "content": { + "type": "string", + "title": "Attachment Text", + "description": "The attachment data. Proactive controls such as input validation and sanitization should be employed to prevent misuse of attachment text." + } + } + }, + "hash": { + "type": "object", + "title": "Hash Objects", + "required": [ + "alg", + "content" + ], + "additionalProperties": false, + "properties": { + "alg": { + "$ref": "#/definitions/hash-alg" + }, + "content": { + "$ref": "#/definitions/hash-content" + } + } + }, + "hash-alg": { + "type": "string", + "enum": [ + "MD5", + "SHA-1", + "SHA-256", + "SHA-384", + "SHA-512", + "SHA3-256", + "SHA3-384", + "SHA3-512", + "BLAKE2b-256", + "BLAKE2b-384", + "BLAKE2b-512", + "BLAKE3" + ], + "title": "Hash Algorithm" + }, + "hash-content": { + "type": "string", + "title": "Hash Content (value)", + "examples": ["3942447fac867ae5cdb3229b658f4d48"], + "pattern": "^([a-fA-F0-9]{32}|[a-fA-F0-9]{40}|[a-fA-F0-9]{64}|[a-fA-F0-9]{96}|[a-fA-F0-9]{128})$" + }, + "license": { + "type": "object", + "title": "License Object", + "oneOf": [ + { + "required": ["id"] + }, + { + "required": ["name"] + } + ], + "additionalProperties": false, + "properties": { + "id": { + "$ref": "spdx.schema.json", + "title": "License ID (SPDX)", + "description": "A valid SPDX license ID", + "examples": ["Apache-2.0"] + }, + "name": { + "type": "string", + "title": "License Name", + "description": "If SPDX does not define the license used, this field may be used to provide the license name", + "examples": ["Acme Software License"] + }, + "text": { + "title": "License text", + "description": "An optional way to include the textual content of a license.", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "License URL", + "description": "The URL to the license file. If specified, a 'license' externalReference should also be specified for completeness", + "examples": ["https://www.apache.org/licenses/LICENSE-2.0.txt"], + "format": "iri-reference" + } + } + }, + "licenseChoice": { + "type": "object", + "title": "License(s)", + "additionalProperties": false, + "properties": { + "license": { + "$ref": "#/definitions/license" + }, + "expression": { + "type": "string", + "title": "SPDX License Expression", + "examples": [ + "Apache-2.0 AND (MIT OR GPL-2.0-only)", + "GPL-3.0-only WITH Classpath-exception-2.0" + ] + } + }, + "oneOf":[ + { + "required": ["license"] + }, + { + "required": ["expression"] + } + ] + }, + "commit": { + "type": "object", + "title": "Commit", + "description": "Specifies an individual commit", + "additionalProperties": false, + "properties": { + "uid": { + "type": "string", + "title": "UID", + "description": "A unique identifier of the commit. This may be version control specific. For example, Subversion uses revision numbers whereas git uses commit hashes." + }, + "url": { + "type": "string", + "title": "URL", + "description": "The URL to the commit. This URL will typically point to a commit in a version control system.", + "format": "iri-reference" + }, + "author": { + "title": "Author", + "description": "The author who created the changes in the commit", + "$ref": "#/definitions/identifiableAction" + }, + "committer": { + "title": "Committer", + "description": "The person who committed or pushed the commit", + "$ref": "#/definitions/identifiableAction" + }, + "message": { + "type": "string", + "title": "Message", + "description": "The text description of the contents of the commit" + } + } + }, + "patch": { + "type": "object", + "title": "Patch", + "description": "Specifies an individual patch", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "unofficial", + "monkey", + "backport", + "cherry-pick" + ], + "title": "Type", + "description": "Specifies the purpose for the patch including the resolution of defects, security issues, or new behavior or functionality.\n\n* __unofficial__ = A patch which is not developed by the creators or maintainers of the software being patched. Refer to [https://en.wikipedia.org/wiki/Unofficial_patch](https://en.wikipedia.org/wiki/Unofficial_patch)\n* __monkey__ = A patch which dynamically modifies runtime behavior. Refer to [https://en.wikipedia.org/wiki/Monkey_patch](https://en.wikipedia.org/wiki/Monkey_patch)\n* __backport__ = A patch which takes code from a newer version of software and applies it to older versions of the same software. Refer to [https://en.wikipedia.org/wiki/Backporting](https://en.wikipedia.org/wiki/Backporting)\n* __cherry-pick__ = A patch created by selectively applying commits from other versions or branches of the same software." + }, + "diff": { + "title": "Diff", + "description": "The patch file (or diff) that show changes. Refer to [https://en.wikipedia.org/wiki/Diff](https://en.wikipedia.org/wiki/Diff)", + "$ref": "#/definitions/diff" + }, + "resolves": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/issue"}, + "title": "Resolves", + "description": "A collection of issues the patch resolves" + } + } + }, + "diff": { + "type": "object", + "title": "Diff", + "description": "The patch file (or diff) that show changes. Refer to https://en.wikipedia.org/wiki/Diff", + "additionalProperties": false, + "properties": { + "text": { + "title": "Diff text", + "description": "Specifies the optional text of the diff", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "URL", + "description": "Specifies the URL to the diff", + "format": "iri-reference" + } + } + }, + "issue": { + "type": "object", + "title": "Diff", + "description": "An individual issue that has been resolved.", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "defect", + "enhancement", + "security" + ], + "title": "Type", + "description": "Specifies the type of issue" + }, + "id": { + "type": "string", + "title": "ID", + "description": "The identifier of the issue assigned by the source of the issue" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the issue" + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the issue" + }, + "source": { + "type": "object", + "title": "Source", + "description": "The source of the issue where it is documented", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the source. For example 'National Vulnerability Database', 'NVD', and 'Apache'" + }, + "url": { + "type": "string", + "title": "URL", + "description": "The url of the issue documentation as provided by the source", + "format": "iri-reference" + } + } + }, + "references": { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + }, + "title": "References", + "description": "A collection of URL's for reference. Multiple URLs are allowed.", + "examples": ["https://example.com"] + } + } + }, + "identifiableAction": { + "type": "object", + "title": "Identifiable Action", + "description": "Specifies an individual commit", + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The timestamp in which the action occurred" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the individual who performed the action" + }, + "email": { + "type": "string", + "format": "idn-email", + "title": "E-mail", + "description": "The email address of the individual who performed the action" + } + } + }, + "externalReference": { + "type": "object", + "title": "External Reference", + "description": "Specifies an individual external reference", + "required": [ + "url", + "type" + ], + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "title": "URL", + "description": "The URL to the external reference", + "format": "iri-reference" + }, + "comment": { + "type": "string", + "title": "Comment", + "description": "An optional comment describing the external reference" + }, + "type": { + "type": "string", + "title": "Type", + "description": "Specifies the type of external reference. There are built-in types to describe common references. If a type does not exist for the reference being referred to, use the \"other\" type.", + "enum": [ + "vcs", + "issue-tracker", + "website", + "advisories", + "bom", + "mailing-list", + "social", + "chat", + "documentation", + "support", + "distribution", + "license", + "build-meta", + "build-system", + "release-notes", + "other" + ] + }, + "hashes": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/hash"}, + "title": "Hashes", + "description": "The hashes of the external reference (if applicable)." + } + } + }, + "dependency": { + "type": "object", + "title": "Dependency", + "description": "Defines the direct dependencies of a component. Components that do not have their own dependencies MUST be declared as empty elements within the graph. Components that are not represented in the dependency graph MAY have unknown dependencies. It is RECOMMENDED that implementations assume this to be opaque and not an indicator of a component being dependency-free.", + "required": [ + "ref" + ], + "additionalProperties": false, + "properties": { + "ref": { + "$ref": "#/definitions/refType", + "title": "Reference", + "description": "References a component by the components bom-ref attribute" + }, + "dependsOn": { + "type": "array", + "uniqueItems": true, + "additionalItems": false, + "items": { + "$ref": "#/definitions/refType" + }, + "title": "Depends On", + "description": "The bom-ref identifiers of the components that are dependencies of this dependency object." + } + } + }, + "service": { + "type": "object", + "title": "Service Object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the service elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "provider": { + "title": "Provider", + "description": "The organization that provides the service.", + "$ref": "#/definitions/organizationalEntity" + }, + "group": { + "type": "string", + "title": "Service Group", + "description": "The grouping name, namespace, or identifier. This will often be a shortened, single name of the company or project that produced the service or domain name. Whitespace and special characters should be avoided.", + "examples": ["com.acme"] + }, + "name": { + "type": "string", + "title": "Service Name", + "description": "The name of the service. This will often be a shortened, single name of the service.", + "examples": ["ticker-service"] + }, + "version": { + "type": "string", + "title": "Service Version", + "description": "The service version.", + "examples": ["1.0.0"] + }, + "description": { + "type": "string", + "title": "Service Description", + "description": "Specifies a description for the service" + }, + "endpoints": { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + }, + "title": "Endpoints", + "description": "The endpoint URIs of the service. Multiple endpoints are allowed.", + "examples": ["https://example.com/api/v1/ticker"] + }, + "authenticated": { + "type": "boolean", + "title": "Authentication Required", + "description": "A boolean value indicating if the service requires authentication. A value of true indicates the service requires authentication prior to use. A value of false indicates the service does not require authentication." + }, + "x-trust-boundary": { + "type": "boolean", + "title": "Crosses Trust Boundary", + "description": "A boolean value indicating if use of the service crosses a trust zone or boundary. A value of true indicates that by using the service, a trust boundary is crossed. A value of false indicates that by using the service, a trust boundary is not crossed." + }, + "data": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/dataClassification"}, + "title": "Data Classification", + "description": "Specifies the data classification." + }, + "licenses": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/licenseChoice"}, + "title": "Component License(s)" + }, + "externalReferences": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM." + }, + "services": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/service"}, + "uniqueItems": true, + "title": "Services", + "description": "A list of services included or deployed behind the parent service. This is not a dependency tree. It provides a way to specify a hierarchical representation of service assemblies." + }, + "releaseNotes": { + "$ref": "#/definitions/releaseNotes", + "title": "Release notes", + "description": "Specifies optional release notes." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "additionalItems": false, + "items": {"$ref": "#/definitions/property"} + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "dataClassification": { + "type": "object", + "title": "Hash Objects", + "required": [ + "flow", + "classification" + ], + "additionalProperties": false, + "properties": { + "flow": { + "$ref": "#/definitions/dataFlow", + "title": "Directional Flow", + "description": "Specifies the flow direction of the data. Direction is relative to the service. Inbound flow states that data enters the service. Outbound flow states that data leaves the service. Bi-directional states that data flows both ways, and unknown states that the direction is not known." + }, + "classification": { + "type": "string", + "title": "Classification", + "description": "Data classification tags data according to its type, sensitivity, and value if altered, stolen, or destroyed." + } + } + }, + "dataFlow": { + "type": "string", + "enum": [ + "inbound", + "outbound", + "bi-directional", + "unknown" + ], + "title": "Data flow direction", + "description": "Specifies the flow direction of the data. Direction is relative to the service. Inbound flow states that data enters the service. Outbound flow states that data leaves the service. Bi-directional states that data flows both ways, and unknown states that the direction is not known." + }, + + "copyright": { + "type": "object", + "title": "Copyright", + "required": [ + "text" + ], + "additionalProperties": false, + "properties": { + "text": { + "type": "string", + "title": "Copyright Text" + } + } + }, + + "componentEvidence": { + "type": "object", + "title": "Evidence", + "description": "Provides the ability to document evidence collected through various forms of extraction or analysis.", + "additionalProperties": false, + "properties": { + "licenses": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/licenseChoice"}, + "title": "Component License(s)" + }, + "copyright": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/copyright"}, + "title": "Copyright" + } + } + }, + "compositions": { + "type": "object", + "title": "Compositions", + "required": [ + "aggregate" + ], + "additionalProperties": false, + "properties": { + "aggregate": { + "$ref": "#/definitions/aggregateType", + "title": "Aggregate", + "description": "Specifies an aggregate type that describe how complete a relationship is." + }, + "assemblies": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "title": "BOM references", + "description": "The bom-ref identifiers of the components or services being described. Assemblies refer to nested relationships whereby a constituent part may include other constituent parts. References do not cascade to child parts. References are explicit for the specified constituent part only." + }, + "dependencies": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "title": "BOM references", + "description": "The bom-ref identifiers of the components or services being described. Dependencies refer to a relationship whereby an independent constituent part requires another independent constituent part. References do not cascade to transitive dependencies. References are explicit for the specified dependency only." + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "aggregateType": { + "type": "string", + "default": "not_specified", + "enum": [ + "complete", + "incomplete", + "incomplete_first_party_only", + "incomplete_third_party_only", + "unknown", + "not_specified" + ] + }, + "property": { + "type": "object", + "title": "Lightweight name-value pair", + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the property. Duplicate names are allowed, each potentially having a different value." + }, + "value": { + "type": "string", + "title": "Value", + "description": "The value of the property." + } + } + }, + "localeType": { + "type": "string", + "pattern": "^([a-z]{2})(-[A-Z]{2})?$", + "title": "Locale", + "description": "Defines a syntax for representing two character language code (ISO-639) followed by an optional two character country code. The language code MUST be lower case. If the country code is specified, the country code MUST be upper case. The language code and country code MUST be separated by a minus sign. Examples: en, en-US, fr, fr-CA" + }, + "releaseType": { + "type": "string", + "examples": [ + "major", + "minor", + "patch", + "pre-release", + "internal" + ], + "description": "The software versioning type. It is RECOMMENDED that the release type use one of 'major', 'minor', 'patch', 'pre-release', or 'internal'. Representing all possible software release types is not practical, so standardizing on the recommended values, whenever possible, is strongly encouraged.\n\n* __major__ = A major release may contain significant changes or may introduce breaking changes.\n* __minor__ = A minor release, also known as an update, may contain a smaller number of changes than major releases.\n* __patch__ = Patch releases are typically unplanned and may resolve defects or important security issues.\n* __pre-release__ = A pre-release may include alpha, beta, or release candidates and typically have limited support. They provide the ability to preview a release prior to its general availability.\n* __internal__ = Internal releases are not for public consumption and are intended to be used exclusively by the project or manufacturer that produced it." + }, + "note": { + "type": "object", + "title": "Note", + "description": "A note containing the locale and content.", + "required": [ + "text" + ], + "additionalProperties": false, + "properties": { + "locale": { + "$ref": "#/definitions/localeType", + "title": "Locale", + "description": "The ISO-639 (or higher) language code and optional ISO-3166 (or higher) country code. Examples include: \"en\", \"en-US\", \"fr\" and \"fr-CA\"" + }, + "text": { + "title": "Release note content", + "description": "Specifies the full content of the release note.", + "$ref": "#/definitions/attachment" + } + } + }, + "releaseNotes": { + "type": "object", + "title": "Release notes", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/definitions/releaseType", + "title": "Type", + "description": "The software versioning type the release note describes." + }, + "title": { + "type": "string", + "title": "Title", + "description": "The title of the release." + }, + "featuredImage": { + "type": "string", + "format": "iri-reference", + "title": "Featured image", + "description": "The URL to an image that may be prominently displayed with the release note." + }, + "socialImage": { + "type": "string", + "format": "iri-reference", + "title": "Social image", + "description": "The URL to an image that may be used in messaging on social media platforms." + }, + "description": { + "type": "string", + "title": "Description", + "description": "A short description of the release." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The date and time (timestamp) when the release note was created." + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Aliases", + "description": "One or more alternate names the release may be referred to. This may include unofficial terms used by development and marketing teams (e.g. code names)." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Tags", + "description": "One or more tags that may aid in search or retrieval of the release note." + }, + "resolves": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/issue"}, + "title": "Resolves", + "description": "A collection of issues that have been resolved." + }, + "notes": { + "type": "array", + "additionalItems": false, + "items": {"$ref": "#/definitions/note"}, + "title": "Notes", + "description": "Zero or more release notes containing the locale and content. Multiple note objects may be specified to support release notes in a wide variety of languages." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "additionalItems": false, + "items": {"$ref": "#/definitions/property"} + } + } + }, + "advisory": { + "type": "object", + "title": "Advisory", + "description": "Title and location where advisory information can be obtained. An advisory is a notification of a threat to a component, service, or system.", + "required": ["url"], + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "title": "Title", + "description": "An optional name of the advisory." + }, + "url": { + "type": "string", + "title": "URL", + "format": "iri-reference", + "description": "Location where the advisory can be obtained." + } + } + }, + "cwe": { + "type": "integer", + "minimum": 1, + "title": "CWE", + "description": "Integer representation of a Common Weaknesses Enumerations (CWE). For example 399 (of https://cwe.mitre.org/data/definitions/399.html)" + }, + "severity": { + "type": "string", + "title": "Severity", + "description": "Textual representation of the severity of the vulnerability adopted by the analysis method. If the analysis method uses values other than what is provided, the user is expected to translate appropriately.", + "enum": [ + "critical", + "high", + "medium", + "low", + "info", + "none", + "unknown" + ] + }, + "scoreMethod": { + "type": "string", + "title": "Method", + "description": "Specifies the severity or risk scoring methodology or standard used.\n\n* CVSSv2 - [Common Vulnerability Scoring System v2](https://www.first.org/cvss/v2/)\n* CVSSv3 - [Common Vulnerability Scoring System v3](https://www.first.org/cvss/v3-0/)\n* CVSSv31 - [Common Vulnerability Scoring System v3.1](https://www.first.org/cvss/v3-1/)\n* OWASP - [OWASP Risk Rating Methodology](https://owasp.org/www-community/OWASP_Risk_Rating_Methodology)", + "enum": [ + "CVSSv2", + "CVSSv3", + "CVSSv31", + "OWASP", + "other" + ] + }, + "impactAnalysisState": { + "type": "string", + "title": "Impact Analysis State", + "description": "Declares the current state of an occurrence of a vulnerability, after automated or manual analysis. \n\n* __resolved__ = the vulnerability has been remediated. \n* __resolved\\_with\\_pedigree__ = the vulnerability has been remediated and evidence of the changes are provided in the affected components pedigree containing verifiable commit history and/or diff(s). \n* __exploitable__ = the vulnerability may be directly or indirectly exploitable. \n* __in\\_triage__ = the vulnerability is being investigated. \n* __false\\_positive__ = the vulnerability is not specific to the component or service and was falsely identified or associated. \n* __not\\_affected__ = the component or service is not affected by the vulnerability. Justification should be specified for all not_affected cases.", + "enum": [ + "resolved", + "resolved_with_pedigree", + "exploitable", + "in_triage", + "false_positive", + "not_affected" + ] + }, + "impactAnalysisJustification": { + "type": "string", + "title": "Impact Analysis Justification", + "description": "The rationale of why the impact analysis state was asserted. \n\n* __code\\_not\\_present__ = the code has been removed or tree-shaked. \n* __code\\_not\\_reachable__ = the vulnerable code is not invoked at runtime. \n* __requires\\_configuration__ = exploitability requires a configurable option to be set/unset. \n* __requires\\_dependency__ = exploitability requires a dependency that is not present. \n* __requires\\_environment__ = exploitability requires a certain environment which is not present. \n* __protected\\_by\\_compiler__ = exploitability requires a compiler flag to be set/unset. \n* __protected\\_at\\_runtime__ = exploits are prevented at runtime. \n* __protected\\_at\\_perimeter__ = attacks are blocked at physical, logical, or network perimeter. \n* __protected\\_by\\_mitigating\\_control__ = preventative measures have been implemented that reduce the likelihood and/or impact of the vulnerability.", + "enum": [ + "code_not_present", + "code_not_reachable", + "requires_configuration", + "requires_dependency", + "requires_environment", + "protected_by_compiler", + "protected_at_runtime", + "protected_at_perimeter", + "protected_by_mitigating_control" + ] + }, + "rating": { + "type": "object", + "title": "Rating", + "description": "Defines the severity or risk ratings of a vulnerability.", + "additionalProperties": false, + "properties": { + "source": { + "$ref": "#/definitions/vulnerabilitySource", + "description": "The source that calculated the severity or risk rating of the vulnerability." + }, + "score": { + "type": "number", + "title": "Score", + "description": "The numerical score of the rating." + }, + "severity": { + "$ref": "#/definitions/severity", + "description": "Textual representation of the severity that corresponds to the numerical score of the rating." + }, + "method": { + "$ref": "#/definitions/scoreMethod" + }, + "vector": { + "type": "string", + "title": "Vector", + "description": "Textual representation of the metric values used to score the vulnerability" + }, + "justification": { + "type": "string", + "title": "Justification", + "description": "An optional reason for rating the vulnerability as it was" + } + } + }, + "vulnerabilitySource": { + "type": "object", + "title": "Source", + "description": "The source of vulnerability information. This is often the organization that published the vulnerability.", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "title": "URL", + "description": "The url of the vulnerability documentation as provided by the source.", + "examples": [ + "https://nvd.nist.gov/vuln/detail/CVE-2021-39182" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the source.", + "examples": [ + "NVD", + "National Vulnerability Database", + "OSS Index", + "VulnDB", + "GitHub Advisories" + ] + } + } + }, + "vulnerability": { + "type": "object", + "title": "Vulnerability", + "description": "Defines a weakness in an component or service that could be exploited or triggered by a threat source.", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the vulnerability elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "id": { + "type": "string", + "title": "ID", + "description": "The identifier that uniquely identifies the vulnerability.", + "examples": [ + "CVE-2021-39182", + "GHSA-35m5-8cvj-8783", + "SNYK-PYTHON-ENROCRYPT-1912876" + ] + }, + "source": { + "$ref": "#/definitions/vulnerabilitySource", + "description": "The source that published the vulnerability." + }, + "references": { + "type": "array", + "title": "References", + "description": "Zero or more pointers to vulnerabilities that are the equivalent of the vulnerability specified. Often times, the same vulnerability may exist in multiple sources of vulnerability intelligence, but have different identifiers. References provide a way to correlate vulnerabilities across multiple sources of vulnerability intelligence.", + "additionalItems": false, + "items": { + "required": [ + "id", + "source" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "title": "ID", + "description": "An identifier that uniquely identifies the vulnerability.", + "examples": [ + "CVE-2021-39182", + "GHSA-35m5-8cvj-8783", + "SNYK-PYTHON-ENROCRYPT-1912876" + ] + }, + "source": { + "$ref": "#/definitions/vulnerabilitySource", + "description": "The source that published the vulnerability." + } + } + } + }, + "ratings": { + "type": "array", + "title": "Ratings", + "description": "List of vulnerability ratings", + "additionalItems": false, + "items": { + "$ref": "#/definitions/rating" + } + }, + "cwes": { + "type": "array", + "title": "CWEs", + "description": "List of Common Weaknesses Enumerations (CWEs) codes that describes this vulnerability. For example 399 (of https://cwe.mitre.org/data/definitions/399.html)", + "examples": [399], + "additionalItems": false, + "items": { + "$ref": "#/definitions/cwe" + } + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the vulnerability as provided by the source." + }, + "detail": { + "type": "string", + "title": "Details", + "description": "If available, an in-depth description of the vulnerability as provided by the source organization. Details often include examples, proof-of-concepts, and other information useful in understanding root cause." + }, + "recommendation": { + "type": "string", + "title": "Details", + "description": "Recommendations of how the vulnerability can be remediated or mitigated." + }, + "advisories": { + "type": "array", + "title": "Advisories", + "description": "Published advisories of the vulnerability if provided.", + "additionalItems": false, + "items": { + "$ref": "#/definitions/advisory" + } + }, + "created": { + "type": "string", + "format": "date-time", + "title": "Created", + "description": "The date and time (timestamp) when the vulnerability record was created in the vulnerability database." + }, + "published": { + "type": "string", + "format": "date-time", + "title": "Published", + "description": "The date and time (timestamp) when the vulnerability record was first published." + }, + "updated": { + "type": "string", + "format": "date-time", + "title": "Updated", + "description": "The date and time (timestamp) when the vulnerability record was last updated." + }, + "credits": { + "type": "object", + "title": "Credits", + "description": "Individuals or organizations credited with the discovery of the vulnerability.", + "additionalProperties": false, + "properties": { + "organizations": { + "type": "array", + "title": "Organizations", + "description": "The organizations credited with vulnerability discovery.", + "additionalItems": false, + "items": { + "$ref": "#/definitions/organizationalEntity" + } + }, + "individuals": { + "type": "array", + "title": "Individuals", + "description": "The individuals, not associated with organizations, that are credited with vulnerability discovery.", + "additionalItems": false, + "items": { + "$ref": "#/definitions/organizationalContact" + } + } + } + }, + "tools": { + "type": "array", + "title": "Creation Tools", + "description": "The tool(s) used to identify, confirm, or score the vulnerability.", + "additionalItems": false, + "items": {"$ref": "#/definitions/tool"} + }, + "analysis": { + "type": "object", + "title": "Impact Analysis", + "description": "An assessment of the impact and exploitability of the vulnerability.", + "additionalProperties": false, + "properties": { + "state": { + "$ref": "#/definitions/impactAnalysisState" + }, + "justification": { + "$ref": "#/definitions/impactAnalysisJustification" + }, + "response": { + "type": "array", + "title": "Response", + "description": "A response to the vulnerability by the manufacturer, supplier, or project responsible for the affected component or service. More than one response is allowed. Responses are strongly encouraged for vulnerabilities where the analysis state is exploitable.", + "additionalItems": false, + "items": { + "type": "string", + "enum": [ + "can_not_fix", + "will_not_fix", + "update", + "rollback", + "workaround_available" + ] + } + }, + "detail": { + "type": "string", + "title": "Detail", + "description": "Detailed description of the impact including methods used during assessment. If a vulnerability is not exploitable, this field should include specific details on why the component or service is not impacted by this vulnerability." + } + } + }, + "affects": { + "type": "array", + "uniqueItems": true, + "additionalItems": false, + "items": { + "required": [ + "ref" + ], + "additionalProperties": false, + "properties": { + "ref": { + "$ref": "#/definitions/refType", + "title": "Reference", + "description": "References a component or service by the objects bom-ref" + }, + "versions": { + "type": "array", + "title": "Versions", + "description": "Zero or more individual versions or range of versions.", + "additionalItems": false, + "items": { + "oneOf": [ + { + "required": ["version"] + }, + { + "required": ["range"] + } + ], + "additionalProperties": false, + "properties": { + "version": { + "description": "A single version of a component or service.", + "$ref": "#/definitions/version" + }, + "range": { + "description": "A version range specified in Package URL Version Range syntax (vers) which is defined at https://github.com/package-url/purl-spec/VERSION-RANGE-SPEC.rst", + "$ref": "#/definitions/version" + }, + "status": { + "description": "The vulnerability status for the version or range of versions.", + "$ref": "#/definitions/affectedStatus", + "default": "affected" + } + } + } + } + } + }, + "title": "Affects", + "description": "The components or services that are affected by the vulnerability." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "additionalItems": false, + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "affectedStatus": { + "description": "The vulnerability status of a given version or range of versions of a product. The statuses 'affected' and 'unaffected' indicate that the version is affected or unaffected by the vulnerability. The status 'unknown' indicates that it is unknown or unspecified whether the given version is affected. There can be many reasons for an 'unknown' status, including that an investigation has not been undertaken or that a vendor has not disclosed the status.", + "type": "string", + "enum": [ + "affected", + "unaffected", + "unknown" + ] + }, + "version": { + "description": "A single version of a component or service.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "range": { + "description": "A version range specified in Package URL Version Range syntax (vers) which is defined at https://github.com/package-url/purl-spec/VERSION-RANGE-SPEC.rst", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "signature": { + "$ref": "jsf-0.82.schema.json#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } +} diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index ba2a2f34d63..77170761448 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -22,7 +22,7 @@ .form-group = f.label :shared_runners_text, _('Shared runners details'), class: 'label-bold' = f.text_area :shared_runners_text, class: 'form-control gl-form-input', rows: 4 - .form-text.text-muted= _("Add a custom message with details about the instance's shared runners. The message is visible in group and project CI/CD settings, in the Runners section. Markdown is supported.") + .form-text.text-muted= _("Add a custom message with details about the instance's shared runners. The message is visible when you view runners for projects and groups. Markdown is supported.") .form-group = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold' = f.number_field :max_artifacts_size, class: 'form-control gl-form-input' diff --git a/app/views/admin/application_settings/_default_branch.html.haml b/app/views/admin/application_settings/_default_branch.html.haml index 4a06dcbc031..f9b1aa22b7a 100644 --- a/app/views/admin/application_settings/_default_branch.html.haml +++ b/app/views/admin/application_settings/_default_branch.html.haml @@ -14,4 +14,4 @@ = render_if_exists 'admin/application_settings/group_owners_can_manage_default_branch_protection_setting', form: f - = f.submit _('Save changes'), class: 'gl-button btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml index 1af4d294c1b..30165139711 100644 --- a/app/views/admin/application_settings/_diff_limits.html.haml +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -1,4 +1,4 @@ -= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-merge-request-settings'), html: { class: 'fieldset-form', id: 'merge-request-settings' } do |f| += gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-merge-request-settings'), html: { class: 'fieldset-form', id: 'merge-request-settings' } do |f| = form_errors(@application_setting, pajamas_alert: true) %fieldset @@ -29,4 +29,4 @@ = link_to sprite_icon('question-o'), help_page_path('user/admin_area/diff_limits', anchor: 'diff-limits-administration') - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index 5dc2d322bb3..ff10e4a8f77 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -23,7 +23,7 @@ = link_to s_('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' .form-check = f.fields_for :repository_storages_weighted, storage_weights do |storage_form| - - Gitlab.config.repositories.storages.keys.each do |storage| + - Gitlab.config.repositories.storages.each_key do |storage| = storage_form.text_field storage, class: 'form-text-input' = storage_form.label storage, storage, class: 'label-bold form-check-label' %br diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml index 1d6051a06ea..7781db29bab 100644 --- a/app/views/admin/application_settings/_runner_registrars_form.html.haml +++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-runner-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .gl-form-group diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index 8684b909853..d500194b742 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -10,7 +10,7 @@ = 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 = gitlab_ui_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 + = form_errors(@application_setting, pajamas_alert: true) if expanded %fieldset .form-group diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index c9ed2309cec..7326a63f8c2 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -3,7 +3,7 @@ - link_end = '</a>'.html_safe = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting, pajamas_alert: true) %fieldset .form-group @@ -38,7 +38,7 @@ %p.gl-mb-3= s_('AdminSettings|Registration Features include:') - email_from_gitlab_path = help_page_path('user/admin_area/email_from_gitlab') - repo_size_limit_path = help_page_path('user/admin_area/settings/account_and_limit_settings', anchor: 'repository-size-limit') - - restrict_ip_path = help_page_path('user/group/index', anchor: 'restrict-group-access-by-ip-address') + - restrict_ip_path = help_page_path('user/group/access_and_permissions', anchor: 'restrict-group-access-by-ip-address') - email_from_gitlab_link = link_start % { url: email_from_gitlab_path } - repo_size_limit_link = link_start % { url: repo_size_limit_path } - restrict_ip_link = link_start % { url: restrict_ip_path } diff --git a/app/views/admin/application_settings/_whats_new.html.haml b/app/views/admin/application_settings/_whats_new.html.haml index 8ae912d24b7..d82bb1c94e4 100644 --- a/app/views/admin/application_settings/_whats_new.html.haml +++ b/app/views/admin/application_settings/_whats_new.html.haml @@ -1,7 +1,7 @@ = gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-whats-new-settings'), html: { class: 'fieldset-form whats-new-settings' } do |f| = form_errors(@application_setting, pajamas_alert: true) - - whats_new_variants.keys.each do |variant| + - whats_new_variants.each_key do |variant| .gl-mb-4 = f.gitlab_ui_radio_component :whats_new_variant, variant, whats_new_variants_label(variant), help_text: whats_new_variants_description(variant) diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml index 224d9fbe953..349e1dfde5d 100644 --- a/app/views/admin/application_settings/appearances/_form.html.haml +++ b/app/views/admin/application_settings/appearances/_form.html.haml @@ -40,7 +40,7 @@ = f.hidden_field :favicon_cache = f.file_field :favicon, class: '', accept: 'image/*' .form-text.text-muted - = _("Maximum file size is 1 MB. Image size must be 32 x 32 pixels. Allowed image formats are %{favicon_extension_whitelist}.") % { favicon_extension_whitelist: favicon_extension_whitelist } + = _("Maximum file size is 1 MB. Image size must be 32 x 32 pixels. Allowed image formats are %{favicon_extension_allowlist}.") % { favicon_extension_allowlist: favicon_extension_allowlist } %br = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.") diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index 180871e48dd..b603c7e5f49 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -29,8 +29,6 @@ %th = _('Callback URL') %th - = _('Clients') - %th = _('Trusted') %th = _('Confidential') @@ -41,7 +39,6 @@ %tr{ id: "application_#{application.id}" } %td= link_to application.name, admin_application_path(application) %td= application.redirect_uri - %td= @application_counts[application.id].to_i %td= application.trusted? ? _('Yes'): _('No') %td= application.confidential? ? _('Yes'): _('No') %td= link_to 'Edit', edit_admin_application_path(application), class: 'gl-button btn btn-link' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 88fbbb28201..271f89a6b08 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -58,7 +58,7 @@ = link_to(s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-default") = c.footer do .d-flex.align-items-center - = link_to(s_('AdminArea|View latest users'), admin_users_path) + = link_to(s_('AdminArea|View latest users'), admin_users_path({ sort: 'created_desc' })) = sprite_icon('chevron-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2') .col-md-4.gl-mb-6 = render Pajamas::CardComponent.new(**component_params) do |c| diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 7bcc97914e5..a254690de72 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -9,7 +9,7 @@ = _('Update your group name, description, avatar, and visibility.') = link_to _('Learn more about groups.'), help_page_path('user/group/index') .col-lg-8 - = render 'shared/group_form', f: f + = render 'shared/groups/group_name_and_path_fields', f: f = render 'shared/group_form_description', f: f .form-group.gl-form-group{ role: 'group' } = f.label :avatar, _("Group avatar"), class: 'gl-display-block col-form-label' diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml index e8176e9f8bb..224afbff39a 100644 --- a/app/views/admin/hooks/edit.html.haml +++ b/app/views/admin/hooks/edit.html.haml @@ -15,6 +15,6 @@ = render 'shared/web_hooks/test_button', hook: @hook = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn gl-button btn-danger float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this webhook?'), confirm_btn_variant: 'danger' } - %hr +%hr - = render partial: 'admin/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs } += render partial: 'shared/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs } diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml index d852e4a2463..3121cd2ae59 100644 --- a/app/views/admin/identities/_identity.html.haml +++ b/app/views/admin/identities/_identity.html.haml @@ -1,12 +1,24 @@ %tr %td - #{Gitlab::Auth::OAuth::Provider.label_for(identity.provider)} (#{identity.provider}) #{identity.saml_provider_id.present? ? "for #{link_to identity.saml_provider.group.path, identity.saml_provider.group} ID: #{identity.saml_provider_id}".html_safe : ""} + = label_for_identity_provider(identity) + %td{ data: { testid: provider_id_cell_testid(identity) } } + = provider_id(identity) + %td{ data: { testid: saml_group_cell_testid(identity) } } + = saml_group_link(identity) %td = identity.extern_uid - %td - = link_to edit_admin_user_identity_path(@user, identity), class: 'gl-button btn btn-sm btn-grouped' do - = _("Edit") - = link_to [:admin, @user, identity], method: :delete, - class: 'gl-button btn btn-sm btn-danger', - data: { confirm: _("Are you sure you want to remove this identity?") } do - = _('Delete') + %td{ class: 'gl-py-0!' } + - button_classes = 'has-tooltip gl-my-3' + = render Pajamas::ButtonComponent.new(category: :tertiary, + href: edit_admin_user_identity_path(@user, identity), + icon: 'pencil', + button_options: { title: _('Edit'), + 'aria-label' => _('Edit'), + class: button_classes } ) + = render Pajamas::ButtonComponent.new(category: :tertiary, + href: url_for([:admin, @user, identity]), + icon: 'remove', + button_options: { title: _('Delete'), + 'aria-label' => _('Delete identity'), + class: button_classes, + data: { method: :delete, confirm: _("Are you sure you want to remove this identity?") } } ) diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml index 2bab802b2c1..1bb14969939 100644 --- a/app/views/admin/identities/index.html.haml +++ b/app/views/admin/identities/index.html.haml @@ -3,14 +3,20 @@ - page_title _("Identities"), @user.name, _("Users") = render 'admin/users/head' -- if @identities.present? - .table-holder - %table.table - %thead - %tr - %th= _('Provider') - %th= _('Identifier') - %th - = render @identities -- else - %h4= _('This user has no identities') +%table.table.gl-table + %thead + %tr + %th{ class: 'gl-border-t-0!' }= _('Provider') + %th{ class: 'gl-border-t-0!' }= s_('Identity|Provider ID') + %th{ class: 'gl-border-t-0!' }= _('Group') + %th{ class: 'gl-border-t-0!' }= _('Identifier') + %th{ class: 'gl-border-t-0!' }= _('Actions') + - if identity_cells_to_render?(@identities, @user) + = render_if_exists partial: 'admin/identities/scim_identity', collection: scim_identities_collection(@user) + = render @identities + - else + %tbody + %tr + %td{ colspan: '5' } + .text-center.my-2 + = _('This user has no identities') diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml index ae8fed8964f..333c865629f 100644 --- a/app/views/admin/labels/_label.html.haml +++ b/app/views/admin/labels/_label.html.haml @@ -1,7 +1,7 @@ %li.label-list-item{ id: dom_id(label) } = render "shared/label_row", label: label.present(issuable_subject: nil) .label-actions-list - = link_to edit_admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do + = link_to edit_admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria: { label: _('Edit') } do = sprite_icon('pencil') = link_to admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary hover-red js-remove-label label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: _('Are you sure you want to delete this label?'), confirm_btn_variant: 'danger' }, aria: { label: _('Delete label') }, method: :delete, remote: true do = sprite_icon('remove') diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 22351397b9a..a9dbcf4a6a5 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -1,4 +1,5 @@ - add_page_specific_style 'page_bundles/ci_status' +- add_page_specific_style 'page_bundles/runner_details' - title = "##{@runner.id} (#{@runner.short_sha})" - breadcrumb_title title diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index ed453b42725..0ceff211806 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -45,5 +45,5 @@ = gl_tab_link_to _("SSH keys"), keys_admin_user_path(@user) = gl_tab_link_to _("Identities"), admin_user_identities_path(@user) - if impersonation_enabled? - = gl_tab_link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user) + = gl_tab_link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user), data: { qa_selector: 'impersonation_tokens_tab' } .gl-mb-3 diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 9ef2599a2a6..02c468cebd7 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -6,10 +6,15 @@ = s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - is_group = !@group.nil? +- is_project = !@project.nil? #js-ci-project-variables{ data: { endpoint: save_endpoint, + is_project: is_project.to_s, project_id: @project&.id || '', - group: is_group.to_s, + project_full_path: @project&.full_path || '', + is_group: is_group.to_s, + group_id: @group&.id || '', + group_path: @group&.full_path, maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s, aws_logo_svg_path: image_path('aws_logo.svg'), diff --git a/app/views/clusters/clusters/_gitlab_integration_form.html.haml b/app/views/clusters/clusters/_gitlab_integration_form.html.haml index b6d6dcdd7a9..e0f5a984529 100644 --- a/app/views/clusters/clusters/_gitlab_integration_form.html.haml +++ b/app/views/clusters/clusters/_gitlab_integration_form.html.haml @@ -1,3 +1,3 @@ = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'js-cluster-details-form' } do |field| - = form_errors(@cluster) + = form_errors(@cluster, pajamas_alert: true) #js-cluster-details-form{ data: js_cluster_form_data(@cluster, can?(current_user, :update_cluster, @cluster)) } diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml index 3e0a8a4f88b..88da252f2bb 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml @@ -2,7 +2,7 @@ - help_path = local_assigns.fetch(:help_path) - label = local_assigns.fetch(:label) - last = local_assigns.fetch(:last, false) -- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-flex-basis-0 gl-flex-grow-1 gl-min-w-0"] +- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-flex-basis-third "] - conditional_classes = [("gl-mr-5" unless last)] = link_to help_path, class: classes + conditional_classes do diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml index f0f1413831a..813c1cdbfe4 100644 --- a/app/views/dashboard/_groups_head.html.haml +++ b/app/views/dashboard/_groups_head.html.haml @@ -3,7 +3,7 @@ - if current_user.can_create_group? .page-title-controls - = link_to _("New group"), new_group_path, class: "gl-button btn btn-confirm", data: { testid: "new-group-button" } + = link_to _("New group"), new_group_path, class: "gl-button btn btn-confirm", data: { qa_selector: "new_group_button", testid: "new-group-button" } .top-area = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml index c90a9e7c672..1400ac9ca72 100644 --- a/app/views/devise/passwords/new.html.haml +++ b/app/views/devise/passwords/new.html.haml @@ -4,7 +4,7 @@ .devise-errors = render "devise/shared/error_messages", resource: resource .form-group.gl-px-5.gl-pt-5 - = f.label :email + = f.label :email, class: ("gl-mb-1" if Feature.enabled?(:restyle_login_page)) = f.email_field :email, class: "form-control gl-form-input", required: true, autocomplete: 'off', value: params[:user_email], autofocus: true, title: _('Please provide a valid email address.') .form-text.text-muted = _('Requires your primary GitLab email address.') diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index b6acb244384..b6719834358 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -11,5 +11,6 @@ = render 'devise/shared/signup_box', url: registration_path(resource_name), button_text: _('Register'), + borderless: Feature.enabled?(:restyle_login_page, @project), show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled? = render 'devise/shared/sign_in_link' diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 48b38861e6e..5a322a8f89b 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -3,7 +3,7 @@ = render_if_exists 'devise/sessions/new_base_user_login_label', form: f = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' } .form-group.gl-px-5 - = f.label :password, class: 'label-bold' + = f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" = 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? .gl-px-5 @@ -23,3 +23,6 @@ .submit-container.move-submit-down.gl-px-5 = f.button _('Sign in'), type: :submit, class: "gl-button btn btn-block btn-confirm js-sign-in-button#{' js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } + - if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project) + .gl-px-5 + = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text) diff --git a/app/views/devise/sessions/_new_base_user_login_label.html.haml b/app/views/devise/sessions/_new_base_user_login_label.html.haml index 2aa66684cad..8a8b9f7a361 100644 --- a/app/views/devise/sessions/_new_base_user_login_label.html.haml +++ b/app/views/devise/sessions/_new_base_user_login_label.html.haml @@ -1 +1 @@ -= local_assigns[:form].label _('Username or email'), for: 'user_login', class: 'label-bold' += local_assigns[:form].label _('Username or email'), for: 'user_login', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index 9a09f6bee38..f4db9ea5637 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -18,10 +18,9 @@ = _('No authentication methods configured.') - if allow_signup? - %p.gl-mt-3 + %p{ class: "gl-mt-3 #{'gl-text-center' if Feature.enabled?(:restyle_login_page, @project)}" } = _("Don't have an account yet?") - = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' } - + = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' }, class: "#{'gl-font-weight-bold' if Feature.enabled?(:restyle_login_page, @project)} " - if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled? .clearfix = render 'devise/shared/omniauth_box' diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index 77a2fda021f..fd20ff9a418 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -1,5 +1,5 @@ %div - = render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication') + = render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication') if Feature.disabled?(:restyle_login_page, @project) .login-box.gl-p-5 .login-body - if @user.two_factor_otp_enabled? @@ -7,7 +7,7 @@ - resource_params = params[resource_name].presence || params = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0) %div - = f.label _('Two-Factor Authentication code'), name: :otp_attempt + = f.label _('Two-Factor Authentication code'), name: :otp_attempt, class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-mb-1' : '' = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' } %p.form-text.text-muted.hint= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.") .prepend-top-20 diff --git a/app/views/devise/shared/_footer.html.haml b/app/views/devise/shared/_footer.html.haml index 5803107a8f7..10cfc07a719 100644 --- a/app/views/devise/shared/_footer.html.haml +++ b/app/views/devise/shared/_footer.html.haml @@ -5,4 +5,5 @@ = link_to _("Explore"), explore_root_path = link_to _("Help"), help_path = link_to _("About GitLab"), "https://#{ApplicationHelper.promo_host}" + = link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', rel: 'noopener noreferrer' = footer_message diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 32b4a15517e..d67669352a6 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -1,20 +1,19 @@ - hide_remember_me = local_assigns.fetch(:hide_remember_me, false) - -.omniauth-container.gl-mt-5.gl-p-5 - %label.gl-font-weight-bold +%div{ class: Feature.enabled?(:restyle_login_page, @project) ? 'omniauth-container gl-mt-5 gl-p-5 gl-text-center gl-w-90p gl-ml-auto gl-mr-auto' : 'omniauth-container gl-mt-5 gl-p-5' } + %label{ class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-font-weight-normal' : 'gl-font-weight-bold' } = _('Sign in with') - providers = enabled_button_based_providers .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 gl-w-full js-oauth-login #{qa_class_for_provider(provider)}", form: { class: 'gl-w-full gl-mb-3' } do + = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)} #{'gl-w-full' if Feature.disabled?(:restyle_login_page, @project)}", 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 - %label + %label{ class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-font-weight-normal' : '' } = 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 1868cfa06e9..991af1eea0c 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -13,7 +13,7 @@ = invisible_captcha nonce: true, autocomplete: SecureRandom.alphanumeric(12) .name.form-row .col.form-group - = f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold' + = f.label :first_name, _('First name'), for: 'new_user_first_name', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" = f.text_field :first_name, class: 'form-control gl-form-input top js-block-emoji js-validate-length', data: { max_length: max_first_name_length, @@ -22,7 +22,7 @@ required: true, title: _('This field is required.') .col.form-group - = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold' + = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" = f.text_field :last_name, class: 'form-control gl-form-input top js-block-emoji js-validate-length', data: { max_length: max_last_name_length, @@ -31,7 +31,7 @@ required: true, title: _('This field is required.') .username.form-group - = f.label :username, class: 'label-bold' + = f.label :username, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" = f.text_field :username, class: 'form-control gl-form-input middle js-block-emoji js-validate-length js-validate-username', data: signup_username_data_attributes, @@ -42,18 +42,19 @@ %p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.') %p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...') .form-group - = f.label :email, class: 'label-bold' + = f.label :email, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" = f.email_field :email, value: @invite_email, class: 'form-control gl-form-input middle js-validate-email', data: { qa_selector: 'new_user_email_field' }, required: true, title: _('Please provide a valid email address.') - %p.gl-field-hint.text-secondary= _('We recommend a work email address.') + %p.validation-hint.gl-field-hint.text-secondary= _('We recommend a work email address.') + %p.validation-warning.gl-field-error-ignore.text-secondary.hide= _('This email address does not look right, are you sure you typed it correctly?') -# This is used for providing entry to Jihu on email verification = render_if_exists 'devise/shared/signup_email_additional_info' .form-group.gl-mb-5#password-strength - = f.label :password, class: 'label-bold' + = f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" = f.password_field :password, class: 'form-control gl-form-input bottom js-password-complexity-validation', data: { qa_selector: 'new_user_password_field' }, @@ -69,6 +70,9 @@ = recaptcha_tags nonce: content_security_policy_nonce .submit-container.gl-mt-5 = f.submit button_text, class: 'btn gl-button btn-confirm gl-display-block gl-w-full', data: { qa_selector: 'new_user_register_button' } + - if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project) + .gl-pt-5 + = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text) = render 'devise/shared/terms_of_service_notice', button_text: button_text - if show_omniauth_providers && omniauth_providers_placement == :bottom = render 'devise/shared/signup_omniauth_providers' diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index 84aabbe0efd..8dc22674243 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -1,11 +1,22 @@ - register_omniauth_params = Feature.enabled?(:update_oauth_registration_flow) ? { intent: :register } : {} - -%label.gl-font-weight-bold - = _("Create an account using:") -.gl-display-flex.gl-justify-content-between.gl-flex-wrap - - providers.each do |provider| - = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-3 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do - - if provider_has_icon?(provider) - = provider_image_tag(provider) - %span.gl-button-text - = label_for_provider(provider) +- if Feature.enabled?(:restyle_login_page, @project) + .gl-text-center.gl-pt-5 + %label.gl-font-weight-normal + = _("Create an account using:") + .gl-text-center.gl-w-90p.gl-ml-auto.gl-mr-auto + - providers.each do |provider| + = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do + - if provider_has_icon?(provider) + = provider_image_tag(provider) + %span.gl-button-text + = label_for_provider(provider) +- else + %label.gl-font-weight-bold + = _("Create an account using:") + .gl-display-flex.gl-justify-content-between.gl-flex-wrap + - providers.each do |provider| + = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-3 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do + - if provider_has_icon?(provider) + = provider_image_tag(provider) + %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 30a54ab86a6..d2a47974e01 100644 --- a/app/views/devise/shared/_signup_omniauth_providers.haml +++ b/app/views/devise/shared/_signup_omniauth_providers.haml @@ -1,3 +1,4 @@ -.omniauth-divider.gl-display-flex.gl-align-items-center - = _("or") +- if Feature.disabled?(:restyle_login_page, @project) + .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/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 62d6ab36578..4a6b7fcfa84 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -21,8 +21,7 @@ %ul.content-list.event-commits = render "events/commit", project: project, event: event - - create_mr = event.new_ref? && create_mr_button?(from: project.default_branch, to: event.ref_name, source_project: project, target_project: project) && event.authored_by?(current_user) - - create_mr_path = create_mr_path(from: project.default_branch, to: event.ref_name, source_project: project, target_project: project) if create_mr + - create_mr = event.new_ref? && create_mr_button_from_event?(event) && event.authored_by?(current_user) - if event.commits_count > 1 %li.commits-stat %span ... and #{pluralize(event.commits_count - 1, 'more commit')}. @@ -41,9 +40,9 @@ - if create_mr %span or - = link_to create_mr_path do + = link_to create_mr_path_from_push_event(event) do create a merge request - elsif create_mr %li.commits-stat - = link_to create_mr_path do + = link_to create_mr_path_from_push_event(event) do Create merge request diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index bd893ca3162..2911e9991f2 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -33,7 +33,10 @@ .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), group_id: @group.id, container_class: 'gl-mx-2 gl-mt-3 gl-vertical-align-top', no_flip: 'true' } } - if can_create_subgroups .gl-px-2.gl-sm-w-auto.gl-w-full - = link_to _("New subgroup"), new_group_path(parent_id: @group.id), class: "btn btn-default gl-button gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_subgroup_button' } + = link_to _("New subgroup"), + new_group_path(parent_id: @group.id, anchor: 'create-group-pane'), + class: "btn btn-default gl-button gl-mt-3 gl-sm-w-auto gl-w-full", + data: { qa_selector: 'new_subgroup_button' } - if can_create_projects .gl-px-2.gl-sm-w-auto.gl-w-full = link_to _("New project"), new_project_path(namespace_id: @group.id), class: "btn btn-confirm gl-button gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_project_button' } diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml index 0527d38159b..632884051f0 100644 --- a/app/views/groups/_new_group_fields.html.haml +++ b/app/views/groups/_new_group_fields.html.haml @@ -1,31 +1,34 @@ +- parent = @group.parent +- submit_label = parent ? s_('GroupsNew|Create subgroup') : s_('GroupsNew|Create group') = form_errors(@group, pajamas_alert: true) -= render 'shared/group_form', f: f, autofocus: true += render 'shared/groups/group_name_and_path_fields', f: f, autofocus: true, new_subgroup: !!parent -.row - .form-group.gl-form-group.col-sm-12 - %label.label-bold - = _('Visibility level') - %p - = _('Who will be able to see this group?') - = link_to _('View the documentation'), help_page_path("user/public_access"), target: '_blank', rel: 'noopener noreferrer' - = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false - -- if Gitlab.config.mattermost.enabled +- unless parent .row - = render 'create_chat_team', f: f + .form-group.gl-form-group.col-sm-12 + %label.label-bold + = _('Visibility level') + %p + = _('Who will be able to see this group?') + = link_to _('View the documentation'), help_page_path("user/public_access"), target: '_blank', rel: 'noopener noreferrer' + = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false -- unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? - = render 'personalize', f: f + - if Gitlab.config.mattermost.enabled + .row + = render 'create_chat_team', f: f -.row.js-invite-members-section - .col-sm-4 - = render_if_exists 'shared/groups/invite_members' + - unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? + = render 'personalize', f: f -- if captcha_required? - .row.recaptcha + .row.js-invite-members-section .col-sm-4 - = recaptcha_tags nonce: content_security_policy_nonce + = render_if_exists 'shared/groups/invite_members' + + - if captcha_required? + .row.recaptcha + .col-sm-4 + = recaptcha_tags nonce: content_security_policy_nonce .row .col-sm-12 - = f.submit _('Create group'), class: "btn gl-button btn-confirm" + = f.submit submit_label, class: "btn gl-button btn-confirm", data: { qa_selector: 'create_group_button' } = link_to _('Cancel'), dashboard_groups_path, class: 'btn gl-button btn-default btn-cancel' diff --git a/app/views/groups/crm/contacts/index.html.haml b/app/views/groups/crm/contacts/index.html.haml index 8a971e451a4..27f18ac1c57 100644 --- a/app/views/groups/crm/contacts/index.html.haml +++ b/app/views/groups/crm/contacts/index.html.haml @@ -5,4 +5,4 @@ = content_for :after_content do #js-crm-form-portal -#js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group), group_id: @group.id, can_admin_crm_contact: can?(current_user, :admin_crm_contact, @group).to_s, base_path: group_crm_contacts_path(@group) } } +#js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group), group_id: @group.id, can_admin_crm_contact: can?(current_user, :admin_crm_contact, @group).to_s, base_path: group_crm_contacts_path(@group), text_query: params[:search] } } diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml index 082f637e854..178d8980ab8 100644 --- a/app/views/groups/dependency_proxies/show.html.haml +++ b/app/views/groups/dependency_proxies/show.html.haml @@ -2,4 +2,6 @@ - @content_class = "limit-container-width" unless fluid_layout #js-dependency-proxy{ data: { group_path: @group.full_path, - no_manifests_illustration: image_path('illustrations/docker-empty-state.svg'), group_id: @group.id } } + no_manifests_illustration: image_path('illustrations/docker-empty-state.svg'), + group_id: @group.id, + can_clear_cache: can?(current_user, :admin_group, @group).to_s } } diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index 7da5a9e9664..9f13ad301bb 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -1,4 +1,4 @@ -= form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| += gitlab_ui_form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| = form_errors(@milestone, pajamas_alert: true) .form-group.row .col-form-label.col-sm-2 diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 3fb2b88dadd..8384c906eeb 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -6,7 +6,8 @@ .group-edit-container.gl-mt-5 - .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s }.merge(verification_for_group_creation_data) } + .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s }.merge(subgroup_creation_data(@group), + verification_for_group_creation_data) } .row{ 'v-cloak': true } #create-group-pane.tab-pane diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml index 65e797a2e82..2fc314cc37f 100644 --- a/app/views/groups/runners/show.html.haml +++ b/app/views/groups/runners/show.html.haml @@ -1,10 +1,8 @@ -- add_to_breadcrumbs _('Runners'), group_runners_path(@group) +- add_page_specific_style 'page_bundles/runner_details' -- if Feature.enabled?(:group_runner_view_ui, @group) - - title = "##{@runner.id} (#{@runner.short_sha})" - - breadcrumb_title title - - page_title title +- add_to_breadcrumbs _('Runners'), group_runners_path(@group) +- title = "##{@runner.id} (#{@runner.short_sha})" +- breadcrumb_title title +- page_title title - #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group), edit_group_runner_path: edit_group_runner_path(@group, @runner)} } -- else - = render 'shared/runners/runner_details', runner: @runner +#js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group), edit_group_runner_path: edit_group_runner_path(@group, @runner)} } diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index 3624ff2bcb3..8fa8eeea3cd 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -8,7 +8,7 @@ .form-group %p = s_("GroupSettings|Changing a group's URL can have unintended side effects.") - = link_to _('Learn more.'), help_page_path('user/group/index', anchor: 'change-a-groups-path'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/group/manage', anchor: 'change-a-groups-path'), target: '_blank', rel: 'noopener noreferrer' .input-group.gl-field-error-anchor .group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' } diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml index 16ea96f0b08..ac6c5d1842c 100644 --- a/app/views/groups/settings/access_tokens/index.html.haml +++ b/app/views/groups/settings/access_tokens/index.html.haml @@ -37,7 +37,7 @@ token: @resource_access_token, scopes: @scopes, access_levels: GroupMember.access_level_roles, - default_access_level: Gitlab::Access::MAINTAINER, + default_access_level: Gitlab::Access::GUEST, prefix: :resource_access_token, help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token') diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml index c294df5ac62..3691c470ea7 100644 --- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml +++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml @@ -2,7 +2,7 @@ = form_errors(group, pajamas_alert: true) %fieldset .form-group - .card.auto-devops-card + .card.gl-mb-3 .card-body - learn_more_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer' - help_text = s_('GroupSettings|The Auto DevOps pipeline runs if no alternative CI configuration file is found.') diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 3b117022d1e..88352ea351c 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -32,7 +32,7 @@ = expanded ? _('Collapse') : _('Expand') %p = _("Runners are processes that pick up and execute CI/CD jobs for GitLab.") - = link_to s_('How do I configure runners?'), help_page_path('ci/runners/index'), target: '_blank', rel: 'noopener noreferrer' + = link_to s_('What is GitLab Runner?'), 'https://docs.gitlab.com/runner/', target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'groups/runners/settings' diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index 08f7cd57732..35fd5d6eda6 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -13,9 +13,9 @@ can_select_namespace: current_user.can_select_namespace?.to_s, ci_cd_only: has_ci_cd_only_params?.to_s, namespaces_path: import_available_namespaces_path, - repos_path: url_for([:status, :import, provider, format: :json]), - jobs_path: url_for([:realtime_changes, :import, provider, format: :json]), + repos_path: url_for([:status, :import, provider, { format: :json }]), + jobs_path: url_for([:realtime_changes, :import, provider, { format: :json }]), default_target_namespace: default_namespace_path, - import_path: url_for([:import, provider, format: :json]), + import_path: url_for([:import, provider, { format: :json }]), filterable: filterable.to_s, paginatable: paginatable.to_s }.merge(extra_data) } diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml index 25af51ca9cb..f5c823465be 100644 --- a/app/views/layouts/_google_tag_manager_head.html.haml +++ b/app/views/layouts/_google_tag_manager_head.html.haml @@ -1,4 +1,23 @@ - return unless google_tag_manager_enabled? +- if Feature.enabled?(:gitlab_gtm_datalayer, type: :ops) + = javascript_tag do + :plain + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + + gtag('consent', 'default', { + 'analytics_storage': 'denied', + 'ad_storage': 'denied', + 'functionality_storage': 'denied', + 'region': ['EU', 'UK', 'PE', 'RU'], + 'wait_for_update': 500 + }); + gtag('consent', 'default', { + 'analytics_storage': 'granted', + 'ad_storage': 'granted', + 'functionality_storage': 'granted', + 'wait_for_update': 500 + }); - if Feature.enabled?(:gtm_nonce, type: :ops) = javascript_tag nonce: content_security_policy_nonce do diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml index 0dad6d367c3..22cc8027202 100644 --- a/app/views/layouts/_snowplow.html.haml +++ b/app/views/layouts/_snowplow.html.haml @@ -12,6 +12,9 @@ window.snowplowOptions = #{Gitlab::Tracking.options(@group).to_json} gl = window.gl || {}; - gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new(namespace: namespace, - project: @project, user: current_user).to_context.to_json.to_json} + gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new( + namespace: namespace, + project: @project, + user: current_user + ).to_context.to_json.to_json} gl.snowplowPseudonymizedPageUrl = #{masked_page_url(group: namespace, project: @project).to_json}; diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml new file mode 100644 index 00000000000..ec12395a5d4 --- /dev/null +++ b/app/views/layouts/component_preview.html.haml @@ -0,0 +1,5 @@ +%head + = stylesheet_link_tag "application" + = stylesheet_link_tag "application_utilities" +%body{ style: "background-color: #{params.dig(:lookbook, :display, :bg_color) || 'white'}" } + .container.gl-mt-6= yield diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index cb1a2a8c690..87a8b6dd870 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -4,39 +4,61 @@ %body.login-page.application.navless{ class: "#{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } } = header_message = render "layouts/init_client_detection_flags" - .page-wrap - = render "layouts/header/empty" - .login-page-broadcast - = render "layouts/broadcast" - .container.navless-container - .content - = render "layouts/flash" - .row.mt-3 - .col-sm-12 - %h1.mb-3.font-weight-normal - = current_appearance&.title.presence || _('GitLab') - .row.mb-3 - .col-md-6.order-12.order-sm-1.brand-holder - - unless recently_confirmed_com? - = brand_image + - if Feature.enabled?(:restyle_login_page, @project) + .page-wrap.borderless + .login-page-broadcast + = render "layouts/broadcast" + .container.navless-container + .content + = render "layouts/flash" + .mt-3 + .col-sm-12.gl-text-center + %img.gl-w-10{ :alt => _("GitLab Logo"), :src => image_path('logo.svg') } + %h1.mb-3.gl-font-size-h2 + = current_appearance&.title.presence || _('GitLab') - if current_appearance&.description? = brand_text - - else - %h3.gl-sm-mt-0 - = _('A complete DevOps platform') + .mb-3 + .gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar + = yield + = render_if_exists 'layouts/devise_help_text' - %p - = _('GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.') - %p - = _('This is a self-managed instance of GitLab.') + = render 'devise/shared/footer', footer_message: footer_message + - else + .page-wrap + = render "layouts/header/empty" + .login-page-broadcast + = render "layouts/broadcast" + .container.navless-container + .content + = render "layouts/flash" + .row.mt-3 + .col-sm-12 + %h1.mb-3.font-weight-normal + = current_appearance&.title.presence || _('GitLab') + .row.mb-3 + .col-md-6.order-12.order-sm-1.brand-holder + - unless recently_confirmed_com? + = brand_image + - if current_appearance&.description? + = brand_text + - else + %h3.gl-sm-mt-0 + = _('A complete DevOps platform') - - if Gitlab::CurrentSettings.sign_in_text.present? - = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text) + %p + = _('GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.') - = render_if_exists 'layouts/devise_help_text' + %p + = _('This is a self-managed instance of GitLab.') - .col-md-6.order-1.new-session-forms-container{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' } - = yield + - if Gitlab::CurrentSettings.sign_in_text.present? + = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text) - = render 'devise/shared/footer', footer_message: footer_message + = render_if_exists 'layouts/devise_help_text' + + .col-md-6.order-1.new-session-forms-container{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' } + = yield + + = render 'devise/shared/footer', footer_message: footer_message diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 1c2ab8cf008..67809cbc608 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -6,8 +6,8 @@ - @left_sidebar = true - content_for :flash_message do - = render "layouts/header/storage_enforcement_banner", namespace: @group - = dispensable_render_if_exists "shared/namespace_storage_limit_alert" + = render "layouts/header/storage_enforcement_banner", context: @group + = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @group - content_for :page_specific_javascripts do - if current_user diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 11dd8ba6c08..353f07c07c5 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -12,7 +12,7 @@ - if can?(current_user, :update_user_status, current_user) %li %button.gl-button.btn.btn-link.menu-item.js-set-status-modal-trigger{ type: 'button' } - - if show_status_emoji?(current_user.status) || user_status_set_to_busy?(current_user.status) + - if current_user.status&.busy? || current_user.status&.customized? = s_('SetStatusModal|Edit status') - else = s_('SetStatusModal|Set status') diff --git a/app/views/layouts/header/_current_user_dropdown_item.html.haml b/app/views/layouts/header/_current_user_dropdown_item.html.haml index 06c597b4932..3fded43ee4f 100644 --- a/app/views/layouts/header/_current_user_dropdown_item.html.haml +++ b/app/views/layouts/header/_current_user_dropdown_item.html.haml @@ -1,11 +1,11 @@ .gl-font-weight-bold = current_user.name - - if current_user&.status && user_status_set_to_busy?(current_user.status) + - if current_user.status&.busy? %span.gl-font-weight-normal.gl-text-gray-500= s_("UserProfile|(Busy)") = current_user.to_reference - if current_user.status .user-status.d-flex.align-items-center.gl-mt-2.gl-mr-0.gl-font-sm.has-tooltip{ title: current_user.status.message_html, data: { html: 'true', placement: 'bottom' } } - - if show_status_emoji?(current_user.status) + - if current_user.status.customized? .user-status-emoji.d-flex.align-items-center = emoji_icon current_user.status.emoji %span.user-status-message.str-truncated diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 911cb85de53..783733bb313 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -8,7 +8,7 @@ .title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0 .title %span.gl-sr-only GitLab - = link_to root_path, title: _('Dashboard'), id: 'logo', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do + = link_to root_path, title: _('Dashboard'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do = brand_header_logo - if Gitlab.com_and_canary? = link_to Gitlab::Saas.canary_toggle_com_url, class: 'canary-badge bg-transparent', data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer' do @@ -57,8 +57,8 @@ = number_with_delimiter(issues_count) - if header_link?(:merge_requests) = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter dropdown" }) do - - top_level_link = current_user.mr_attention_requests_enabled? ? attention_requested_mrs_dashboard_path : assigned_mrs_dashboard_path - = link_to top_level_link, class: 'dashboard-shortcuts-merge_requests', title: _('Merge requests'), aria: { label: _('Merge requests') }, + - top_level_link = assigned_mrs_dashboard_path + = link_to top_level_link, class: 'dashboard-shortcuts-merge_requests has-tooltip', title: _('Merge requests'), aria: { label: _('Merge requests') }, data: { qa_selector: 'merge_requests_shortcut_button', toggle: "dropdown", placement: 'bottom', @@ -74,27 +74,14 @@ %ul %li.dropdown-header = _('Merge requests') - - if current_user.mr_attention_requests_enabled? - %li#js-need-attention-nav - #js-need-attention-nav-onboarding - = link_to attention_requested_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do - = _('Need your attention') - = gl_badge_tag user_merge_requests_counts[:attention_requested_count], { size: :sm, variant: user_merge_requests_counts[:attention_requested_count] == 0 ? :neutral : :warning }, { class: 'merge-request-badge gl-ml-auto js-attention-count' } - %li.divider %li = link_to assigned_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do - - if current_user.mr_attention_requests_enabled? - = _('Assignee') - - else - = _('Assigned to you') + = _('Assigned to you') = gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-assigned-mr-count gl-ml-auto" }) do = user_merge_requests_counts[:assigned] %li = link_to reviewer_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do - - if current_user.mr_attention_requests_enabled? - = _('Reviewer') - - else - = _('Review requests for you') + = _('Review requests for you') = gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-reviewer-mr-count gl-ml-auto" }) do = user_merge_requests_counts[:review_requested] - if header_link?(:todos) diff --git a/app/views/layouts/header/_storage_enforcement_banner.html.haml b/app/views/layouts/header/_storage_enforcement_banner.html.haml index c117f22a402..1f7060f8235 100644 --- a/app/views/layouts/header/_storage_enforcement_banner.html.haml +++ b/app/views/layouts/header/_storage_enforcement_banner.html.haml @@ -1,14 +1,15 @@ - return unless current_user -- namespace = local_assigns.fetch(:namespace) -- banner_info = storage_enforcement_banner_info(namespace) +- context = local_assigns.fetch(:context) +- banner_info = storage_enforcement_banner_info(context) - return unless banner_info.present? = render Pajamas::AlertComponent.new(variant: :warning, alert_options: { class: 'js-storage-enforcement-banner', data: { feature_id: banner_info[:callouts_feature_name], dismiss_endpoint: banner_info[:callouts_path], - group_id: namespace.id, + group_id: banner_info[:namespace_id], defer_links: "true" }}) do |c| = c.body do - = banner_info[:text] - = banner_info[:learn_more_link] + %p= banner_info[:text_paragraph_1] + %p= banner_info[:text_paragraph_2] + %p= banner_info[:text_paragraph_3] diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 02565a8f573..f3f79750643 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -7,14 +7,14 @@ %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 admin/topics 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') %span.nav-item-name = _('Overview') %ul.sidebar-sub-level-items - = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts), html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: %w[dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts], html_options: { class: "fly-out-top-item" } ) do = link_to admin_root_path do %strong.fly-out-top-item-name = _('Overview') @@ -27,7 +27,7 @@ = link_to admin_projects_path, title: _('Projects') do %span = _('Projects') - = nav_link(controller: %w(users cohorts)) do + = nav_link(controller: %w[users cohorts]) do = link_to admin_users_path, title: _('Users'), data: { qa_selector: 'users_overview_link' } do %span = _('Users') diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index 16c0c00ad3f..cf1f84790a2 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -3,7 +3,7 @@ .context-header = 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' } + = image_tag avatar_icon_for_user(current_user, 32), class: ['avatar', 'avatar-tile', 'js-sidebar-user-avatar', 's32', 'gl-rounded-full!'], alt: current_user.name, data: { testid: 'sidebar-user-avatar' } %span.sidebar-context-title= _('User Settings') %ul.sidebar-top-level-items = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do diff --git a/app/views/layouts/profile.html.haml b/app/views/layouts/profile.html.haml index 322a77116c8..1ec839ef642 100644 --- a/app/views/layouts/profile.html.haml +++ b/app/views/layouts/profile.html.haml @@ -7,6 +7,7 @@ - enable_search_settings locals: { container_class: 'gl-my-5' } - content_for :flash_message do - = render "layouts/header/storage_enforcement_banner", namespace: current_user.namespace + = render "layouts/header/storage_enforcement_banner", context: current_user.namespace + = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: current_user.namespace = render template: "layouts/application" diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 86b4c4eabe3..9503e874fd0 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -8,8 +8,8 @@ - @content_class = [@content_class, project_classes(@project)].compact.join(" ") - content_for :flash_message do - = render "layouts/header/storage_enforcement_banner", namespace: @project.namespace - = dispensable_render_if_exists "shared/namespace_storage_limit_alert" + = render "layouts/header/storage_enforcement_banner", context: @project + = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @project - content_for :project_javascripts do - project = @target_project || @project diff --git a/app/views/notify/approved_merge_request_email.text.haml b/app/views/notify/approved_merge_request_email.text.haml index 476da7f9af7..ab79a96c4ed 100644 --- a/app/views/notify/approved_merge_request_email.text.haml +++ b/app/views/notify/approved_merge_request_email.text.haml @@ -2,7 +2,7 @@ Merge request #{@merge_request.to_reference} was approved by #{sanitize_name(@ap Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)} -= merge_path_description(@merge_request, 'to') += merge_path_description(@merge_request) Author: #{sanitize_name(@merge_request.author_name)} = assignees_label(@merge_request) diff --git a/app/views/notify/attention_requested_merge_request_email.html.haml b/app/views/notify/attention_requested_merge_request_email.html.haml deleted file mode 100644 index af42f180ae7..00000000000 --- a/app/views/notify/attention_requested_merge_request_email.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -%p - #{sanitize_name(@updated_by.name)} requested your attention on #{merge_request_reference_link(@merge_request)}. diff --git a/app/views/notify/attention_requested_merge_request_email.text.erb b/app/views/notify/attention_requested_merge_request_email.text.erb deleted file mode 100644 index 97b1d4a824b..00000000000 --- a/app/views/notify/attention_requested_merge_request_email.text.erb +++ /dev/null @@ -1 +0,0 @@ -<%= sanitize_name(@updated_by.name) %> requested your attention on <%= merge_request_reference_link(@merge_request) %>. diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml index 942e771261a..c6e38f5fc3d 100644 --- a/app/views/notify/closed_merge_request_email.text.haml +++ b/app/views/notify/closed_merge_request_email.text.haml @@ -2,7 +2,7 @@ Merge request #{@merge_request.to_reference} was closed by #{sanitize_name(@upda Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)} -= merge_path_description(@merge_request, 'to') += merge_path_description(@merge_request) Author: #{sanitize_name(@merge_request.author_name)} = assignees_label(@merge_request) diff --git a/app/views/notify/member_access_requested_email.html.haml b/app/views/notify/member_access_requested_email.html.haml index 43f25af3dba..c3b8d586425 100644 --- a/app/views/notify/member_access_requested_email.html.haml +++ b/app/views/notify/member_access_requested_email.html.haml @@ -1,6 +1,5 @@ %tr %td.text-content %p - #{link_to member.user.name, member.user, class: :highlight} requested #{content_tag :span, member.human_access, class: :highlight} - access to the #{link_to member_source.human_name, polymorphic_url([member_source, :members]), class: :highlight} #{member_source.model_name.singular}. + = member_request_access_link member diff --git a/app/views/notify/member_invite_accepted_email.html.haml b/app/views/notify/member_invite_accepted_email.html.haml index 0abb79000e0..6a2fda22c36 100644 --- a/app/views/notify/member_invite_accepted_email.html.haml +++ b/app/views/notify/member_invite_accepted_email.html.haml @@ -1,8 +1,7 @@ %tr %td.text-content %p - #{content_tag :span, member.invite_email, class: :highlight}, now known as - #{link_to member.user.name, user_url(member.user)}, - has accepted your invitation to join the - #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular}. - + = s_('Notify|%{invite_email}, now known as %{user_name}, has accepted your invitation to join the %{target_name} %{target_model_name}.').html_safe % { invite_email: content_tag(:span, member.invite_email, class: :highlight), + user_name: link_to(member.user.name, user_url(member.user)), + target_name: link_to(member_source.human_name, member_source.web_url, class: :highlight), + target_model_name: member_source.model_name.singular } diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb index c824533eac2..c694bb96f3c 100644 --- a/app/views/notify/member_invite_accepted_email.text.erb +++ b/app/views/notify/member_invite_accepted_email.text.erb @@ -1,3 +1,8 @@ -<%= member.invite_email %>, now known as <%= sanitize_name(member.user.name) %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. +<%= s_('Notify|%{invite_email}, now known as %{user_name}, has accepted your invitation to join the %{target_name} %{target_model_name}.') % { + invite_email: member.invite_email, + user_name: member.user.name, + target_name: member_source.human_name, + target_model_name: member_source.model_name.singular } +%> <%= member_source.web_url %> diff --git a/app/views/notify/member_invite_declined_email.html.haml b/app/views/notify/member_invite_declined_email.html.haml index 5e626767235..df9f388d0b9 100644 --- a/app/views/notify/member_invite_declined_email.html.haml +++ b/app/views/notify/member_invite_declined_email.html.haml @@ -1,7 +1,11 @@ %tr %td.text-content %p - #{content_tag :span, @invite_email, class: :highlight} - has #{content_tag :span, 'declined', class: :highlight} your invitation to join the - #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular}. - + - invited_user = content_tag :span, @invite_email, class: :highlight + - target_link = link_to member_source.human_name, strip_tags(member_source.web_url), class: :highlight + - target_name = sanitize_name(member_source.model_name.singular) + = sanitize(html_escape(s_('Notify|%{invited_user} has %{highlight_start}declined%{highlight_end} your invitation to join the %{target_link} %{target_name}.')) % { invited_user: invited_user, + highlight_start: '<span class="highlight">'.html_safe, + highlight_end: '</span>'.html_safe, + target_link: target_link, + target_name: target_name }) diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml index f3845b2b910..61c9b130da8 100644 --- a/app/views/notify/merge_request_status_email.text.haml +++ b/app/views/notify/merge_request_status_email.text.haml @@ -2,7 +2,7 @@ = sprintf(s_('Notify|Merge request URL: %{merge_request_url}'), { merge_request_url: project_merge_request_url(@merge_request.target_project, @merge_request) }) -= merge_path_description(@merge_request, 'to') += merge_path_description(@merge_request) = sprintf(s_('Notify|Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) }) = assignees_label(@merge_request) diff --git a/app/views/notify/merge_request_unmergeable_email.html.haml b/app/views/notify/merge_request_unmergeable_email.html.haml index f0a5e5d4367..38c9710f385 100644 --- a/app/views/notify/merge_request_unmergeable_email.html.haml +++ b/app/views/notify/merge_request_unmergeable_email.html.haml @@ -1,7 +1,7 @@ %p = sprintf(s_('Notify|Merge request %{merge_request} can no longer be merged due to conflict.'), { merge_request: merge_request_reference_link(@merge_request) }).html_safe %p - = merge_path_description(@merge_request, 'to') + = merge_path_description(@merge_request) %p = sprintf(s_('Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) }) %p diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml index 22d56e73ca8..211e4e379df 100644 --- a/app/views/notify/merge_request_unmergeable_email.text.haml +++ b/app/views/notify/merge_request_unmergeable_email.text.haml @@ -2,7 +2,7 @@ = sprintf(s_('Notify|Merge request URL: %{merge_request_url}'), { merge_request_url: project_merge_request_url(@merge_request.target_project, @merge_request) }) -= merge_path_description(@merge_request, 'to') += merge_path_description(@merge_request) = sprintf(s_('Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) }) = assignees_label(@merge_request) diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml index 568ca995e04..dbf742a5cbc 100644 --- a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml +++ b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml @@ -2,7 +2,7 @@ Merge request #{@merge_request.to_reference} was scheduled to merge after pipeli Merge request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} -= merge_path_description(@merge_request, 'to') += merge_path_description(@merge_request) Author: #{sanitize_name(@merge_request.author_name)} = assignees_label(@merge_request) diff --git a/app/views/notify/merged_merge_request_email.html.haml b/app/views/notify/merged_merge_request_email.html.haml index e2e4d6d937f..bf50ad0a9ad 100644 --- a/app/views/notify/merged_merge_request_email.html.haml +++ b/app/views/notify/merged_merge_request_email.html.haml @@ -2,7 +2,7 @@ = sprintf(s_('Notify|Merge request %{merge_request} was merged'), { merge_request: merge_request_reference_link(@merge_request) }).html_safe %p - = merge_path_description(@merge_request, 'to') + = merge_path_description(@merge_request) %div = sprintf(s_('Notify|Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) }) diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml index 9b9eb566903..b80e4606f35 100644 --- a/app/views/notify/merged_merge_request_email.text.haml +++ b/app/views/notify/merged_merge_request_email.text.haml @@ -2,7 +2,7 @@ = sprintf(s_('Notify|Merge request URL: %{merge_request_url}'), { merge_request_url: project_merge_request_url(@merge_request.target_project, @merge_request) }) -= merge_path_description(@merge_request, 'to') += merge_path_description(@merge_request) = sprintf(s_('Notify|Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) }) diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb index 9ba86f17ef6..9a45aaf1148 100644 --- a/app/views/notify/new_mention_in_merge_request_email.text.erb +++ b/app/views/notify/new_mention_in_merge_request_email.text.erb @@ -2,7 +2,7 @@ You have been mentioned in merge request <%= @merge_request.to_reference %> <%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %> -<%= merge_path_description(@merge_request, 'to') %> +<%= merge_path_description(@merge_request) %> Author: <%= sanitize_name(@merge_request.author_name) %> <%= assignees_label(@merge_request) %> <%= reviewers_label(@merge_request) %> diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 1542d5bba85..3c60235e6d2 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -3,7 +3,7 @@ mr_link: merge_request_reference_link(@merge_request) } .branch - = merge_path_description(@merge_request, 'to') + = merge_path_description(@merge_request) .author Author: #{@merge_request.author_name} .assignee diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb index 09e8ca36225..f2be0b71592 100644 --- a/app/views/notify/new_merge_request_email.text.erb +++ b/app/views/notify/new_merge_request_email.text.erb @@ -2,7 +2,7 @@ mr_link: url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) } %> -<%= merge_path_description(@merge_request, 'to') %> +<%= merge_path_description(@merge_request) %> <%= "#{_('Author')}: #{sanitize_name(@merge_request.author_name)}" %> <%= assignees_label(@merge_request) if @merge_request.assignees.any? %> <%= reviewers_label(@merge_request) if @merge_request.reviewers.any? %> diff --git a/app/views/notify/unapproved_merge_request_email.text.haml b/app/views/notify/unapproved_merge_request_email.text.haml index 4e34b883906..52c65e6f5c6 100644 --- a/app/views/notify/unapproved_merge_request_email.text.haml +++ b/app/views/notify/unapproved_merge_request_email.text.haml @@ -2,7 +2,7 @@ Merge request #{@merge_request.to_reference} was unapproved by #{@unapproved_by. Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)} -= merge_path_description(@merge_request, 'to') += merge_path_description(@merge_request) Author: #{sanitize_name(@merge_request.author_name)} = assignees_label(@merge_request) diff --git a/app/views/notify/user_auto_banned_email.html.haml b/app/views/notify/user_auto_banned_email.html.haml deleted file mode 100644 index 8c33cd7299d..00000000000 --- a/app/views/notify/user_auto_banned_email.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe -- link_end = '</a>'.html_safe -= email_default_heading(_("We've detected some unusual activity")) -%p - = _('We want to let you know %{username} has been banned from %{scope} due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes, scope: @ban_scope } -%p - = _('If this is a mistake, you can %{link_start}unban them%{link_end}.').html_safe % { link_start: link_start % { url: admin_users_url(filter: 'banned') }, link_end: link_end } -%p - = _('You can adjust rules on auto-banning %{link_start}here%{link_end}.').html_safe % { link_start: link_start % { url: network_admin_application_settings_url(anchor: 'js-ip-limits-settings') }, link_end: link_end } diff --git a/app/views/notify/user_auto_banned_email.text.erb b/app/views/notify/user_auto_banned_email.text.erb deleted file mode 100644 index 336973c2e42..00000000000 --- a/app/views/notify/user_auto_banned_email.text.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%= _("We've detected some unusual activity") %> - -<%= _('We want to let you know %{username} has been banned from %{scope} due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes, scope: @ban_scope } %> - -<%= _('If this is a mistake, you can unban them: %{url}.') % { url: admin_users_url(filter: 'banned') } %> - -<%= _('You can adjust rules on auto-banning here: %{url}.') % { url: network_admin_application_settings_url(anchor: 'js-ip-limits-settings') } %> diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 69f765ee163..ef9e7512b57 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -18,7 +18,7 @@ = f.submit _('Add email address'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'add_email_address_button' } %hr %h4.gl-mt-0 - = _('Linked emails (%{email_count})') % { email_count: @emails.load.size + 1 } + = _('Linked emails (%{email_count})') % { email_count: @emails.load.size } .account-well.gl-mb-3 %ul %li @@ -36,28 +36,31 @@ %ul.content-list %li = render partial: 'shared/email_with_badge', locals: { email: @primary_email, verified: current_user.confirmed? } - %span.float-right - = gl_badge_tag s_('Profiles|Primary email'), variant: :success + %ul + %li= s_('Profiles|Primary email') - if @primary_email === current_user.commit_email_or_default - = gl_badge_tag s_('Profiles|Commit email'), variant: :info + %li= s_('Profiles|Commit email') - if @primary_email === current_user.public_email - = gl_badge_tag s_('Profiles|Public email'), variant: :info + %li= s_('Profiles|Public email') - if @primary_email === current_user.notification_email_or_default - = gl_badge_tag s_('Profiles|Default notification email'), variant: :info + %li= s_('Profiles|Default notification email') - @emails.reject(&:user_primary_email?).each do |email| %li{ data: { qa_selector: 'email_row_content' } } - = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } - %span.float-right + .gl-display-flex.gl-justify-content-space-between{ style: 'flex-flow: wrap-reverse; row-gap: 0.5rem' } + %div + = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } + .gl-ml-n3 + - unless email.confirmed? + - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}" + = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default gl-ml-3' + + = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger gl-ml-3' do + %span.sr-only= _('Remove') + = sprite_icon('remove') + %ul - if email.email === current_user.commit_email_or_default - = gl_badge_tag s_('Profiles|Commit email'), variant: :info + %li= s_('Profiles|Commit email') - if email.email === current_user.public_email - = gl_badge_tag s_('Profiles|Public email'), variant: :info + %li= s_('Profiles|Public email') - if email.email === current_user.notification_email_or_default - = gl_badge_tag s_('Profiles|Notification email'), variant: :info - - unless email.confirmed? - - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}" - = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default gl-ml-3' - - = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger gl-ml-3' do - %span.sr-only= _('Remove') - = sprite_icon('remove') + %li= s_('Profiles|Notification email') diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index 5c8acc053f4..35bf7d81502 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -7,6 +7,12 @@ = page_title %p = _('SSH keys allow you to establish a secure connection between your computer and GitLab.') + %br + %h4.gl-mt-0 + = _('SSH Fingerprints') + %p + - config_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_instance_configuration_url } + = html_escape(s_('SSH fingerprints verify that the client is connecting to the correct host. Check the %{config_link_start}current instance configuration%{config_link_end}.')) % { config_link_start: config_link_start, config_link_end: '</a>'.html_safe } .col-lg-8 %h5.gl-mt-0 = _('Add an SSH key') diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index a63e02fca1d..f8737a4e54a 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -99,7 +99,7 @@ s_("Preferences|Show one file at a time on merge request's Changes tab"), help_text: s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.") .form-group - - supported_characters = %w(" ' ` ( [ { < * _).map {|char| "<code>#{char}</code>" }.join(', ') + - supported_characters = %w(" ' ` ( [ { < * _).map { |char| "<code>#{char}</code>" }.join(', ') = f.gitlab_ui_checkbox_component :markdown_surround_selection, s_('Preferences|Surround text selection when typing quotes or brackets'), help_text: sprintf(s_( "Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index dda1640968e..a64968cdcbb 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -3,7 +3,7 @@ - @content_class = "limit-container-width" unless fluid_layout - gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host - availability = availability_values -- custom_emoji = show_status_emoji?(@user.status) +- custom_emoji = @user.status&.customized? = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| .row.js-search-settings-section diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 6304d42896d..c1eaa84e99d 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -21,24 +21,27 @@ - else %p - - register_2fa_token = _('We recommend cloud-based mobile authenticator apps such as Authy, Duo Mobile, and LastPass. They can restore access if you lose your hardware device.') + - register_2fa_token = _('We recommend using cloud-based authenticator applications that can restore access if you lose your hardware device.') = register_2fa_token.html_safe + = link_to _('What are some examples?'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'enable-one-time-password'), target: '_blank', rel: 'noopener noreferrer' .row.gl-mb-3 .col-md-4.gl-min-w-fit-content .gl-p-2.gl-mb-3{ style: 'background: #fff' } = raw @qr_code .col-md-8 - .account-well - %p.gl-mt-0.gl-mb-0 - = _("Can't scan the code?") - %p.gl-mt-0.gl-mb-0 - = _('To add the entry manually, provide the following details to the application on your phone.') - %p.gl-mt-0.gl-mb-0 - = _('Account: %{account}') % { account: @account_string } - %p.gl-mt-0.gl-mb-0.two-factor-secret{ data: { qa_selector: 'otp_secret_content' } } - = _('Key: %{key}') %{ key: current_user.otp_secret.scan(/.{4}/).join(' ') } - %p.two-factor-new-manual-content - = _('Time based: Yes') + .gl-card + .gl-card-body + %p.gl-mt-0.gl-mb-3.gl-font-weight-bold + = _("Can't scan the code?") + %p.gl-mt-0.gl-mb-3 + = _('To add the entry manually, provide the following details to the application on your phone.') + %p.gl-mt-0.gl-mb-0 + = _('Account: %{account}') % { account: @account_string } + %p.gl-mt-0.gl-mb-0{ data: { qa_selector: 'otp_secret_content' } } + = _('Key:') + %code.two-factor-secret= current_user.otp_secret.scan(/.{4}/).join(' ') + %p.gl-mb-0.two-factor-new-manual-content + = _('Time based: Yes') = form_tag profile_two_factor_auth_path, method: :post do |f| - if @error = render Pajamas::AlertComponent.new(title: @error[:message], diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml index 659bca25533..952c6daf415 100644 --- a/app/views/projects/_commit_button.html.haml +++ b/app/views/projects/_commit_button.html.haml @@ -1,5 +1,5 @@ .form-actions.gl-display-flex - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button qa-commit-button' }) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button', data: { qa_selector: 'commit_button' } }) do = _('Commit changes') = render Pajamas::ButtonComponent.new(href: cancel_path, button_options: { class: 'gl-ml-3', id: 'cancel-changes', aria: { label: _('Discard changes') }, data: { confirm: leave_edit_message, confirm_btn_variant: "danger" } }) do diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index eee9cfe0618..c220aa66c81 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -24,15 +24,12 @@ %span.gl-ml-3.gl-mb-3 = render 'shared/members/access_request_links', source: @project - .gl-mt-3.gl-pl-3.gl-w-full - = render "shared/projects/topics", project: @project, cache_enabled: cache_enabled - = cache_if(cache_enabled, [@project, :buttons, current_user, @notification_setting], expires_in: 1.day) do .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-start.gl-flex-wrap.gl-mt-5 - if current_user - if current_user.admin? = link_to [:admin, @project], class: 'btn gl-button btn-icon gl-align-self-start gl-py-2! gl-mr-3', title: _('View project in admin area'), - data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + data: {toggle: 'tooltip', placement: 'top', container: 'body'} do = sprite_icon('admin') .gl-display-flex.gl-align-items-start.gl-mr-3 - if @notification_setting @@ -49,7 +46,8 @@ = 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) - + .gl-my-3 + = render "shared/projects/topics", project: @project, cache_enabled: cache_enabled .home-panel-home-desc.mt-1 - if @project.description.present? .home-panel-description.text-break diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 9845de17a11..859f065377d 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -14,7 +14,7 @@ #{time_ago_with_tooltip(event.created_at)} - - if can?(current_user, :create_merge_request_in, event.project.default_merge_request_target) + - if create_mr_button_from_event?(event) = c.actions do - = link_to new_mr_path_from_push_event(event), title: _("New merge request"), class: "btn gl-button btn-confirm qa-create-merge-request" do - #{ _('Create merge request') } + = render Pajamas::ButtonComponent.new(variant: :confirm, href: create_mr_path_from_push_event(event), button_options: { class: 'qa-create-merge-request' }) do + = _('Create merge request') diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 992b46c1f7b..98cd831d6f1 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -1,6 +1,7 @@ - visibility_level = selected_visibility_level(@project, params.dig(:project, :visibility_level)) - ci_cd_only = local_assigns.fetch(:ci_cd_only, false) - hide_init_with_readme = local_assigns.fetch(:hide_init_with_readme, false) +- include_description = local_assigns.fetch(:include_description, true) - track_label = local_assigns.fetch(:track_label, 'blank_project') .row{ id: project_name_id } @@ -44,10 +45,19 @@ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'add-details-to-your-profile-with-a-readme') } = html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe } -.form-group - = f.label :description, class: 'label-bold' do - = s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe } - = f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { qa_selector: 'project_description', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_description", track_value: "" } +- if include_description + .form-group + = f.label :description, class: 'label-bold' do + = s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe } + = f.text_area :description, + placeholder: s_('ProjectsNew|Description format'), + class: "form-control gl-form-input", + rows: 3, + maxlength: 250, + data: { qa_selector: 'project_description', + track_label: track_label, + track_action: "activate_form_input", + track_property: "project_description" } - unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? || !Gitlab.com? .js-deployment-target-select @@ -63,18 +73,20 @@ = 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 + = render Pajamas::CheckboxTagComponent.new(name: 'project[initialize_with_readme]', + checked: true, + checkbox_options: { data: { qa_selector: 'initialize_with_readme_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_readme' } }) do |c| + = c.label do + = s_('ProjectsNew|Initialize repository with a README') + = c.help_text do = s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.') .form-group - .form-check.gl-mb-3 - = check_box_tag 'project[initialize_with_sast]', '1', false, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', 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 + = render Pajamas::CheckboxTagComponent.new(name: 'project[initialize_with_sast]', + checkbox_options: { data: { qa_selector: 'initialize_with_sast_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } }) do |c| + = c.label do = s_('ProjectsNew|Enable Static Application Security Testing (SAST)') - .form-text.text-muted + = c.help_text do = 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' } diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml index 393b199fb05..02aa1f7e93b 100644 --- a/app/views/projects/_transfer.html.haml +++ b/app/views/projects/_transfer.html.haml @@ -1,7 +1,7 @@ - return unless can?(current_user, :change_namespace, @project) - form_id = "transfer-project-form" - hidden_input_id = "new_namespace_id" -- initial_data = { namespaces: namespaces_as_json, button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id } +- initial_data = { button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id } .sub-section %h4.danger-title= _('Transfer project') diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml index 66066ceb5b2..e8a4e091dcf 100644 --- a/app/views/projects/_visibility_modal.html.haml +++ b/app/views/projects/_visibility_modal.html.haml @@ -22,8 +22,8 @@ %label{ for: "confirm_path_input" } = _("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-legacy-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } } .form-group - = text_field_tag 'confirm_path_input', '', class: 'form-control js-legacy-confirm-danger-input qa-confirm-input' + = text_field_tag 'confirm_path_input', '', class: 'form-control js-legacy-confirm-danger-input' .form-actions %button.btn.gl-button.btn-default.gl-mr-4{ type: "button", "data-dismiss": "modal" } = _('Cancel') - = submit_tag _('Reduce project visibility'), class: "btn gl-button btn-danger js-legacy-confirm-danger-submit qa-confirm-button", disabled: true + = submit_tag _('Reduce project visibility'), class: "btn gl-button btn-danger js-legacy-confirm-danger-submit", disabled: true diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 09a275c24a1..398ca3dd27c 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -42,7 +42,7 @@ .file-editor.code - if Feature.enabled?(:source_editor_toolbar, current_user) #editor-toolbar - .js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }< + .js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true, qa_selector: 'source_editor_preview_container' } }< %pre.editor-loading-content= params[:content] || local_assigns[:blob_data] - if local_assigns[:path] .js-edit-mode-pane#preview.hide diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 1477ae66d80..52b8d6bc66f 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -33,8 +33,8 @@ .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5 %svg.s24 - - if merge_project && create_mr_button?(from: @repository.root_ref, to: branch.name, source_project: @project, target_project: @project) - = link_to create_mr_path(from: @repository.root_ref, to: branch.name, source_project: @project, target_project: @project), class: 'gl-button btn btn-default' do + - if merge_project && create_mr_button?(from: branch.name, source_project: @project) + = render Pajamas::ButtonComponent.new(href: create_mr_path(from: branch.name, source_project: @project)) do = _('Merge request') - if branch.name != @repository.root_ref diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml index bd6831ff3b2..6ca5aaf061e 100644 --- a/app/views/projects/branches/_panel.html.haml +++ b/app/views/projects/branches/_panel.html.haml @@ -7,12 +7,13 @@ - return unless branches.any? -.card - .card-header += render Pajamas::CardComponent.new(card_options: {class: 'gl-mb-5'}, body_options: {class: 'gl-py-0'}, footer_options: {class: 'gl-text-center'}) do |c| + - c.header do = panel_title - %ul.content-list.all-branches.qa-all-branches - - branches.first(overview_max_branches).each do |branch| - = render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any? + - c.body do + %ul.content-list.all-branches.qa-all-branches + - branches.first(overview_max_branches).each do |branch| + = render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any? - if branches.size > overview_max_branches - .card-footer.text-center + - c.footer do = link_to show_more_text, project_branches_filtered_path(project, state: state), id: "state-#{state}", data: { state: state } diff --git a/app/views/projects/buttons/_remove_tag.html.haml b/app/views/projects/buttons/_remove_tag.html.haml index 060a854d4e4..dfa643a87bb 100644 --- a/app/views/projects/buttons/_remove_tag.html.haml +++ b/app/views/projects/buttons/_remove_tag.html.haml @@ -8,4 +8,4 @@ - title = s_('TagsPage|Only a project maintainer or owner can delete a protected tag') - disabled = true -= render Pajamas::ButtonComponent.new(variant: :default, icon: 'remove', button_options: { class: "js-delete-tag-button gl-ml-3\!", 'aria-label': s_('TagsPage|Delete tag'), title: title, disabled: disabled, data: { toggle: 'tooltip', container: 'body', path: project_tag_path(@project, tag.name), tag_name: tag.name, is_protected: protected_tag?(project, tag).to_s } }) += render Pajamas::ButtonComponent.new(variant: :default, icon: 'remove', button_options: { class: "js-delete-tag-button", 'aria-label': s_('TagsPage|Delete tag'), title: title, disabled: disabled, data: { toggle: 'tooltip', container: 'body', path: project_tag_path(@project, tag.name), tag_name: tag.name, is_protected: protected_tag?(project, tag).to_s } }) diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index 00d518450e9..f607a21ad21 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -7,7 +7,7 @@ - else = sprite_icon('star-o', css_class: 'icon') %span= s_('ProjectOverview|Star') - = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm star-count count' do + = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm has-tooltip star-count count' do = @project.star_count - else @@ -15,5 +15,5 @@ = link_to new_user_session_path, class: 'gl-button btn btn-default btn-sm has-tooltip star-btn', title: s_('ProjectOverview|You must sign in to star a project') do = sprite_icon('star-o', css_class: 'icon') %span= s_('ProjectOverview|Star') - = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm star-count count' do + = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm has-tooltip star-count count' do = @project.star_count diff --git a/app/views/projects/ci/secure_files/show.html.haml b/app/views/projects/ci/secure_files/show.html.haml deleted file mode 100644 index 1a87ccd753c..00000000000 --- a/app/views/projects/ci/secure_files/show.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -- page_title s_('Secure Files') - -#js-ci-secure-files{ data: { project_id: @project.id, admin: can?(current_user, :admin_secure_files, @project).to_s, file_size_limit: Ci::SecureFile::FILE_SIZE_LIMIT.to_mb } } diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml index e56579b162f..629d3cfaf74 100644 --- a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml +++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = capture do - = html_escape(_('This commit was signed with a verified signature, but the committer email is %{strong_open}not verified%{strong_close} to belong to the same user.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } + = html_escape(_('This commit was signed with a verified signature, but the committer email is not associated with the GPG Key.')) - locals = { signature: signature, title: title, label: _('Unverified'), css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true } diff --git a/app/views/projects/commits/_commit_list.html.haml b/app/views/projects/commits/_commit_list.html.haml index 6ed65d07202..23b25b5dcbd 100644 --- a/app/views/projects/commits/_commit_list.html.haml +++ b/app/views/projects/commits/_commit_list.html.haml @@ -2,14 +2,15 @@ - hidden = @hidden_commit_count - commits = Commit.decorate(commits, @project) -.card - .card-header += render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}, body_options: { class: 'gl-py-0'}) do |c| + - c.header do Commits (#{@total_commit_count}) - - if hidden > 0 - %ul.content-list - - commits.each do |commit| - = render "projects/commits/inline_commit", commit: commit, project: @project - %li.warning-row.unstyled - #{number_with_delimiter(hidden)} additional commits have been omitted to prevent performance issues. - - else - %ul.content-list= render commits, project: @project, ref: @ref + - c.body do + - if hidden > 0 + %ul.content-list + - commits.each do |commit| + = render "projects/commits/inline_commit", commit: commit, project: @project + %li.warning-row.unstyled + #{number_with_delimiter(hidden)} additional commits have been omitted to prevent performance issues. + - else + %ul.content-list= render commits, project: @project, ref: @ref diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 764ddace0ad..bb3a38d6ac8 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -41,7 +41,7 @@ = n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden) - if can_update_merge_request && context_commits&.empty? - = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-mt-5', data: { context_commits_empty: 'true' } }) do + = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-mt-5 add-review-item-modal-trigger', data: { context_commits_empty: 'true' } }) do = _('Add previously merged commits') - if commits.size == 0 && context_commits.nil? diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index e5be3a897a5..4007b657403 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -18,9 +18,10 @@ - if @merge_request.present? .control.d-none.d-md-block = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn gl-button' - - elsif create_mr_button?(from: @repository.root_ref, to: @ref, source_project: @project, target_project: @project) + - elsif create_mr_button?(from: @ref, source_project: @project) .control.d-none.d-md-block - = link_to _("Create merge request"), create_mr_path(from: @repository.root_ref, to: @ref, source_project: @project, target_project: @project), class: 'btn gl-button btn-confirm' + = render Pajamas::ButtonComponent.new(variant: :confirm, href: create_mr_path(from: @ref, source_project: @project)) do + = _("Create merge request") .control = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index a6be6695b75..95186b85838 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -1,7 +1,7 @@ - add_to_breadcrumbs _("Compare Revisions"), project_compare_index_path(@project) - page_title "#{params[:from]}...#{params[:to]}" -.sub-header-block.no-bottom-space +.sub-header-block.gl-border-b-0.gl-mb-0 .js-signature-container{ data: { 'signatures-path' => signatures_namespace_project_compare_index_path } } #js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, params) } @@ -17,11 +17,11 @@ paginate_diffs: true, paginate_diffs_per_page: Projects::CompareController::COMMIT_DIFFS_PER_PAGE - else - .card.gl-bg-gray-50.gl-border-none.gl-p-2 - .center + = render Pajamas::CardComponent.new(card_options: { class: "gl-bg-gray-50 gl-mb-5 gl-border-none gl-text-center" }) do |c| + - c.body do %h4 = s_("CompareBranches|There isn't anything to compare.") - %p.slead + %p.gl-mb-4.gl-line-height-24 - if params[:to] == params[:from] - source_branch = capture do %span.ref-name= params[:from] diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index d596199f816..11984a9d6f6 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -11,7 +11,7 @@ .content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed .files-changed-inner .inline-parallel-buttons.gl-display-none.gl-md-display-flex - - if !diffs_expanded? && diff_files.any? { |diff_file| diff_file.collapsed? } + - if !diffs_expanded? && diff_files.any?(&:collapsed?) = link_to _('Expand all'), url_for(safe_params.merge(expanded: 1, format: nil)), class: 'gl-button btn btn-default' - if show_whitespace_toggle - if current_controller?(:commit) diff --git a/app/views/projects/google_cloud/configuration/index.html.haml b/app/views/projects/google_cloud/configuration/index.html.haml index ec977898f47..dab49d5032a 100644 --- a/app/views/projects/google_cloud/configuration/index.html.haml +++ b/app/views/projects/google_cloud/configuration/index.html.haml @@ -1,4 +1,4 @@ -- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project) - breadcrumb_title s_('CloudSeed|Configuration') - page_title s_('CloudSeed|Configuration') diff --git a/app/views/projects/google_cloud/databases/index.html.haml b/app/views/projects/google_cloud/databases/index.html.haml index ad732317d8d..0528ac3d1f5 100644 --- a/app/views/projects/google_cloud/databases/index.html.haml +++ b/app/views/projects/google_cloud/databases/index.html.haml @@ -1,4 +1,4 @@ -- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project) - breadcrumb_title s_('CloudSeed|Databases') - page_title s_('CloudSeed|Databases') diff --git a/app/views/projects/google_cloud/deployments/index.html.haml b/app/views/projects/google_cloud/deployments/index.html.haml index b140159a7f5..22a365671bc 100644 --- a/app/views/projects/google_cloud/deployments/index.html.haml +++ b/app/views/projects/google_cloud/deployments/index.html.haml @@ -1,4 +1,4 @@ -- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project) - breadcrumb_title s_('CloudSeed|Deployments') - page_title s_('CloudSeed|Deployments') diff --git a/app/views/projects/google_cloud/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml index d7cabaa029b..36b5630611e 100644 --- a/app/views/projects/google_cloud/gcp_regions/index.html.haml +++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml @@ -1,4 +1,4 @@ -- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project) - breadcrumb_title _('CloudSeed|Regions') - page_title s_('CloudSeed|Regions') diff --git a/app/views/projects/google_cloud/service_accounts/index.html.haml b/app/views/projects/google_cloud/service_accounts/index.html.haml index 6191de577fe..8f70818abd9 100644 --- a/app/views/projects/google_cloud/service_accounts/index.html.haml +++ b/app/views/projects/google_cloud/service_accounts/index.html.haml @@ -1,4 +1,4 @@ -- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project) - breadcrumb_title s_('CloudSeed|Service Account') - page_title s_('CloudSeed|Service Account') diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index ca0307aed60..04d400688d4 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -15,20 +15,7 @@ - if defined?(@daily_coverage_options) .repo-charts.my-5 - .sub-header-block.border-top - .d-flex.justify-content-between.align-items-center - %h4.sub-header.m-0 - - start_date = capture do - #{@daily_coverage_options[:base_params][:start_date].strftime('%b %d')} - - end_date = capture do - #{@daily_coverage_options[:base_params][:end_date].strftime('%b %d')} - = (_("Code coverage statistics for %{ref} %{start_date} - %{end_date}") % { ref: "<strong>#{h @ref}</strong>", start_date: start_date, end_date: end_date }).html_safe - - download_path = capture do - #{@daily_coverage_options[:download_path]} - %a.btn.gl-button.btn-default.btn-sm{ href: "#{download_path}?#{@daily_coverage_options[:base_params].to_query}" } - %small - = _("Download raw data (.csv)") - #js-code-coverage-chart{ data: { graph_endpoint: "#{@daily_coverage_options[:graph_api_path]}?#{@daily_coverage_options[:base_params].to_query}" } } + #js-code-coverage-chart{ data: project_coverage_chart_data_attributes(@daily_coverage_options, @ref) } .repo-charts .sub-header-block.border-top diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml deleted file mode 100644 index 6a46b0b3510..00000000000 --- a/app/views/projects/hook_logs/_index.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- 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 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 - = render partial: 'shared/hook_logs/recent_deliveries_table', locals: { hook: hook, hook_logs: hook_logs } diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml index 74af65904cd..b350455807d 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -18,4 +18,4 @@ %hr -= render partial: 'projects/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs, project: @project } += render partial: 'shared/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs, project: @project } diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml index bcfa32566fb..306f24d717b 100644 --- a/app/views/projects/imports/new.html.haml +++ b/app/views/projects/imports/new.html.haml @@ -1,19 +1,19 @@ -- page_title _("Import repository") +- page_title _('Import repository') %h1.page-title.gl-font-size-h-display = _('Import repository') %hr - if @project.import_failed? - .card.border-danger - .card-header.bg-danger.text-white The repository could not be imported. - .card-body - %pre - :preserve - #{h(@project.import_state.last_error)} + = render Pajamas::AlertComponent.new(title: s_('Import|The repository could not be imported.'), + dismissible: false, + variant: :danger, + alert_options: { class: 'gl-mb-5' }) do |c| + = c.body do + = @project.import_state.last_error = gitlab_ui_form_for @project, url: project_import_path(@project), method: :post, html: { class: 'js-project-import' } do |f| - = render "shared/import_form", f: f + = render 'shared/import_form', f: f .form-actions - = f.submit 'Start import', class: "gl-button btn btn-confirm", data: { disable_with: false } + = f.submit 'Start import', class: 'gl-button btn btn-confirm', data: { disable_with: false } diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 801841edc26..f9798d25b06 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -6,11 +6,10 @@ - create_mr_text = can_create_confidential_merge_request? ? _('Create confidential merge request') : _('Create merge request') - can_create_path = can_create_branch_project_issue_path(@project, @issue) - - create_mr_path = project_new_merge_request_path(@project, merge_request: { source_branch: @issue.to_branch_name, target_branch: @project.default_branch, issue_iid: @issue.iid }) - create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid, format: :json) - refs_path = refs_namespace_project_path(@project.namespace, @project, search: '') - .create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } } + .create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path(from: @issue.to_branch_name, source_project: @project, to: @project.default_branch, mr_params: { issue_iid: @issue.iid }), create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } } .btn-group.unavailable %button.gl-button.btn{ type: 'button', disabled: 'disabled' } = gl_loading_icon(inline: true, css_class: 'js-create-mr-spinner gl-button-icon gl-display-none') diff --git a/app/views/projects/issues/_related_issues.html.haml b/app/views/projects/issues/_related_issues.html.haml index bab37609c20..1c252958525 100644 --- a/app/views/projects/issues/_related_issues.html.haml +++ b/app/views/projects/issues/_related_issues.html.haml @@ -1,5 +1,7 @@ - if can?(current_user, :read_issue_link, @project) .js-related-issues-root{ data: { endpoint: project_issue_links_path(@project, @issue), can_add_related_issues: "#{can?(current_user, :admin_issue_link, @issue)}", + full_path: @project.full_path, + has_issue_weights_feature: @project.licensed_feature_available?(:issue_weights).to_s, help_path: help_page_path('user/project/issues/related_issues'), - show_categorized_issues: "false" } } + show_categorized_issues: @project.licensed_feature_available?(:blocked_issues).to_s } } diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml index 5d478784350..df2ffdd30ee 100644 --- a/app/views/projects/issues/_work_item_links.html.haml +++ b/app/views/projects/issues/_work_item_links.html.haml @@ -1,2 +1,2 @@ - if Feature.enabled?(:work_items_hierarchy, @project) - .js-work-item-links-root{ data: { issuable_id: @issue.id, project_path: @project.full_path } } + .js-work-item-links-root{ data: { issuable_id: @issue.id, project_path: @project.full_path, wi: work_items_index_data(@project) } } diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 647464b31f8..f7a02c521f5 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -11,14 +11,14 @@ .labels-container.gl-mt-5 - if can_admin_label && search.blank? %p.text-muted - = _('Labels can be applied to issues and merge requests.') - %br - = _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.') + = _('Labels can be applied to issues and merge requests. Star a label to make it a priority label.') -# Only show it in the first page - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1') .prioritized-labels.gl-mb-7{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] } %h4.gl-mt-3= _('Prioritized Labels') + %p.text-muted + = _('Drag to reorder prioritized labels and change their relative priority.') .manage-labels-list.js-prioritized-labels{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } } #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" } = render 'shared/empty_states/priority_labels' diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml index 6b367c735c3..62cd8bd94e3 100644 --- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml +++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml @@ -34,9 +34,10 @@ = display_issuable_type - unless current_controller?('conflicts') - - if current_user && moved_mr_sidebar_enabled? && !@merge_request.merged? - %li.gl-new-dropdown-divider - %hr.dropdown-divider + - if current_user && moved_mr_sidebar_enabled? + - if !@merge_request.merged? + %li.gl-new-dropdown-divider + %hr.dropdown-divider %li.gl-new-dropdown-item.js-sidebar-subscriptions-entry-point - unless issuable_author_is_current_user(@merge_request) %li.gl-new-dropdown-item diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 4ef557fbd8f..78976be5dd7 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -32,7 +32,7 @@ = tab_link_for @merge_request, :commits do = _("Commits") = gl_badge_tag @commits_count, { size: :sm } - - if @number_of_pipelines.nonzero? + - if @project.builds_enabled? = render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do = tab_link_for @merge_request, :pipelines do = _("Pipelines") @@ -44,7 +44,7 @@ - if Feature.enabled?(:moved_mr_sidebar, @project) .gl-ml-auto.gl-align-items-center.gl-display-none.gl-md-display-flex.js-expand-sidebar{ class: "gl-lg-display-none!" } = render Pajamas::ButtonComponent.new(size: 'small', - icon: 'angle-double-left', + icon: 'chevron-double-lg-left', button_options: { class: 'js-sidebar-toggle' }) do = _('Expand') .d-flex.flex-wrap.align-items-center.justify-content-lg-end @@ -80,7 +80,7 @@ = render "projects/merge_requests/tabs/pane", name: "commits", id: "commits", class: "commits" do -# This tab is always loaded via AJAX = render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do - - if @number_of_pipelines.nonzero? + - if @project.builds_enabled? = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) - params = request.query_parameters.merge(diff_head: true) = render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: diffs_tab_pane_data(@project, @merge_request, params) @@ -99,11 +99,8 @@ #js-review-bar -- if Feature.enabled?(:mr_experience_survey, @project) - #js-mr-experience-survey - -- if current_user&.mr_attention_requests_enabled? - #js-need-attention-sidebar-onboarding +- if Feature.enabled?(:mr_experience_survey, @project) && current_user + #js-mr-experience-survey{ data: { account_age: current_user.account_age_in_days } } = render 'projects/invite_members_modal', project: @project = render 'shared/web_ide_path' diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 9b0508d8cb5..0d56bf7793d 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -1,4 +1,4 @@ -= form_for [@project, @milestone], += gitlab_ui_form_for [@project, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| = form_errors(@milestone, pajamas_alert: true) .form-group.row diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 07c38d9845c..56581fe7b18 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -13,7 +13,7 @@ .row{ 'v-cloak': true } #blank-project-pane.tab-pane.active = gitlab_ui_form_for @project, html: { class: 'new_project gl-mt-3' } do |f| - = render 'new_project_fields', f: f, project_name_id: "blank-project-name" + = render 'new_project_fields', f: f, project_name_id: "blank-project-name", include_description: false #create-from-template-pane.tab-pane = render Pajamas::CardComponent.new(card_options: { class: 'gl-my-5' }) do |c| diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml index c5efacb21af..28f04d78861 100644 --- a/app/views/projects/pages/_access.html.haml +++ b/app/views/projects/pages/_access.html.haml @@ -1,8 +1,8 @@ - if @project.pages_deployed? - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { qa_selector: 'access_page_container' } }, footer_options: { class: 'gl-alert-warning' }) do |c| + - c.header do = s_('GitLabPages|Access pages') - .card-body + - c.body do %p %strong = s_('GitLabPages|Your pages are served under:') @@ -14,7 +14,7 @@ %p = external_link(domain.url, domain.url) - unless @project.public_pages? - .card-footer.gl-alert-warning + - c.footer do - help_page = help_page_path('user/project/pages/pages_access_control') - link_start = '<a href="%{url}" target="_blank" class="gl-alert-link" rel="noopener noreferrer">'.html_safe % { url: help_page } - link_end = '</a>'.html_safe diff --git a/app/views/projects/pages/_header.html.haml b/app/views/projects/pages/_header.html.haml new file mode 100644 index 00000000000..da35f2fdf09 --- /dev/null +++ b/app/views/projects/pages/_header.html.haml @@ -0,0 +1,11 @@ +- can_add_new_domain = can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) + +%h1.page-title.gl-font-size-h-display.with-button + = s_('GitLabPages|Pages') + - if can_add_new_domain + = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'float-right'}, href: new_project_pages_domain_path(@project)) do + = s_('GitLabPages|New Domain') +%p + - docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer'>".html_safe + - docs_link_end = '</a>'.html_safe + = s_('GitLabPages|With GitLab Pages you can host your static website directly from your GitLab repository. %{docs_link_start}Learn more.%{link_end}').html_safe % { docs_link_start: docs_link_start, link_end: docs_link_end } diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index 0ddf105ef60..16312da1353 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -1,38 +1,39 @@ - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? - if can?(current_user, :update_pages, @project) && @domains.any? - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}) do |c| + - c.header do Domains (#{@domains.size}) - %ul.list-group.list-group-flush - - @domains.each do |domain| - %li.list-group-item.gl-display-flex.gl-justify-content-space-between.gl-align-items-center - .gl-display-flex.gl-align-items-center - - if verification_enabled - - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success'] - .domain-status.ci-status-icon.has-tooltip{ class: "gl-mr-5 ci-status-icon-#{status}", title: tooltip } - = sprite_icon("status_#{status}" ) - .domain-name - = external_link(domain.url, domain.url) - - if domain.certificate - %div - = gl_badge_tag(s_('GitLabPages|Certificate: %{subject}') % { subject: domain.pages_domain.subject }) - - if domain.expired? - = gl_badge_tag s_('GitLabPages|Expired'), variant: :danger - %div - = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-confirm btn-inverted" - = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped" - - if domain.needs_verification? - %li.list-group-item.bs-callout-warning - - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe - - details_link_end = '</a>'.html_safe - = s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain, - link_start: details_link_start, - link_end: details_link_end } - - if domain.show_auto_ssl_failed_warning? - %li.list-group-item.bs-callout-warning - - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe - - details_link_end = '</a>'.html_safe - = s_("GitLabPages|Something went wrong while obtaining the Let's Encrypt certificate for %{domain}. To retry visit your %{link_start}domain details%{link_end}.").html_safe % { domain: domain.domain, - link_start: details_link_start, - link_end: details_link_end } + - c.body do + %ul.list-group.list-group-flush + - @domains.each do |domain| + %li.list-group-item.gl-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-p-0 + .gl-display-flex.gl-align-items-center + - if verification_enabled + - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success'] + .domain-status.ci-status-icon.has-tooltip{ class: "gl-mr-5 ci-status-icon-#{status}", title: tooltip } + = sprite_icon("status_#{status}" ) + .domain-name + = external_link(domain.url, domain.url) + - if domain.certificate + %div + = gl_badge_tag(s_('GitLabPages|Certificate: %{subject}') % { subject: domain.pages_domain.subject }) + - if domain.expired? + = gl_badge_tag s_('GitLabPages|Expired'), variant: :danger + %div + = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-confirm btn-inverted" + = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped" + - if domain.needs_verification? + %li.list-group-item.bs-callout-warning + - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe + - details_link_end = '</a>'.html_safe + = s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain, + link_start: details_link_start, + link_end: details_link_end } + - if domain.show_auto_ssl_failed_warning? + %li.list-group-item.bs-callout-warning + - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe + - details_link_end = '</a>'.html_safe + = s_("GitLabPages|Something went wrong while obtaining the Let's Encrypt certificate for %{domain}. To retry visit your %{link_start}domain details%{link_end}.").html_safe % { domain: domain.domain, + link_start: details_link_start, + link_end: details_link_end } diff --git a/app/views/projects/pages/_no_domains.html.haml b/app/views/projects/pages/_no_domains.html.haml index a537bd80d30..eee7d062d00 100644 --- a/app/views/projects/pages/_no_domains.html.haml +++ b/app/views/projects/pages/_no_domains.html.haml @@ -1,6 +1,6 @@ - if can?(current_user, :update_pages, @project) - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}, body_options: { class: 'gl-text-center nothing-here-block' }) do |c| + - c.header do = s_('GitLabPages|Domains') - .nothing-here-block + - c.body do = s_("GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it.") diff --git a/app/views/projects/pages/_use.html.haml b/app/views/projects/pages/_use.html.haml index 20e6338fa76..dccf61c6ec5 100644 --- a/app/views/projects/pages/_use.html.haml +++ b/app/views/projects/pages/_use.html.haml @@ -1,10 +1,9 @@ - unless @project.pages_deployed? - .card.border-info - .card-header.bg-info.text-white + = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-blue-500' }, header_options: { class: 'gl-bg-blue-500 gl-text-white' }) do |c| + - c.header do = s_('GitLabPages|Configure pages') - .card-body - %p.gl-mb-0 - - docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer'>".html_safe - - samples_link_start = "<a href='https://gitlab.com/pages' target='_blank' rel='noopener noreferrer'>".html_safe - - link_end = '</a>'.html_safe - = s_('GitLabPages|Your Pages site is not configured yet. See the %{docs_link_start}GitLab Pages documentation%{link_end} to learn how to upload your static site and have GitLab serve it. You can also take some inspiration from the %{samples_link_start}sample Pages projects%{link_end}.').html_safe % { docs_link_start: docs_link_start, samples_link_start: samples_link_start, link_end: link_end } + - c.body do + - docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer' data-track-action='click_link' data-track-label='pages_docs_link'>".html_safe + - samples_link_start = "<a href='https://gitlab.com/pages' target='_blank' rel='noopener noreferrer' data-track-action='click_link' data-track-label='pages_samples_link'>".html_safe + - link_end = '</a>'.html_safe + = s_('GitLabPages|Your Pages site is not configured yet. See the %{docs_link_start}GitLab Pages documentation%{link_end} to learn how to upload your static site and have GitLab serve it. You can also take some inspiration from the %{samples_link_start}sample Pages projects%{link_end}.').html_safe % { docs_link_start: docs_link_start, samples_link_start: samples_link_start, link_end: link_end } diff --git a/app/views/projects/pages/_waiting.html.haml b/app/views/projects/pages/_waiting.html.haml new file mode 100644 index 00000000000..e8acadbabe3 --- /dev/null +++ b/app/views/projects/pages/_waiting.html.haml @@ -0,0 +1,13 @@ +.empty-state + .row.gl-align-items-center.gl-justify-content-center + .order-md-2 + = image_tag 'illustrations/pipelines_pending.svg' + .row.gl-align-items-center.gl-justify-content-center + .text-content.gl-text-center.order-md-1 + %h4= s_("GitLabPages|Waiting for the Pages Pipeline to complete...") + %p= s_("GitLabPages|Your Project has been configured for Pages. Now we have to wait for the Pipeline to succeed for the first time.") + = render Pajamas::ButtonComponent.new(variant: :confirm, href: project_pipelines_path(@project)) do + = s_("GitLabPages|Check the Pipeline Status") + = render Pajamas::ButtonComponent.new(href: new_namespace_project_pages_path) do + = s_("GitLabPages|Start over") + diff --git a/app/views/projects/pages/disabled.html.haml b/app/views/projects/pages/disabled.html.haml new file mode 100644 index 00000000000..769ecac636b --- /dev/null +++ b/app/views/projects/pages/disabled.html.haml @@ -0,0 +1,4 @@ += render 'header' + +.bs-callout.bs-callout-warning + = html_escape_once(s_('GitLabPages|GitLab Pages are disabled for this project. You can enable them on your project\'s %{strong_start}Settings > General > Visibility%{strong_end} page.')).html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } diff --git a/app/views/projects/pages/new.html.haml b/app/views/projects/pages/new.html.haml new file mode 100644 index 00000000000..cdd52a933e9 --- /dev/null +++ b/app/views/projects/pages/new.html.haml @@ -0,0 +1,7 @@ +- if Feature.enabled?(:use_pipeline_wizard_for_pages, @group) + #js-pages{ data: @pipeline_wizard_data } + +- else + = render 'header' + + = render 'use' diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 3fea9f9ff1b..01477967394 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -1,30 +1,20 @@ - page_title _('Pages') -- if @project.pages_enabled? - %h1.page-title.gl-font-size-h-display.with-button - = s_('GitLabPages|Pages') +- unless @project.pages_deployed? + = render 'waiting' - - if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) - = link_to new_project_pages_domain_path(@project), class: 'btn gl-button btn-confirm float-right', title: s_('GitLabPages|New Domain') do - = s_('GitLabPages|New Domain') +- else + = render 'header' - %p.light - - docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer'>".html_safe - - link_end = '</a>'.html_safe - = s_('GitLabPages|With GitLab Pages you can host your static website directly from your GitLab repository. %{docs_link_start}Learn more.%{link_end}').html_safe % { docs_link_start: docs_link_start, link_end: link_end } + %section = render 'pages_settings' %hr.clearfix - - = render 'ssl_limitations_warning' if @project.pages_subdomain.include?(".") - = render 'access' - = render 'use' - - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https - = render 'list' - - else - = render 'no_domains' - = render 'destroy' -- else - .bs-callout.bs-callout-warning - = html_escape_once(s_('GitLabPages|GitLab Pages are disabled for this project. You can enable them on your project\'s %{strong_start}Settings > General > Visibility%{strong_end} page.')).html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = render 'ssl_limitations_warning' if @project.pages_subdomain.include?(".") + = render 'access' + - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https + = render 'list' + - else + = render 'no_domains' + = render 'destroy' diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index edcd44563f7..c36c3ae5adf 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -33,7 +33,7 @@ = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: _('Play'), class: 'btn gl-button btn-default btn-icon' do = sprite_icon('play') - if can?(current_user, :take_ownership_pipeline_schedule, pipeline_schedule) - = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn gl-button btn-default' do + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-take-ownership-button has-tooltip', title: s_('PipelineSchedule|Take ownership to edit'), data: { url: take_ownership_pipeline_schedule_path(pipeline_schedule) } }) do = s_('PipelineSchedules|Take ownership') - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn gl-button btn-default btn-icon' do diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index a56e8f7f5c7..661cf465081 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -18,3 +18,5 @@ - else .card.bg-light.gl-mt-3 .nothing-here-block= _("No schedules") + +#pipeline-take-ownership-modal diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index 5a655e7e83d..e16a2235e53 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -6,4 +6,5 @@ failed_pipelines_link: project_pipelines_path(@project, page: '1', scope: 'all', status: 'failed'), coverage_chart_path: charts_project_graph_path(@project, @project.default_branch), test_runs_empty_state_image_path: image_path('illustrations/pipeline.svg'), + project_quality_summary_feedback_image_path: image_path('illustrations/chat-bubble-sm.svg'), default_branch: @project.default_branch } } diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml index 5e4b1397dd3..d0fdd3a729a 100644 --- a/app/views/projects/project_templates/_template.html.haml +++ b/app/views/projects/project_templates/_template.html.haml @@ -10,7 +10,8 @@ .controls.d-flex.align-items-center %a.btn.gl-button.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_action: "click_button", track_value: "" } } = _("Preview") - %label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name } + %label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name, + 'data-testid': "use_template_#{template.name}" } %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_action: "click_button", track_value: "" } } %span{ data: { qa_selector: 'use_template_button' } } = _("Use template") diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index 3b8294a1dec..35770c32f9f 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -1,9 +1,9 @@ = form_for [@project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' } - .card - .card-header.gl-font-weight-bold + = render Pajamas::CardComponent.new(card_options: { class: "gl-mb-5" }) do |c| + - c.header do = s_("ProtectedBranch|Protect a branch") - .card-body + - c.body do = form_errors(@protected_branch, pajamas_alert: true) .form-group.row = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12' @@ -31,5 +31,5 @@ - force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url } = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe = render_if_exists 'projects/protected_branches/ee/code_owner_approval_form', f: f - .card-footer + - c.footer do = f.submit s_('ProtectedBranch|Protect'), class: 'gl-button btn btn-confirm', disabled: true, data: { qa_selector: 'protect_button' } diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index 449b6c25f50..5acd6f95df4 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -28,9 +28,9 @@ = _('This group does not have any group runners yet.') - if can?(current_user, :admin_group_runners, @project.group) - - register_runners_path = group_runners_path(@project.group) - - group_link = link_to _("group's CI/CD settings."), register_runners_path - = _('Group owners can register group runners in the %{link}').html_safe % { link: group_link } + - group_link_start = "<a href='#{group_runners_path(@project.group)}'>".html_safe + - group_link_end = '</a>'.html_safe + = s_("Runners|To register them, go to the %{link_start}group's Runners page%{link_end}.").html_safe % { link_start: group_link_start, link_end: group_link_end } - else = _('Ask your group owner to set up a group runner.') diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index 359e34d8918..7ecc8004334 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -37,7 +37,7 @@ token: @resource_access_token, scopes: @scopes, access_levels: ProjectMember.permissible_access_level_roles(current_user, @project), - default_access_level: Gitlab::Access::MAINTAINER, + default_access_level: Gitlab::Access::GUEST, prefix: :resource_access_token, help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token') diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 64f45ec89d1..ea77bda0b0f 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -20,7 +20,7 @@ %fieldset.builds-feature.js-auto-devops-settings .form-group = f.fields_for :auto_devops_attributes, @auto_devops do |form| - .card.auto-devops-card + .card.gl-mb-3 .card-body - autodevops_help_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer' - auto_devops_badge = auto_devops_enabled ? (gl_badge_tag badge_for_auto_devops_scope(@project), { variant: :info }, { class: 'js-instance-default-badge gl-ml-3 gl-mt-n1'}) : '' diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 09f9ca60b3e..dd9cc296d52 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -41,7 +41,7 @@ = expanded ? _('Collapse') : _('Expand') %p = _("Runners are processes that pick up and execute CI/CD jobs for GitLab.") - = link_to s_('How do I configure runners?'), help_page_path('ci/runners/index'), target: '_blank', rel: 'noopener noreferrer' + = link_to s_('What is GitLab Runner?'), 'https://docs.gitlab.com/runner/', target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'projects/runners/settings' diff --git a/app/views/projects/settings/integrations/edit.html.haml b/app/views/projects/settings/integrations/edit.html.haml index a250daafdbb..46276e6c6c9 100644 --- a/app/views/projects/settings/integrations/edit.html.haml +++ b/app/views/projects/settings/integrations/edit.html.haml @@ -6,4 +6,5 @@ = render 'form', integration: @integration - if @web_hook_logs - = render partial: 'projects/hook_logs/index', locals: { hook: @integration.service_hook, hook_logs: @web_hook_logs, project: @project } + %hr + = render partial: 'shared/hook_logs/index', locals: { hook: @integration.service_hook, hook_logs: @web_hook_logs, project: @project } diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index 50bfd3c6976..87e3e03099c 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -2,22 +2,18 @@ - page_title _('Monitor Settings') - breadcrumb_title _('Monitor Settings') -.gl-alert.gl-alert-danger.gl-mb-5 - - removal_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/7188' - - removal_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: removal_epic_link_url } - - opstrace_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/6976' - - opstrace_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: opstrace_link_url } - - link_end = '</a>'.html_safe - .gl-alert-container - = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-content - .gl-alert-title - = s_('Deprecations|Feature deprecation and removal') - .gl-alert-body - %p - = html_escape(s_('Deprecations|The metrics feature was deprecated in GitLab 14.7.')) - = html_escape(s_('Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0.')) % {removal_link_start: removal_epic_link_start, link_end: link_end } - = html_escape(s_('Deprecations|For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {opstrace_link_start: opstrace_link_start, link_end: link_end } += render Pajamas::AlertComponent.new(variant: :danger, + dismissible: false, + title: s_('Deprecations|Feature deprecation and removal')) do |c| + = c.body do + - removal_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/7188' + - removal_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: removal_epic_link_url } + - opstrace_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/6976' + - opstrace_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: opstrace_link_url } + - link_end = '</a>'.html_safe + = html_escape(s_('Deprecations|The metrics feature was deprecated in GitLab 14.7.')) + = html_escape(s_('Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0.')) % {removal_link_start: removal_epic_link_start, link_end: link_end } + = html_escape(s_('Deprecations|For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {opstrace_link_start: opstrace_link_start, link_end: link_end } = render 'projects/settings/operations/metrics_dashboard' = render 'projects/settings/operations/error_tracking' diff --git a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml new file mode 100644 index 00000000000..795544b75a2 --- /dev/null +++ b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml @@ -0,0 +1,6 @@ +- add_to_breadcrumbs _('Packages & Registries'), project_settings_packages_and_registries_path(@project) +- breadcrumb_title s_('ContainerRegistry|Clean up image tags') +- page_title s_('ContainerRegistry|Clean up image tags'), _('Packages & Registries') +- @content_class = 'limit-container-width' unless fluid_layout + +#js-registry-settings-cleanup-image-tags{ data: cleanup_settings_data } diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml index 1a7821d3268..d579981ebc0 100644 --- a/app/views/projects/settings/packages_and_registries/show.html.haml +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -2,16 +2,4 @@ - page_title _('Packages & Registries') - @content_class = 'limit-container-width' unless fluid_layout -#js-registry-settings{ data: { project_id: @project.id, - project_path: @project.full_path, - cadence_options: cadence_options.to_json, - keep_n_options: keep_n_options.to_json, - older_than_options: older_than_options.to_json, - is_admin: current_user&.admin.to_s, - show_container_registry_settings: show_container_registry_settings(@project).to_s, - show_package_registry_settings: show_package_registry_settings(@project).to_s, - admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), - enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s, - help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'), - show_cleanup_policy_link: show_cleanup_policy_link(@project).to_s, - tags_regex_help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'regex-pattern-examples') } } +#js-registry-settings{ data: settings_data } diff --git a/app/views/projects/tags/_edit_release_button.html.haml b/app/views/projects/tags/_edit_release_button.html.haml index 5bdf1c7896c..1c2626e5612 100644 --- a/app/views/projects/tags/_edit_release_button.html.haml +++ b/app/views/projects/tags/_edit_release_button.html.haml @@ -1,11 +1,9 @@ -- if Feature.enabled?(:edit_tag_release_notes_via_release_page, project) - - release_btn_text = s_('TagsPage|Create release') - - release_btn_path = new_project_release_path(project, tag_name: tag.name) - - if release - - release_btn_text = s_('TagsPage|Edit release') - - release_btn_path = edit_project_release_path(project, release) - = link_to release_btn_path, class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: release_btn_text, data: { container: "body" } do - = sprite_icon('pencil', css_class: 'gl-icon') -- else - = link_to edit_project_tag_release_path(project, tag.name), class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do - = sprite_icon('pencil', css_class: 'gl-icon') +- release_btn_text = s_('TagsPage|Create release') +- release_btn_path = new_project_release_path(project, tag_name: tag.name) +- option_css_classes = local_assigns.fetch(:option_css_classes, '') +- css_classes = "btn gl-button btn-default btn-icon btn-edit has-tooltip #{option_css_classes}" +- if release + - release_btn_text = s_('TagsPage|Edit release') + - release_btn_path = edit_project_release_path(project, release) += link_to release_btn_path, class: css_classes, title: release_btn_text, data: { container: "body" } do + = sprite_icon('pencil', css_class: 'gl-icon') diff --git a/app/views/projects/tags/_release_link.html.haml b/app/views/projects/tags/_release_link.html.haml new file mode 100644 index 00000000000..c942d122a58 --- /dev/null +++ b/app/views/projects/tags/_release_link.html.haml @@ -0,0 +1,4 @@ +.gl-text-secondary + = sprite_icon("rocket", size: 12) + = _("Release") + = link_to release.name, project_release_path(project, release), class: "gl-text-blue-600!" diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 258f662420b..fcad8509a7d 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -18,10 +18,7 @@ = s_("TagsPage|Can't find HEAD commit for this tag") - if release - .text-secondary - = sprite_icon("rocket", size: 12) - = _("Release") - = link_to release.name, project_release_path(@project, release), class: 'gl-text-blue-600!' + = render 'release_link', project: @project, release: release - if tag.message.present? %pre.wrap @@ -40,5 +37,5 @@ = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name] - if can?(current_user, :admin_tag, @project) - = render 'edit_release_button', tag: tag, project: @project, release: release + = render 'edit_release_button', tag: tag, project: @project, release: release, option_css_classes: 'gl-mr-3!' = render 'projects/buttons/remove_tag', project: @project, tag: tag diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml deleted file mode 100644 index c99f146ea7a..00000000000 --- a/app/views/projects/tags/releases/edit.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -- add_to_breadcrumbs _("Tags"), project_tags_path(@project) -- breadcrumb_title @tag.name -- page_title _("Edit"), @tag.name, _("Tags") - -.sub-header-block.no-bottom-space - .oneline - .title - Release notes for tag - %strong= @tag.name - -= form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name), - html: { class: 'common-note-form release-form js-quick-submit' }) do |f| - = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…" - = render 'shared/notes/hints' - .error-alert - .gl-mt-5.gl-display-flex - = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mr-3' - = link_to _('Cancel'), project_tag_path(@project, @tag.name), class: "btn gl-button btn-default btn-cancel" diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 24da8e2db87..cb7751ecf2e 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -37,20 +37,21 @@ - else = s_("TagsPage|Can't find HEAD commit for this tag") + - if @release + = render 'release_link', project: @project, release: @release + .nav-controls - if @tag.has_signature? = render partial: 'projects/commit/signature', object: @tag.signature - if can?(current_user, :admin_tag, @project) = render 'edit_release_button', tag: @tag, project: @project, release: @release - = link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Browse files') do + = link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default has-tooltip', title: s_('TagsPage|Browse files') do = sprite_icon('folder-open', css_class: 'gl-icon') - = link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Browse commits') do + = link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default has-tooltip', title: s_('TagsPage|Browse commits') do = sprite_icon('history', css_class: 'gl-icon') - .controls-item - = render 'projects/buttons/download', project: @project, ref: @tag.name + = render 'projects/buttons/download', project: @project, ref: @tag.name - if can?(current_user, :admin_tag, @project) - .btn-container.controls-item-full - = render 'projects/buttons/remove_tag', project: @project, tag: @tag + = render 'projects/buttons/remove_tag', project: @project, tag: @tag - if @tag.message.present? %pre.wrap{ data: { qa_selector: 'tag_message_content' } } diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index 8b3d0ef17a4..0c53ed48210 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -1,4 +1,4 @@ -.row.gl-mt-3.gl-mb-3.triggers-container +.row.gl-mt-3.gl-mb-3 .col-lg-12 .card .card-header diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index ce036606a1c..bce7dc8a94b 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -6,7 +6,7 @@ - else %span= trigger.short_token - .label-container + .gl-display-inline-block.gl-ml-3 - unless trigger.can_access_project? = gl_badge_tag s_('Trigger|invalid'), { variant: :danger }, { title: s_('Trigger|Trigger user has insufficient permissions to project'), data: { toggle: 'tooltip', container: 'body' } } @@ -27,7 +27,7 @@ - else Never - %td.text-right.trigger-actions + %td.text-right.gl-white-space-nowrap - revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?" - if can?(current_user, :admin_trigger, trigger) = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "gl-button btn btn-default btn-icon" do diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index d5d3cd753f3..168f4ca10bc 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -3,7 +3,7 @@ = render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty? .results.gl-md-display-flex.gl-mt-3 - - if %w(issues merge_requests).include?(@scope) + - if %w[issues merge_requests].include?(@scope) #js-search-sidebar{ class: search_bar_classes } .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden - if @timeout diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index 1f37e33a037..ac7d56520f7 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -1,5 +1,5 @@ - if show_auto_devops_implicitly_enabled_banner?(project, current_user) - = render Pajamas::AlertComponent.new(alert_options: { class: 'qa-auto-devops-banner auto-devops-implicitly-enabled-banner' }, + = render Pajamas::AlertComponent.new(alert_options: { class: 'auto-devops-implicitly-enabled-banner', data: { qa_selector: 'auto_devops_banner_content' } }, close_button_options: { class: 'hide-auto-devops-implicitly-enabled-banner', data: { project_id: project.id }}) do |c| = c.body do diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml index f7794677dc1..a202add339f 100644 --- a/app/views/shared/_broadcast_message.html.haml +++ b/app/views/shared/_broadcast_message.html.haml @@ -21,7 +21,7 @@ - else - notification_class = "js-broadcast-notification-#{message.id}" - notification_class << ' preview' if preview - .broadcast-message.broadcast-notification-message.mt-2{ role: "alert", class: notification_class } + .broadcast-message.broadcast-notification-message.mt-2{ role: "alert", class: notification_class, data: { qa_selector: 'broadcast_notification_container' } } = sprite_icon(icon_name, css_class: 'vertical-align-text-top') - if message.message.present? = render_broadcast_message(message) @@ -31,5 +31,5 @@ = render Pajamas::ButtonComponent.new(variant: :link, icon: 'close', size: :small, - button_options: { class: 'js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601 } }, + button_options: { class: 'js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601, qa_selector: 'close_button' } }, icon_classes: 'gl-mx-3! gl-text-gray-700') diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 80b50f7a3de..6b502ee928e 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -5,7 +5,7 @@ %span.js-clone-dropdown-label = enabled_protocol_button(container, enabled_protocol) - else - %a#clone-dropdown.input-group-text.gl-button.btn.btn-default.btn-icon.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } + %a#clone-dropdown.input-group-text.gl-button.btn.btn-default.btn-icon.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } } %span.js-clone-dropdown-label = default_clone_protocol.upcase = sprite_icon('chevron-down', css_class: 'gl-icon') diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml index f8ac3832a77..23a17c07ea8 100644 --- a/app/views/shared/_file_highlight.html.haml +++ b/app/views/shared/_file_highlight.html.haml @@ -1,13 +1,17 @@ #blob-content.file-content.code.js-syntax-highlight - offset = defined?(first_line_number) ? first_line_number : 1 - .line-numbers + .line-numbers{ class: "gl-p-0\!" } - if blob.data.present? - link = blob_link if defined?(blob_link) + - blame_link = project_blame_path(@project, tree_join(@ref, blob.path)) - blob.data.each_line.each_with_index do |_, index| - i = index + offset -# We're not using `link_to` because it is too slow once we get to thousands of lines. - %a.file-line-num.diff-line-num{ href: "#{link}#L#{i}", id: "L#{i}", 'data-line-number' => i } - = i + .line-links.diff-line-num + - if Feature.enabled?(:file_line_blame) + %a.file-line-blame{ href: "#{blame_link}#L#{i}" } + %a.file-line-num{ href: "#{link}#L#{i}", id: "L#{i}", 'data-line-number' => i } + = i - highlight = defined?(highlight_line) && highlight_line ? highlight_line - offset : nil .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } } %pre.code.highlight diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml deleted file mode 100644 index db5e055a1c4..00000000000 --- a/app/views/shared/_group_form.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- parent = @group.parent -- group_path = root_url -- group_path << parent.full_path + '/' if parent - - -= render 'shared/groups/group_name_and_path_fields', f: f diff --git a/app/views/shared/_help_dropdown_forum_link.html.haml b/app/views/shared/_help_dropdown_forum_link.html.haml index f3c69a7c897..06889428e82 100644 --- a/app/views/shared/_help_dropdown_forum_link.html.haml +++ b/app/views/shared/_help_dropdown_forum_link.html.haml @@ -1,2 +1,2 @@ -= link_to _("Community forum"), "https://forum.gitlab.com/", target: '_blank', class: 'text-nowrap', += link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', rel: 'noopener noreferrer', data: { 'track_action': 'click_forum', 'track_property': 'question_menu' } diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index 252f9c26f06..c351ea29c7c 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -3,11 +3,11 @@ - show_label_issues_link = subject_or_group_defined && show_label_issuables_link?(label, :issues) - show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests) -.label-name.gl-flex-shrink-0.gl-mt-2.gl-mr-3 +.label-name.gl-flex-shrink-0.gl-mt-2.gl-mr-5 = render_label(label, tooltip: false) .label-description.gl-overflow-hidden.gl-w-full .gl-display-flex.gl-align-items-stretch.gl-flex-wrap.gl-mt-2 - .gl-flex-basis-half.gl-flex-grow-1.gl-overflow-hidden.gl-mr-2 + .gl-flex-basis-half.gl-flex-grow-1.gl-overflow-hidden.gl-mr-5 - if label.description.present? = markdown_field(label, :description) - elsif show_labels_full_path?(@project, @group) diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml index 821f1ede422..0bd5d1795d0 100644 --- a/app/views/shared/_new_project_item_select.html.haml +++ b/app/views/shared/_new_project_item_select.html.haml @@ -1,6 +1,6 @@ - if any_projects?(@projects) .dropdown.b-dropdown.gl-new-dropdown.btn-group.project-item-select-holder{ class: 'gl-display-inline-flex!' } - %a.btn.gl-button.btn-confirm.split-content-button.js-new-project-item-link.block-truncated.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] } } + %a.btn.gl-button.btn-confirm.split-content-button.js-new-project-item-link.block-truncated{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] } } = gl_loading_icon(inline: true, color: 'light') = project_select_tag :project_path, class: "project-item-select gl-absolute! gl-visibility-hidden", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled] - %button.btn.dropdown-toggle.btn-confirm.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button.qa-new-project-item-select-button{ 'aria-label': _('Toggle project select') } + %button.btn.dropdown-toggle.btn-confirm.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button{ 'aria-label': _('Toggle project select') } diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml index 74e0a088656..20bf2141cc3 100644 --- a/app/views/shared/_ref_switcher.html.haml +++ b/app/views/shared/_ref_switcher.html.haml @@ -13,8 +13,8 @@ - @options && @options.each do |key, value| = hidden_field_tag key, value, id: nil .dropdown - = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: ref, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: field_name, submit_form_on_click: true, visit: true }, { toggle_class: "js-project-refs-dropdown qa-branches-select" } - .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging.qa-branches-dropdown{ class: ("dropdown-menu-right" if local_assigns[:align_right]) } + = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: ref, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: field_name, submit_form_on_click: true, visit: true, qa_selector: "branches_dropdown", testid: "branches-select" }, { toggle_class: "js-project-refs-dropdown" } + .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging{ class: ("dropdown-menu-right" if local_assigns[:align_right]), data: { qa_selector: "branches_dropdown_content" } } .dropdown-page-one = dropdown_title _("Switch branch/tag") = dropdown_filter _("Search branches and tags") diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml index f3942aa5dc2..770d335a88b 100644 --- a/app/views/shared/_remote_mirror_update_button.html.haml +++ b/app/views/shared/_remote_mirror_update_button.html.haml @@ -3,5 +3,5 @@ button_options: { class: 'disabled', title: _('Updating'), data: { toggle: 'tooltip', container: 'body', qa_selector: 'updating_button' } }, icon_classes: 'spin') - elsif remote_mirror.enabled? - = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn btn-icon gl-button qa-update-now-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do + = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn btn-icon gl-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body', qa_selector: 'update_now_button' }, title: _('Update now') do = sprite_icon("retry") diff --git a/app/views/shared/_search_settings.html.haml b/app/views/shared/_search_settings.html.haml index 7265f090967..95eb421dbfe 100644 --- a/app/views/shared/_search_settings.html.haml +++ b/app/views/shared/_search_settings.html.haml @@ -4,4 +4,4 @@ %div{ class: container_class } .js-search-settings-app - %input.gl-form-input.form-control{ type: "text", placeholder: _("Search settings"), aria_label: _("Search settings"), disabled: true } + %input.gl-form-input.form-control{ type: "text", placeholder: _("Search settings"), aria: { label: _("Search settings") }, disabled: true } diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml index 0a74e47fa4c..4cdf1340d64 100644 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ b/app/views/shared/_sidebar_toggle_button.html.haml @@ -1,4 +1,4 @@ -%a.toggle-sidebar-button.js-toggle-sidebar.qa-toggle-sidebar.rspec-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } +%a.toggle-sidebar-button.js-toggle-sidebar.rspec-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } = sprite_icon('chevron-double-lg-left', size: 12, css_class: 'icon-chevron-double-lg-left') %span.collapse-text.gl-ml-3= _("Collapse sidebar") diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index 0f6fc860883..dd4d2ab46c1 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -27,7 +27,7 @@ .row .col .js-access-tokens-expires-at{ data: expires_at_field_data } - = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' } + = f.text_field :expires_at, class: 'gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' } - if resource .row @@ -45,9 +45,5 @@ = link_to _("Learn more."), help_path, target: '_blank', rel: 'noopener noreferrer' = render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes, f: f - - if prefix == :personal_access_token && Feature.enabled?(:personal_access_tokens_scoped_to_projects, current_user) - .js-access-tokens-projects - %input{ type: 'hidden', name: 'personal_access_token[projects]', id: 'personal_access_token_projects', data: { js_name: 'projects' } } - .gl-mt-3 = f.submit _('Create %{type}') % { type: type }, class: 'gl-button btn btn-confirm', data: { qa_selector: 'create_token_button' } diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml index 5ca9cf8d9a4..53c6800f93d 100644 --- a/app/views/shared/access_tokens/_table.html.haml +++ b/app/views/shared/access_tokens/_table.html.haml @@ -45,7 +45,7 @@ %span.token-never-expires-label= _('Never') - if resource %td= resource.member(token.user).human_access - %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: "gl-button btn btn-danger btn-sm float-right qa-revoke-button #{'btn-danger-secondary' unless token.expires?}", aria: { label: _('Revoke') }, data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type }, 'confirm-btn-variant': 'danger' } + %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: "gl-button btn btn-danger btn-sm float-right #{'btn-danger-secondary' unless token.expires?}", aria: { label: _('Revoke') }, data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type }, 'confirm-btn-variant': 'danger', qa_selector: 'revoke_button' } - else .settings-message.text-center = no_active_tokens_message diff --git a/app/views/shared/admin/_admin_note.html.haml b/app/views/shared/admin/_admin_note.html.haml index 82407705885..9dcf181a118 100644 --- a/app/views/shared/admin/_admin_note.html.haml +++ b/app/views/shared/admin/_admin_note.html.haml @@ -1,7 +1,7 @@ - if @group.admin_note.present? - text = @group.admin_note.note - .card.border-info - .card-header.bg-info.gl-text-white + = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-blue-500 gl-mb-5' }, header_options: { class: 'gl-bg-blue-500 gl-text-white' }) do |c| + - c.header do = s_('Admin|Admin notes') - .card-body + - c.body do %p= text diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml index 60641006e96..4db1d20e81b 100644 --- a/app/views/shared/blob/_markdown_buttons.html.haml +++ b/app/views/shared/blob/_markdown_buttons.html.haml @@ -22,11 +22,15 @@ = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") }) = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") }) - = markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a task list") }) + = markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a checklist") }) = markdown_toolbar_button({ icon: "details-block", data: { "md-tag" => "<details><summary>Click to expand</summary>\n{text}\n</details>", "md-prepend" => true, "md-select" => "Click to expand" }, title: _("Add a collapsible section") }) = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: _("Add a table") }) + = markdown_toolbar_button({ icon: "paperclip", + data: { "testid" => "button-attach-file" }, + css_class: 'js-attach-file-button markdown-selector', + title: _("Attach a file or image") }) - if show_fullscreen_button %button.gl-button.btn.btn-default-tertiary.btn-icon.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: _("Go full screen"), data: { container: "body" } } = sprite_icon("maximize") diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index 2e04bbf3605..eade973d72a 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -12,7 +12,7 @@ .form-group = f.label :expires_at, _('Expiration date (optional)'), class: 'label-bold' - = f.text_field :expires_at, class: 'datepicker form-control', data: { qa_selector: 'deploy_token_expires_at_field' }, value: f.object.expires_at + = f.gitlab_ui_datepicker :expires_at, data: { qa_selector: 'deploy_token_expires_at_field' }, value: f.object.expires_at .text-secondary= s_('DeployTokens|Enter an expiration date for your token. Defaults to never expire.') .form-group diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml index 4e5e04ba4d4..e96fcd11cef 100644 --- a/app/views/shared/empty_states/_labels.html.haml +++ b/app/views/shared/empty_states/_labels.html.haml @@ -1,6 +1,6 @@ .row.empty-state.labels .col-12 - .svg-content.qa-label-svg + .svg-content{ data: { qa_selector: 'label_svg_content' } } = image_tag 'illustrations/labels.svg' .col-12 .text-content @@ -8,7 +8,7 @@ %p= _("You can also star a label to make it a priority label.") .text-center - if can?(current_user, :admin_label, @project) - = link_to _('New label'), new_project_label_path(@project), class: 'btn gl-button btn-confirm qa-label-create-new', title: _('New label'), id: 'new_label_link' + = link_to _('New label'), new_project_label_path(@project), class: 'btn gl-button btn-confirm', title: _('New label'), id: 'new_label_link' = link_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, class: 'btn gl-button btn-confirm-secondary', title: _('Generate a default set of labels'), id: 'generate_labels_link' - if can?(current_user, :admin_label, @group) = link_to _('New label'), new_group_label_path(@group), class: 'btn gl-button btn-confirm', title: _('New label'), id: 'new_label_link' diff --git a/app/views/shared/empty_states/_priority_labels.html.haml b/app/views/shared/empty_states/_priority_labels.html.haml index a93f6e4c795..3381c5f0c67 100644 --- a/app/views/shared/empty_states/_priority_labels.html.haml +++ b/app/views/shared/empty_states/_priority_labels.html.haml @@ -1,5 +1,5 @@ .text-center - .svg-content.qa-label-svg + .svg-content{ data: { qa_selector: 'label_svg_content' } } = image_tag 'illustrations/priority_labels.svg' - if can?(current_user, :admin_label, @project) %p diff --git a/app/views/shared/empty_states/_topics.html.haml b/app/views/shared/empty_states/_topics.html.haml index fd82a853037..0283e852c7d 100644 --- a/app/views/shared/empty_states/_topics.html.haml +++ b/app/views/shared/empty_states/_topics.html.haml @@ -1,7 +1,7 @@ .row.empty-state .col-12 .svg-content - = image_tag 'illustrations/labels.svg', data: { qa_selector: 'svg_content' } + = image_tag 'illustrations/labels.svg' .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/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml index 552b100d5dd..8304a2f18a0 100644 --- a/app/views/shared/empty_states/_wikis.html.haml +++ b/app/views/shared/empty_states/_wikis.html.haml @@ -3,7 +3,7 @@ - if can?(current_user, :create_wiki, @wiki.container) - create_path = wiki_page_path(@wiki, params[:id], view: 'create') - - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-confirm qa-create-first-page-link', title: s_('WikiEmpty|Create your first page') + - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-confirm', title: s_('WikiEmpty|Create your first page'), data: { qa_selector: 'create_first_page_link' } = render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do %h4.text-left diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml index 3b100f832b2..0b7034838ed 100644 --- a/app/views/shared/empty_states/_wikis_layout.html.haml +++ b/app/views/shared/empty_states/_wikis_layout.html.haml @@ -1,6 +1,6 @@ .row.empty-state.empty-state-wiki .col-12 - .svg-content.qa-svg-content + .svg-content{ data: { qa_selector: 'svg_content' } } = image_tag image_path .col-12 .text-content.text-center diff --git a/app/views/shared/groups/_group_name_and_path_fields.html.haml b/app/views/shared/groups/_group_name_and_path_fields.html.haml index 634b8448535..08192cc0cc5 100644 --- a/app/views/shared/groups/_group_name_and_path_fields.html.haml +++ b/app/views/shared/groups/_group_name_and_path_fields.html.haml @@ -1,5 +1,6 @@ -.js-group-name-and-path{ data: group_name_and_path_app_data(@group) } +.js-group-name-and-path{ data: group_name_and_path_app_data.merge(new_subgroup: local_assigns[:new_subgroup].to_s) } = f.hidden_field :name, data: { js_name: 'name' } = f.hidden_field :path, maxlength: ::Namespace::URL_MAX_LENGTH, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, data: { js_name: 'path' } = f.hidden_field :parent_id, value: @group.parent&.id, data: { js_name: 'parentId' } + = f.hidden_field :parent_full_path, value: @group.parent&.full_path, data: { js_name: 'parentFullPath' } = f.hidden_field :id, data: { js_name: 'groupId' } diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml index a574394694d..2afac0ad733 100644 --- a/app/views/shared/groups/_search_form.html.haml +++ b/app/views/shared/groups/_search_form.html.haml @@ -1,2 +1,2 @@ = form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f| - = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter qa-groups-filter', spellcheck: false, id: 'group-filter-form-field' + = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter', data: { qa_selector: 'groups_filter_field' }, spellcheck: false, id: 'group-filter-form-field' diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/shared/hook_logs/_index.html.haml index 6a46b0b3510..6a46b0b3510 100644 --- a/app/views/admin/hook_logs/_index.html.haml +++ b/app/views/shared/hook_logs/_index.html.haml diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml index 112b0368a3a..5326b26d655 100644 --- a/app/views/shared/issuable/_assignees.html.haml +++ b/app/views/shared/issuable/_assignees.html.haml @@ -3,11 +3,8 @@ - render_count = assignees_rendering_overflow ? max_render - 1 : max_render - more_assignees_count = issuable.assignees.size - render_count -- if issuable.instance_of?(MergeRequest) && current_user&.mr_attention_requests_enabled? - = render 'shared/issuable/merge_request_assignees', issuable: issuable, count: render_count -- else - - issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord - = link_to_member(@project, assignee, name: false, title: s_("MrList|Assigned to %{name}") % { name: assignee.name}) +- issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord + = link_to_member(@project, assignee, name: false, title: s_("MrList|Assigned to %{name}") % { name: assignee.name}) - if more_assignees_count > 0 %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', qa_selector: 'avatar_counter_content' }, title: _("+%{more_assignees_count} more assignees") % { more_assignees_count: more_assignees_count} } diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index e90ea35f28e..ae8b266c092 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -3,7 +3,7 @@ - project = @target_project || @project - presenter = local_assigns.fetch(:presenter, nil) -= form_errors(issuable) += form_errors(issuable, pajamas_alert: true) - if @conflict = render Pajamas::AlertComponent.new(variant: :danger, @@ -57,9 +57,9 @@ .gl-mt-5{ class: (is_footer ? "footer-block" : "middle-block") } - if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = issuable.project.present.contribution_guide_path) .gl-mb-5 - Please review the - %strong= link_to('contribution guidelines', guide_url) - for this project. + - contribution_guidelines_start = '<strong><a href="%{url}">'.html_safe % {url: strip_tags(guide_url)} + - contribution_guidelines_end = '</a></strong>'.html_safe + = sanitize(html_escape(_('Please review the %{linkStart}contribution guidelines%{linkEnd} for this project.')) % { linkStart: contribution_guidelines_start, linkEnd: contribution_guidelines_end }) - if issuable.new_record? = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button', track_experiment: 'promote_mr_approvals_in_free', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index 08883bb3372..af63839d7c1 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -11,7 +11,7 @@ - dropdown_title = local_assigns.fetch(:dropdown_title, _('Filter by label')) - dropdown_data = label_dropdown_data(edit_context, labels: labels_filter_path_with_defaults(only_group_labels: edit_context.is_a?(Group)), default_label: _('Labels')) -- dropdown_data.merge!(data_options) +- dropdown_data.merge!(data_options, qa_selector: "issuable_label_dropdown") - label_name = local_assigns.fetch(:label_name, _('Labels')) - no_default_styles = local_assigns.fetch(:no_default_styles, false) - classes << 'js-extra-options' if extra_options @@ -22,7 +22,7 @@ = hidden_field_tag data_options[:field_name], use_id ? label.try(:id) : label.try(:title), id: nil .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect.qa-issuable-label{ class: classes.join(' '), type: "button", data: dropdown_data } + %button.dropdown-menu-toggle.js-label-select.js-multiselect{ class: classes.join(' '), type: "button", data: dropdown_data } - apply_is_default_styles = (selected.nil? || selected.empty?) && !no_default_styles %span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) } = multi_label_name(selected, label_name) diff --git a/app/views/shared/issuable/_merge_request_assignees.html.haml b/app/views/shared/issuable/_merge_request_assignees.html.haml deleted file mode 100644 index 6c7a2496ec6..00000000000 --- a/app/views/shared/issuable/_merge_request_assignees.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -- issuable.merge_request_assignees.take(count).each do |merge_request_assignee| # rubocop: disable CodeReuse/ActiveRecord - - assignee = merge_request_assignee.assignee - - assignee_tooltip = ( merge_request_assignee.attention_requested? ? s_("MrList|Attention requested from assignee %{name}") : s_("MrList|Assigned to %{name}") ) % { name: assignee.name} - - = link_to_member(@project, assignee, name: false, title: assignee_tooltip, extra_class: "gl-flex-direction-row-reverse") do - - if merge_request_assignee.attention_requested? - %span.gl-display-inline-flex - = sprite_icon('attention-solid-sm', css_class: 'gl-text-orange-500 icon-overlap-and-shadow') diff --git a/app/views/shared/issuable/_merge_request_reviewers.html.haml b/app/views/shared/issuable/_merge_request_reviewers.html.haml deleted file mode 100644 index 8dd74e12aff..00000000000 --- a/app/views/shared/issuable/_merge_request_reviewers.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -- issuable.merge_request_reviewers.take(count).each do |merge_request_reviewer| # rubocop: disable CodeReuse/ActiveRecord - - reviewer = merge_request_reviewer.reviewer - - reviewer_tooltip = ( merge_request_reviewer.attention_requested? ? s_("MrList|Attention requested from reviewer %{name}") : s_("MrList|Review requested from %{name}") ) % { name: reviewer.name} - - = link_to_member(@project, reviewer, name: false, title: reviewer_tooltip, extra_class: "gl-flex-direction-row-reverse") do - - if merge_request_reviewer.attention_requested? - %span.gl-display-inline-flex - = sprite_icon('attention-solid-sm', css_class: 'gl-text-orange-500 icon-overlap-and-shadow') diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml index dc713337747..ef539029272 100644 --- a/app/views/shared/issuable/_milestone_dropdown.html.haml +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -7,8 +7,8 @@ - dropdown_title = local_assigns.fetch(:dropdown_title, _('Filter by milestone')) - if selected.present? || params[:milestone_title].present? = hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id) -= dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "qa-issuable-milestone-dropdown js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "qa-issuable-dropdown-menu-milestone dropdown-menu-selectable dropdown-menu-milestone", - placeholder: _('Search milestones'), footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: _('Milestone') } }) do += dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", dropdown_qa_selector: "issuable_milestone_dropdown_content", + placeholder: _('Search milestones'), footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: _('Milestone'), qa_selector: "issuable_milestone_dropdown", testid: "issuable-milestone-dropdown" } }) do - if project %ul.dropdown-footer-list - if can? current_user, :admin_milestone, project diff --git a/app/views/shared/issuable/_reviewers.html.haml b/app/views/shared/issuable/_reviewers.html.haml index 3bf923eb946..4adb7096181 100644 --- a/app/views/shared/issuable/_reviewers.html.haml +++ b/app/views/shared/issuable/_reviewers.html.haml @@ -3,11 +3,8 @@ - render_count = reviewers_rendering_overflow ? max_render - 1 : max_render - more_reviewers_count = issuable.reviewers.size - render_count -- if issuable.instance_of?(MergeRequest) && current_user&.mr_attention_requests_enabled? - = render 'shared/issuable/merge_request_reviewers', issuable: issuable, count: render_count -- else - - issuable.reviewers.take(render_count).each do |reviewer| # rubocop: disable CodeReuse/ActiveRecord - = link_to_member(@project, reviewer, name: false, title: s_("MrList|Review requested from %{name}") % { name: reviewer.name}) +- issuable.reviewers.take(render_count).each do |reviewer| # rubocop: disable CodeReuse/ActiveRecord + = link_to_member(@project, reviewer, name: false, title: s_("MrList|Review requested from %{name}") % { name: reviewer.name}) - if more_reviewers_count > 0 %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old' }, title: _("+%{more_reviewers_count} more reviewers") % { more_reviewers_count: more_reviewers_count} } diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 6394e05ae24..21716710015 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -88,16 +88,6 @@ = render 'shared/issuable/user_dropdown_item', user: User.new(username: '{{username}}', name: '{{name}}'), avatar: { lazy: true, url: '{{avatar_url}}' } - - if current_user&.mr_attention_requests_enabled? - #js-dropdown-attention-requested.filtered-search-input-dropdown-menu.dropdown-menu - - if current_user - %ul{ data: { dropdown: true } } - = render 'shared/issuable/user_dropdown_item', - user: current_user - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - = render 'shared/issuable/user_dropdown_item', - user: User.new(username: '{{username}}', name: '{{name}}'), - avatar: { lazy: true, url: '{{avatar_url}}' } = render_if_exists 'shared/issuable/approver_dropdown' = render_if_exists 'shared/issuable/approved_by_dropdown' #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 55f5dce8b37..6da094924a0 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -26,11 +26,14 @@ = _('To-Do') .js-issuable-todo{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } } - .block.assignee.qa-assignee-block{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}" } + .block.assignee{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { qa_selector: 'assignee_block_container' } } = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in + - if issuable_sidebar[:supports_severity] + #js-severity + - if reviewers - .block.reviewer.qa-reviewer-block + .block.reviewer = render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers, signed_in: signed_in - if issuable_sidebar[:supports_escalation] @@ -67,9 +70,6 @@ = _('Time tracking') = gl_loading_icon(inline: true) - - if issuable_sidebar[:supports_severity] - #js-severity - - if issuable_sidebar.dig(:features_available, :health_status) .js-sidebar-status-entry-point{ data: sidebar_status_data(issuable_sidebar, @project) } diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml index ce252e74570..cd976b88304 100644 --- a/app/views/shared/issuable/_sidebar_reviewers.html.haml +++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml @@ -36,7 +36,7 @@ - data[:multi_select] = true - data['dropdown-title'] = title - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] - - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select'] + - data['max-select'] = dropdown_max_select(dropdown_options[:data]) - options[:data].merge!(data) = render 'shared/issuable/sidebar_user_dropdown', diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 61cc408f6b3..76469b34832 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -35,7 +35,7 @@ = form.label :milestone_id, _('Milestone'), class: "col-12" .col-12 .issuable-form-select-holder - = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: _('Select milestone') + = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: _('Select milestone') .form-group.row = form.label :label_ids, _('Labels'), class: "col-12" @@ -53,4 +53,4 @@ = form.label :due_date, _('Due date'), class: "col-12" .col-12 .issuable-form-select-holder - = form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: _('Select due date'), autocomplete: 'off' + = form.gitlab_ui_datepicker :due_date, placeholder: _('Select due date'), autocomplete: 'off', id: "issuable-due-date" diff --git a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml index f9c3c11eed8..efecffbcc2e 100644 --- a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml @@ -8,4 +8,4 @@ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } = dropdown_tag(users_dropdown_label(issuable.assignees), options: assignees_dropdown_options(issuable.to_ability_name)) - = link_to _('Assign to me'), '#', class: "assign-to-me-link gl-white-space-nowrap gl-pl-4 qa-assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" + = link_to _('Assign to me'), '#', class: "assign-to-me-link gl-white-space-nowrap gl-pl-4 #{'hide' if issuable.assignees.include?(current_user)}", data: { qa_selector: 'assign_to_me_link' } diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index e7c0833de0f..51f49c7ca8e 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -9,7 +9,7 @@ %div{ data: { testid: 'issue-title-input-field' } } = form.text_field :title, required: true, aria: { required: true }, maxlength: 255, autofocus: true, - autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', dir: 'auto' + autocomplete: 'off', class: 'form-control pad', dir: 'auto', data: { qa_selector: 'issuable_form_title_field' } - if issuable.respond_to?(:draft?) .form-text.text-muted diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml index 39e7d196965..369aa53586f 100644 --- a/app/views/shared/issue_type/_details_content.html.haml +++ b/app/views/shared/issue_type/_details_content.html.haml @@ -19,7 +19,7 @@ = render_if_exists 'projects/issues/work_item_links' = render_if_exists 'projects/issues/linked_resources' - = render_if_exists 'projects/issues/related_issues' + = render 'projects/issues/related_issues' #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), project_namespace: @project.namespace.path, project_path: @project.path } } diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index f768b63afff..cf8bd23b153 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -1,23 +1,23 @@ = form_for @label, as: :label, url: url, html: { class: 'label-form js-quick-submit js-requires-input' } do |f| - = form_errors(@label) + = form_errors(@label, pajamas_alert: true) .form-group.row .col-12 = f.label :title - = f.text_field :title, class: "gl-form-input form-control js-label-title qa-label-title", required: true, autofocus: true + = f.text_field :title, class: "gl-form-input form-control js-label-title", required: true, autofocus: true, data: { qa_selector: 'label_title_field' } = render_if_exists 'shared/labels/create_label_help_text' .form-group.row .col-12 = f.label :description - = f.text_field :description, class: "gl-form-input form-control js-quick-submit qa-label-description" + = f.text_field :description, class: "gl-form-input form-control js-quick-submit", data: { qa_selector: 'label_description_field' } .form-group.row .col-12 = f.label :color, _("Background color") .input-group .input-group-prepend .input-group-text.label-color-preview - = f.text_field :color, class: "gl-form-input form-control qa-label-color" + = f.text_field :color, class: "gl-form-input form-control", data: { qa_selector: 'label_color_field' } .form-text.text-muted = _('Choose any color.') %br @@ -28,7 +28,7 @@ - if @label.persisted? = f.submit _('Save changes'), class: 'btn gl-button btn-confirm js-save-button gl-mr-2' - else - = f.submit _('Create label'), class: 'btn gl-button btn-confirm js-save-button qa-label-create-button gl-mr-2' + = f.submit _('Create label'), class: 'btn gl-button btn-confirm js-save-button gl-mr-2', data: { qa_selector: 'label_create_button' } = link_to _('Cancel'), back_path, class: 'btn gl-button btn-default btn-cancel gl-mr-2' - if @label.persisted? - presented_label = @label.present diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml index 622ad9db425..c82a22c73b8 100644 --- a/app/views/shared/labels/_nav.html.haml +++ b/app/views/shared/labels/_nav.html.haml @@ -14,8 +14,8 @@ = render Pajamas::ButtonComponent.new(icon: 'search', button_options: { type: "submit", "aria-label" => _('Submit search') }) = render 'shared/labels/sort_dropdown' - if labels_or_filters && can_admin_label && @project - = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_project_label_path(@project), button_options: { class: 'qa-label-create-new' }) do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_project_label_path(@project), button_options: { data: { qa_selector: 'create_new_label_button' } }) do = _('New label') - if labels_or_filters && can_admin_label && @group - = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_group_label_path(@group), button_options: { class: 'qa-label-create-new' }) do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_group_label_path(@group), button_options: { data: { qa_selector: 'create_new_label_button' } }) do = _('New label') diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index ec08dde37bf..98e2c6c43b1 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -5,15 +5,16 @@ - return if requesters.empty? -.card.gl-mt-3{ data: { testid: 'access-requests' } } - .card-header - = _("Users requesting access to") += render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-3 gl-mb-5', data: { testid: 'access-requests' } }, body_options: { class: 'gl-p-0' }) do |c| + - c.header do + = _('Users requesting access to') %strong= membership_source.name = gl_badge_tag requesters.size = render 'shared/members/manage_access_button', path: membership_source.is_a?(Project) ? project_project_members_path(@project, tab: 'access_requests') : group_group_members_path(@group, tab: 'access_requests') - %ul.content-list.members-list - = render partial: 'shared/members/member', - collection: requesters, as: :member, - locals: { membership_source: membership_source, + - c.body do + %ul.content-list.members-list + = render partial: 'shared/members/member', + collection: requesters, as: :member, + locals: { membership_source: membership_source, group: group, current_user_is_group_owner: current_user_is_group_owner } diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml index 7a41e381a96..50e3e8e195c 100644 --- a/app/views/shared/milestones/_form_dates.html.haml +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -2,10 +2,10 @@ .col-form-label.col-sm-2 = f.label :start_date, _('Start Date') .col-sm-4 - = f.text_field :start_date, class: "datepicker form-control gl-form-input", data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off' + = f.gitlab_ui_datepicker :start_date, data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off' %a.inline.float-right.gl-mt-2.js-clear-start-date{ href: "#" }= _('Clear start date') .col-form-label.col-sm-2 = f.label :due_date, _('Due Date') .col-sm-4 - = f.text_field :due_date, class: "datepicker form-control gl-form-input", data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off' + = f.gitlab_ui_datepicker :due_date, data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off' %a.inline.float-right.gl-mt-2.js-clear-due-date{ href: "#" }= _('Clear due date') diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml index 18db556e024..334785685d5 100644 --- a/app/views/shared/milestones/_header.html.haml +++ b/app/views/shared/milestones/_header.html.haml @@ -20,10 +20,10 @@ #promote-milestone-modal - if milestone.active? - = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), button_options: { class: 'btn-grouped btn-close', data: { method: 'put' }, rel: 'nofollow' }) do + = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), method: :put, button_options: { class: 'btn-grouped btn-close' }) do = _('Close milestone') - else - = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), button_options: { class: 'btn-grouped', data: { method: 'put' }, rel: 'nofollow' }) do + = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), method: :put, button_options: { class: 'btn-grouped' }) do = _('Reopen milestone') = render 'shared/milestones/delete_button' diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index c845d4df7df..44740db5a00 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -11,7 +11,7 @@ - if supports_file_upload %span.uploading-container %span.uploading-progress-container.hide - = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom') + = sprite_icon('paperclip', 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% @@ -19,7 +19,7 @@ %span.uploading-error-container.hide %span.uploading-error-icon - = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom') + = sprite_icon('paperclip', 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 @@ -31,11 +31,6 @@ = _("attach a new file") = _(".") - %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/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index e96a9152c80..51a5c9dd38f 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -1,7 +1,7 @@ - form_field_classes = local_assigns[:admin_view] || !Feature.enabled?(:project_list_filter_bar) ? 'input-short js-projects-list-filter' : '' - placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : 'Filter by name...' -= form_tag filter_projects_path, method: :get, class: 'project-filter-form qa-project-filter-form', id: 'project-filter-form' do |f| += form_tag filter_projects_path, method: :get, class: 'project-filter-form', data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f| = search_field_tag :name, params[:name], placeholder: placeholder, class: "project-filter-form-field form-control #{form_field_classes}", diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml index e3895663033..be513af4e3f 100644 --- a/app/views/shared/projects/_topics.html.haml +++ b/app/views/shared/projects/_topics.html.haml @@ -3,16 +3,16 @@ - if project.topics.present? = cache_if(cache_enabled, [project, :topic_list], expires_in: 1.day) do - %span.gl-w-full.gl-display-inline-flex.gl-font-base.gl-font-weight-normal.gl-align-items-center{ 'data-testid': 'project_topic_list' } - = sprite_icon('tag', css_class: 'icon gl-relative gl-mr-2') - + .gl-w-full.gl-display-inline-flex.gl-flex-wrap.gl-font-base.gl-font-weight-normal.gl-align-items-center.gl-mx-n2.gl-my-n2{ 'data-testid': 'project_topic_list' } + %span.gl-p-2.gl-text-gray-500 + = _('Topics') + ':' - project.topics_to_show.each do |topic| - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name]) - if topic[:title].length > max_project_topic_length - %a.gl-mr-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' } + %a.gl-p-2.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' } = gl_badge_tag truncate(topic[:title], length: max_project_topic_length) - else - %a.gl-mr-3{ href: explore_project_topic_path, itemprop: 'keywords' } + %a.gl-p-2{ href: explore_project_topic_path, itemprop: 'keywords' } = gl_badge_tag topic[:title] - if project.has_extra_topics? @@ -27,5 +27,5 @@ - else %a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' } = gl_badge_tag topic[:title] - .text-nowrap{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } + .text-nowrap.gl-p-2{ role: 'button', tabindex: 0, data: { toggle: 'popover', triggers: 'focus hover', html: 'true', placement: 'top', title: title, content: content } } = _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown } diff --git a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml index 32b9044c551..d10196a83cc 100644 --- a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml @@ -10,7 +10,7 @@ %td.merge_access_levels-container = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", merge_access_levels.first&.access_level = dropdown_tag( (merge_access_levels.first&.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge qa-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', data: { field_name: "allowed_to_merge_#{protected_branch.id}", preselected_items: access_levels_data(merge_access_levels) }}) - if user_merge_access_levels.any? %p.small diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 361beda4d02..25070138128 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -4,6 +4,7 @@ - page_title user_display_name(@user) - page_description @user.bio unless @user.blocked? || !@user.confirmed? - page_itemtype 'http://schema.org/Person' +- add_page_specific_style 'page_bundles/profile' - link_classes = "flex-grow-1 mx-1 " = content_for :meta_tags do @@ -42,16 +43,18 @@ = sprite_icon('user') - if current_user && current_user.id != @user.id - if current_user.following?(@user) - = link_to user_unfollow_path(@user, :json) , class: link_classes + 'btn gl-button btn-default', method: :post do - = _('Unfollow') + = form_tag user_unfollow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do + = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do + = _('Unfollow') - else - = link_to user_follow_path(@user, :json) , class: link_classes + 'btn gl-button btn-confirm', method: :post, data: { qa_selector: 'follow_user_link' } do - = _('Follow') + = form_tag user_follow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do + = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do + = _('Follow') .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] } .avatar-holder = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do - = image_tag avatar_icon_for_user(@user, 90, current_user: current_user), class: "avatar s90", alt: '', itemprop: 'image' + = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" }) - if @user.blocked? || !@user.confirmed? .user-info @@ -65,14 +68,14 @@ - if @user.pronouns.present? %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle = "(#{@user.pronouns})" - - if @user&.status && user_status_set_to_busy?(@user.status) + - if @user.status&.busy? %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)") - if @user.pronunciation.present? .gl-align-items-center %p.gl-mb-4.gl-text-gray-500= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation } - - if show_status_emoji?(@user.status) + - if @user.status&.customized? .cover-status.gl-display-inline-flex.gl-align-items-center = emoji_icon(@user.status.emoji, class: 'gl-mr-2') = markdown_field(@user.status, :message) diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 966a1202db2..8bba5e36b52 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1551,15 +1551,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: pipeline_background:archive_trace - :worker_name: ArchiveTraceWorker - :feature_category: :continuous_integration - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] - :name: pipeline_background:ci_archive_trace :worker_name: Ci::ArchiveTraceWorker :feature_category: :continuous_integration @@ -1650,6 +1641,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: pipeline_background:ci_track_failed_build + :worker_name: Ci::TrackFailedBuildWorker + :feature_category: :static_application_security_testing + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :cpu + :weight: 1 + :idempotent: true + :tags: [] - :name: pipeline_creation:ci_external_pull_requests_create_pipeline :worker_name: Ci::ExternalPullRequests::CreatePipelineWorker :feature_category: :continuous_integration @@ -1776,15 +1776,6 @@ :weight: 2 :idempotent: false :tags: [] -- :name: pipeline_processing:build_finished - :worker_name: BuildFinishedWorker - :feature_category: :continuous_integration - :has_external_dependencies: false - :urgency: :high - :resource_boundary: :cpu - :weight: 5 - :idempotent: false - :tags: [] - :name: pipeline_processing:build_queue :worker_name: BuildQueueWorker :feature_category: :continuous_integration @@ -2109,6 +2100,15 @@ :weight: 2 :idempotent: false :tags: [] +- :name: ci_cancel_pipeline + :worker_name: Ci::CancelPipelineWorker + :feature_category: :continuous_integration + :has_external_dependencies: false + :urgency: :high + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: ci_delete_objects :worker_name: Ci::DeleteObjectsWorker :feature_category: :continuous_integration @@ -2127,6 +2127,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: ci_runners_process_runner_version_update + :worker_name: Ci::Runners::ProcessRunnerVersionUpdateWorker + :feature_category: :runner_fleet + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: create_commit_signature :worker_name: CreateCommitSignatureWorker :feature_category: :source_code_management @@ -2252,8 +2261,7 @@ :resource_boundary: :unknown :weight: 2 :idempotent: false - :tags: - - :needs_own_queue + :tags: [] - :name: emails_on_push :worker_name: EmailsOnPushWorker :feature_category: :source_code_management @@ -2551,6 +2559,24 @@ :weight: 1 :idempotent: true :tags: [] +- :name: merge_requests_create_approval_event + :worker_name: MergeRequests::CreateApprovalEventWorker + :feature_category: :code_review + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: merge_requests_create_approval_note + :worker_name: MergeRequests::CreateApprovalNoteWorker + :feature_category: :code_review + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: merge_requests_delete_source_branch :worker_name: MergeRequests::DeleteSourceBranchWorker :feature_category: :source_code_management @@ -2560,6 +2586,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: merge_requests_execute_approval_hooks + :worker_name: MergeRequests::ExecuteApprovalHooksWorker + :feature_category: :code_review + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: merge_requests_handle_assignees_change :worker_name: MergeRequests::HandleAssigneesChangeWorker :feature_category: :code_review @@ -2578,6 +2613,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: merge_requests_resolve_todos_after_approval + :worker_name: MergeRequests::ResolveTodosAfterApprovalWorker + :feature_category: :code_review + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: merge_requests_update_head_pipeline :worker_name: MergeRequests::UpdateHeadPipelineWorker :feature_category: :code_review @@ -2812,6 +2856,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: projects_import_export_relation_export + :worker_name: Projects::ImportExport::RelationExportWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :memory + :weight: 1 + :idempotent: true + :tags: [] - :name: projects_inactive_projects_deletion_notification :worker_name: Projects::InactiveProjectsDeletionNotificationWorker :feature_category: :compliance_management @@ -3018,8 +3071,7 @@ :resource_boundary: :unknown :weight: 2 :idempotent: false - :tags: - - :needs_own_queue + :tags: [] - :name: snippets_schedule_bulk_repository_shard_moves :worker_name: Snippets::ScheduleBulkRepositoryShardMovesWorker :feature_category: :gitaly diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb deleted file mode 100644 index ecde05f94dc..00000000000 --- a/app/workers/archive_trace_worker.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class ArchiveTraceWorker < ::Ci::ArchiveTraceWorker # rubocop:disable Scalability/IdempotentWorker - # DEPRECATED: Not triggered since https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64934/ -end diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb deleted file mode 100644 index 0d41f7b9438..00000000000 --- a/app/workers/build_finished_worker.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class BuildFinishedWorker < ::Ci::BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker - # DEPRECATED: Not triggered since https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64934/ - - # We need to explicitly specify these settings. They aren't inheriting from the parent class. - urgency :high - worker_resource_boundary :cpu -end diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index 5c08344bfe3..2c62aed72d6 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -13,9 +13,9 @@ class BuildHooksWorker # rubocop:disable Scalability/IdempotentWorker # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) - Ci::Build.includes({ runner: :tags }) - .find_by_id(build_id) - .try(:execute_hooks) + build = Ci::Build.find_by_id(build_id) + + build.execute_hooks if build end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb index 25c7637a79f..36a50735fed 100644 --- a/app/workers/ci/build_finished_worker.rb +++ b/app/workers/ci/build_finished_worker.rb @@ -36,8 +36,7 @@ module Ci build.update_coverage Ci::BuildReportResultService.new.execute(build) - # We execute these async as these are independent operations. - BuildHooksWorker.perform_async(build) + build.feature_flagged_execute_hooks ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat? build.track_deployment_usage build.track_verify_usage diff --git a/app/workers/ci/cancel_pipeline_worker.rb b/app/workers/ci/cancel_pipeline_worker.rb new file mode 100644 index 00000000000..147839a0625 --- /dev/null +++ b/app/workers/ci/cancel_pipeline_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ci + class CancelPipelineWorker + include ApplicationWorker + + # lots of updates to ci_builds + data_consistency :always + feature_category :continuous_integration + idempotent! + deduplicate :until_executed + urgency :high + + def perform(pipeline_id, auto_canceled_by_pipeline_id) + ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| + pipeline.cancel_running( + # cascade_to_children is false because we iterate through children + # we also cancel bridges prior to prevent more children + cascade_to_children: false, + auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id + ) + end + end + end +end diff --git a/app/workers/ci/runners/process_runner_version_update_worker.rb b/app/workers/ci/runners/process_runner_version_update_worker.rb new file mode 100644 index 00000000000..f1ad0c8563e --- /dev/null +++ b/app/workers/ci/runners/process_runner_version_update_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ci + module Runners + class ProcessRunnerVersionUpdateWorker + include ApplicationWorker + + data_consistency :always + + feature_category :runner_fleet + urgency :low + + idempotent! + deduplicate :until_executing + + def perform(version) + result = ::Ci::Runners::ProcessRunnerVersionUpdateService.new(version).execute + + result.to_h.slice(:status, :message, :upgrade_status).each do |key, value| + log_extra_metadata_on_done(key, value) + end + end + end + end +end diff --git a/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb b/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb index 035b2563e56..69ab477c80a 100644 --- a/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb +++ b/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb @@ -12,11 +12,25 @@ module Ci feature_category :runner_fleet urgency :low + deduplicate :until_executed idempotent! - def perform + def perform(cronjob_scheduled = true) + if cronjob_scheduled + # Introduce some randomness across the day so that instances don't all hit the GitLab Releases API + # around the same time of day + period = rand(0..12.hours.in_seconds) + self.class.perform_in(period, false) + + Sidekiq.logger.info( + class: self.class.name, + message: "rescheduled job for #{period.seconds.from_now}") + + return + end + result = ::Ci::Runners::ReconcileExistingRunnerVersionsService.new.execute - result.each { |key, value| log_extra_metadata_on_done(key, value) } + result.payload.each { |key, value| log_extra_metadata_on_done(key, value) } end end end diff --git a/app/workers/ci/track_failed_build_worker.rb b/app/workers/ci/track_failed_build_worker.rb new file mode 100644 index 00000000000..2ad948876ac --- /dev/null +++ b/app/workers/ci/track_failed_build_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Worker for tracking exit codes of failed CI jobs +module Ci + class TrackFailedBuildWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include PipelineBackgroundQueue + + feature_category :static_application_security_testing + + urgency :low + data_consistency :sticky + worker_resource_boundary :cpu + idempotent! + worker_has_external_dependencies! + + def perform(build_id, exit_code, failure_reason) + ::Ci::Build.find_by_id(build_id).try do |build| + ::Ci::TrackFailedBuildService.new( + build: build, + exit_code: exit_code, + failure_reason: failure_reason).execute + end + end + end +end diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb index 336d60d46ac..9300c2a5790 100644 --- a/app/workers/concerns/waitable_worker.rb +++ b/app/workers/concerns/waitable_worker.rb @@ -7,7 +7,7 @@ module WaitableWorker # Schedules multiple jobs and waits for them to be completed. def bulk_perform_and_wait(args_list) # Short-circuit: it's more efficient to do small numbers of jobs inline - if args_list.size == 1 + if args_list.size == 1 && !always_async_project_authorizations_refresh? return bulk_perform_inline(args_list) end @@ -29,6 +29,10 @@ module WaitableWorker bulk_perform_async(failed) if failed.present? end + + def always_async_project_authorizations_refresh? + Feature.enabled?(:always_async_project_authorizations_refresh) + end end def perform(*args) diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index 54689df4d7b..339383476be 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -11,9 +11,6 @@ class EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker urgency :high weight 2 - # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1263 - tags :needs_own_queue - attr_accessor :raw def perform(raw) diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index d7bd8207f06..5cc9bb6954e 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -17,8 +17,8 @@ class EmailsOnPushWorker # rubocop:disable Scalability/IdempotentWorker def perform(project_id, recipients, push_data, options = {}) options.symbolize_keys! options.reverse_merge!( - send_from_committer_email: false, - disable_diffs: false + send_from_committer_email: false, + disable_diffs: false ) send_from_committer_email = options[:send_from_committer_email] disable_diffs = options[:disable_diffs] @@ -64,14 +64,14 @@ class EmailsOnPushWorker # rubocop:disable Scalability/IdempotentWorker send_email( recipient, project_id, - author_id: author_id, - ref: ref, - action: action, - compare: compare, - reverse_compare: reverse_compare, - diff_refs: diff_refs, + author_id: author_id, + ref: ref, + action: action, + compare: compare, + reverse_compare: reverse_compare, + diff_refs: diff_refs, send_from_committer_email: send_from_committer_email, - disable_diffs: disable_diffs + disable_diffs: disable_diffs ) # These are input errors and won't be corrected even if Sidekiq retries diff --git a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb index 8155b910677..0ec0a1b58d2 100644 --- a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb @@ -15,32 +15,34 @@ module Gitlab # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) - importer = ::Gitlab::GithubImport::Importer::SingleEndpointIssueEventsImporter - return skip_to_next_stage(project, importer) if feature_disabled?(project) + importer = importer_class(project) + return skip_to_next_stage(project) if importer.nil? - start_importer(project, importer, client) + info(project.id, message: "starting importer", importer: importer.name) + waiter = importer.new(project, client).execute + move_to_next_stage(project, { waiter.key => waiter.jobs_remaining }) end private - def start_importer(project, importer, client) - info(project.id, message: "starting importer", importer: importer.name) - waiter = importer.new(project, client).execute - move_to_next_stage(project, waiter.key => waiter.jobs_remaining) + def importer_class(project) + if Feature.enabled?(:github_importer_single_endpoint_issue_events_import, project.group, type: :ops) + ::Gitlab::GithubImport::Importer::SingleEndpointIssueEventsImporter + elsif Feature.enabled?(:github_importer_issue_events_import, project.group, type: :ops) + ::Gitlab::GithubImport::Importer::IssueEventsImporter + else + nil + end end - def skip_to_next_stage(project, importer) - info(project.id, message: "skipping importer", importer: importer.name) + def skip_to_next_stage(project) + info(project.id, message: "skipping importer", importer: "IssueEventsImporter") move_to_next_stage(project) end def move_to_next_stage(project, waiters = {}) AdvanceStageWorker.perform_async(project.id, waiters, :notes) end - - def feature_disabled?(project) - Feature.disabled?(:github_importer_issue_events_import, project.group, type: :ops) - end end end end diff --git a/app/workers/merge_requests/create_approval_event_worker.rb b/app/workers/merge_requests/create_approval_event_worker.rb new file mode 100644 index 00000000000..9b1a3c262e4 --- /dev/null +++ b/app/workers/merge_requests/create_approval_event_worker.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MergeRequests + class CreateApprovalEventWorker + include Gitlab::EventStore::Subscriber + + data_consistency :always + feature_category :code_review + urgency :low + idempotent! + + def handle_event(event) + current_user_id = event.data[:current_user_id] + merge_request_id = event.data[:merge_request_id] + current_user = User.find_by_id(current_user_id) + + unless current_user + logger.info(structured_payload(message: 'Current user not found.', current_user_id: current_user_id)) + return + end + + merge_request = MergeRequest.find_by_id(merge_request_id) + + unless merge_request + logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id)) + return + end + + ::MergeRequests::CreateApprovalEventService + .new(project: merge_request.project, current_user: current_user) + .execute(merge_request) + end + end +end diff --git a/app/workers/merge_requests/create_approval_note_worker.rb b/app/workers/merge_requests/create_approval_note_worker.rb new file mode 100644 index 00000000000..841431f6a9d --- /dev/null +++ b/app/workers/merge_requests/create_approval_note_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module MergeRequests + class CreateApprovalNoteWorker + include Gitlab::EventStore::Subscriber + + data_consistency :always + feature_category :code_review + urgency :low + idempotent! + + def handle_event(event) + current_user_id = event.data[:current_user_id] + merge_request_id = event.data[:merge_request_id] + current_user = User.find_by_id(current_user_id) + + unless current_user + logger.info(structured_payload(message: 'Current user not found.', current_user_id: current_user_id)) + return + end + + merge_request = MergeRequest.find_by_id(merge_request_id) + + unless merge_request + logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id)) + return + end + + SystemNoteService.approve_mr(merge_request, current_user) + end + end +end diff --git a/app/workers/merge_requests/execute_approval_hooks_worker.rb b/app/workers/merge_requests/execute_approval_hooks_worker.rb new file mode 100644 index 00000000000..81eca425a38 --- /dev/null +++ b/app/workers/merge_requests/execute_approval_hooks_worker.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module MergeRequests + class ExecuteApprovalHooksWorker + include Gitlab::EventStore::Subscriber + + data_consistency :always + feature_category :code_review + urgency :low + idempotent! + + # MergeRequests::ExecuteApprovalHooksService execute webhooks which are treated as external dependencies + worker_has_external_dependencies! + + def handle_event(event) + current_user_id = event.data[:current_user_id] + merge_request_id = event.data[:merge_request_id] + current_user = User.find_by_id(current_user_id) + + unless current_user + logger.info(structured_payload(message: 'Current user not found.', current_user_id: current_user_id)) + return + end + + merge_request = MergeRequest.find_by_id(merge_request_id) + + unless merge_request + logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id)) + return + end + + ::MergeRequests::ExecuteApprovalHooksService + .new(project: merge_request.project, current_user: current_user) + .execute(merge_request) + end + end +end diff --git a/app/workers/merge_requests/resolve_todos_after_approval_worker.rb b/app/workers/merge_requests/resolve_todos_after_approval_worker.rb new file mode 100644 index 00000000000..7d9c76ea872 --- /dev/null +++ b/app/workers/merge_requests/resolve_todos_after_approval_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module MergeRequests + class ResolveTodosAfterApprovalWorker + include Gitlab::EventStore::Subscriber + + data_consistency :always + feature_category :code_review + urgency :low + idempotent! + + def handle_event(event) + current_user_id = event.data[:current_user_id] + merge_request_id = event.data[:merge_request_id] + current_user = User.find_by_id(current_user_id) + + unless current_user + logger.info(structured_payload(message: 'Current user not found.', current_user_id: current_user_id)) + return + end + + merge_request = MergeRequest.find_by_id(merge_request_id) + + unless merge_request + logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id)) + return + end + + TodoService.new.resolve_todos_for_target(merge_request, current_user) + end + end +end diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index 13936fac1e4..e14f0dc7dfe 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -13,7 +13,11 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker worker_resource_boundary :cpu weight 2 - def perform(issue_id, user_id) + attr_reader :issuable_class + + def perform(issue_id, user_id, issuable_class = 'Issue') + @issuable_class = issuable_class.constantize + return unless objects_found?(issue_id, user_id) ::EventCreateService.new.open_issue(issuable, user) @@ -25,8 +29,4 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker .new(project: issuable.project, current_user: user) .execute(issuable) end - - def issuable_class - Issue - end end diff --git a/app/workers/pages/invalidate_domain_cache_worker.rb b/app/workers/pages/invalidate_domain_cache_worker.rb index 63b6f5c05b5..97e8966b342 100644 --- a/app/workers/pages/invalidate_domain_cache_worker.rb +++ b/app/workers/pages/invalidate_domain_cache_worker.rb @@ -15,9 +15,13 @@ module Pages .clear_cache end - if event.data[:root_namespace_id] + event.data.values_at( + :root_namespace_id, + :old_root_namespace_id, + :new_root_namespace_id + ).compact.uniq.each do |namespace_id| ::Gitlab::Pages::CacheControl - .for_namespace(event.data[:root_namespace_id]) + .for_namespace(namespace_id) .clear_cache end end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 68a0934e2b7..329ccfc6362 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -85,6 +85,7 @@ class PostReceive replicate_snippet_changes(snippet) expire_caches(post_received, snippet.repository) + snippet.touch Snippets::UpdateStatisticsService.new(snippet).execute end diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index 0e90b41e28d..cb1a7c8560a 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -47,7 +47,8 @@ class ProjectCacheWorker Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute - UpdateProjectStatisticsWorker.perform_in(LEASE_TIMEOUT, project.id, statistics) + lease_key = project_cache_worker_key(project.id, statistics) + UpdateProjectStatisticsWorker.perform_in(LEASE_TIMEOUT, lease_key, project.id, statistics) end private diff --git a/app/workers/projects/import_export/relation_export_worker.rb b/app/workers/projects/import_export/relation_export_worker.rb new file mode 100644 index 00000000000..13ca33c4457 --- /dev/null +++ b/app/workers/projects/import_export/relation_export_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class RelationExportWorker + include ApplicationWorker + include ExceptionBacktrace + + idempotent! + data_consistency :always + deduplicate :until_executed + feature_category :importers + sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION + urgency :low + worker_resource_boundary :memory + + def perform(project_relation_export_id) + relation_export = Projects::ImportExport::RelationExport.find(project_relation_export_id) + + if relation_export.queued? + Projects::ImportExport::RelationExportService.new(relation_export, jid).execute + end + end + end + end +end diff --git a/app/workers/service_desk_email_receiver_worker.rb b/app/workers/service_desk_email_receiver_worker.rb index c8ab8891856..b3b36ca2ada 100644 --- a/app/workers/service_desk_email_receiver_worker.rb +++ b/app/workers/service_desk_email_receiver_worker.rb @@ -9,9 +9,6 @@ class ServiceDeskEmailReceiverWorker < EmailReceiverWorker # rubocop:disable Sca urgency :high sidekiq_options retry: 3 - # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1263 - tags :needs_own_queue - def should_perform? ::Gitlab::ServiceDeskEmail.enabled? end diff --git a/app/workers/update_project_statistics_worker.rb b/app/workers/update_project_statistics_worker.rb index 45a6cc8f476..3308fa149f5 100644 --- a/app/workers/update_project_statistics_worker.rb +++ b/app/workers/update_project_statistics_worker.rb @@ -10,10 +10,15 @@ class UpdateProjectStatisticsWorker # rubocop:disable Scalability/IdempotentWork feature_category :source_code_management - # project_id - The ID of the project for which to flush the cache. - # statistics - An Array containing columns from ProjectStatistics to - # refresh, if empty all columns will be refreshed - def perform(project_id, statistics = []) + # lease_key - The exclusive lease key to take + # project_id - The ID of the project for which to flush the cache. + # statistics - An Array containing columns from ProjectStatistics to + # refresh, if empty all columns will be refreshed + def perform(lease_key, project_id, statistics = []) + return unless Gitlab::ExclusiveLease + .new(lease_key, timeout: ProjectCacheWorker::LEASE_TIMEOUT) + .try_obtain + project = Project.find_by_id(project_id) Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute diff --git a/app/workers/users/deactivate_dormant_users_worker.rb b/app/workers/users/deactivate_dormant_users_worker.rb index d7ea20e4b62..b14b7e67450 100644 --- a/app/workers/users/deactivate_dormant_users_worker.rb +++ b/app/workers/users/deactivate_dormant_users_worker.rb @@ -10,43 +10,23 @@ module Users feature_category :utilization - NUMBER_OF_BATCHES = 50 - BATCH_SIZE = 200 - PAUSE_SECONDS = 0.25 - def perform return if Gitlab.com? return unless ::Gitlab::CurrentSettings.current_application_settings.deactivate_dormant_users - with_context(caller_id: self.class.name.to_s) do - NUMBER_OF_BATCHES.times do - result = User.connection.execute(update_query) - - break if result.cmd_tuples == 0 - - sleep(PAUSE_SECONDS) - end - end + deactivate_users(User.dormant) + deactivate_users(User.with_no_activity) end private - def update_query - <<~SQL - UPDATE "users" - SET "state" = 'deactivated' - WHERE "users"."id" IN ( - (#{users.dormant.to_sql}) - UNION - (#{users.with_no_activity.to_sql}) - LIMIT #{BATCH_SIZE} - ) - SQL - end - - def users - User.select(:id).limit(BATCH_SIZE) + def deactivate_users(scope) + with_context(caller_id: self.class.name.to_s) do + scope.each_batch do |batch| + batch.each(&:deactivate) + end + end end end end diff --git a/app/workers/x509_issuer_crl_check_worker.rb b/app/workers/x509_issuer_crl_check_worker.rb index 39440504927..cb5bae7ca4e 100644 --- a/app/workers/x509_issuer_crl_check_worker.rb +++ b/app/workers/x509_issuer_crl_check_worker.rb @@ -41,13 +41,13 @@ class X509IssuerCrlCheckWorker certs.find_each do |cert| logger.info(message: "Certificate revoked", - id: cert.id, - email: cert.email, - subject: cert.subject, - serial_number: cert.serial_number, - issuer: cert.x509_issuer.id, - issuer_subject: cert.x509_issuer.subject, - issuer_crl_url: cert.x509_issuer.crl_url) + id: cert.id, + email: cert.email, + subject: cert.subject, + serial_number: cert.serial_number, + issuer: cert.x509_issuer.id, + issuer_subject: cert.x509_issuer.subject, + issuer_crl_url: cert.x509_issuer.crl_url) end certs.update_all(certificate_status: :revoked) @@ -61,18 +61,18 @@ class X509IssuerCrlCheckWorker OpenSSL::X509::CRL.new(response.body) else logger.warn(message: "Failed to download certificate revocation list", - issuer: issuer.id, - issuer_subject: issuer.subject, - issuer_crl_url: issuer.crl_url) + issuer: issuer.id, + issuer_subject: issuer.subject, + issuer_crl_url: issuer.crl_url) nil end rescue OpenSSL::X509::CRLError logger.warn(message: "Failed to parse certificate revocation list", - issuer: issuer.id, - issuer_subject: issuer.subject, - issuer_crl_url: issuer.crl_url) + issuer: issuer.id, + issuer_subject: issuer.subject, + issuer_crl_url: issuer.crl_url) nil end |