diff options
Diffstat (limited to 'app/assets')
554 files changed, 8605 insertions, 5323 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); } } |