diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-08 18:08:30 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-08 18:08:30 +0300 |
commit | ec9dd96cd876d8778bb757a1e1e0252a58fdcbbb (patch) | |
tree | 434606041cb42bcc922a02efe52a156b792e123b /app | |
parent | 473b876fe3d7e0b36eb6268cc44a4fe0d94f4422 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
31 files changed, 267 insertions, 313 deletions
diff --git a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue index b5ffba26360..f4f0fcac58f 100644 --- a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue +++ b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue @@ -1,10 +1,9 @@ <script> -import { GlButton, GlModal, GlCard, GlLink, GlAvatar } from '@gitlab/ui'; +import { GlButton, GlModal, GlCard, GlLink, GlAvatar, GlTruncateText } from '@gitlab/ui'; import { __ } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue'; import { REPORTED_CONTENT_I18N } from '../constants'; export default { @@ -15,8 +14,8 @@ export default { GlCard, GlLink, GlAvatar, + GlTruncateText, TimeAgoTooltip, - TruncatedText, }, modalId: 'abuse-report-screenshot-modal', directives: { @@ -109,13 +108,13 @@ export default { footer-class="gl-bg-white js-test-card-footer" > <template v-if="report.content" #header> - <truncated-text> + <gl-truncate-text> <div ref="gfmContent" v-safe-html:[$options.safeHtmlConfig]="report.content" class="md" ></div> - </truncated-text> + </gl-truncate-text> </template> {{ $options.i18n.reportedBy }} <template #footer> 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 5624bae34c2..5abacf44cf3 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,22 +1,71 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; import { selectedRect as getSelectedRect } from '@tiptap/pm/tables'; -import { __ } from '~/locale'; +import { __, n__ } from '~/locale'; const TABLE_CELL_HEADER = 'th'; const TABLE_CELL_BODY = 'td'; +function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1 }) { + const totalRows = selectedRect?.map.height; + const totalCols = selectedRect?.map.width; + const isTableBodyCell = cellType === TABLE_CELL_BODY; + const selectedRows = selectedRect ? selectedRect.bottom - selectedRect.top : 0; + const selectedCols = selectedRect ? selectedRect.right - selectedRect.left : 0; + const showSplitCellOption = + selectedRows === rowspan && selectedCols === colspan && (rowspan > 1 || colspan > 1); + const showMergeCellsOption = selectedRows !== rowspan || selectedCols !== colspan; + const numCellsToMerge = (selectedRows - rowspan + 1) * (selectedCols - colspan + 1); + const showDeleteRowOption = totalRows > selectedRows + 1 && isTableBodyCell; + const showDeleteColumnOption = totalCols > selectedCols; + + return [ + { + items: [ + { text: __('Insert column before'), value: 'addColumnBefore' }, + { text: __('Insert column after'), value: 'addColumnAfter' }, + isTableBodyCell && { text: __('Insert row before'), value: 'addRowBefore' }, + { text: __('Insert row after'), value: 'addRowAfter' }, + ].filter(Boolean), + }, + { + items: [ + showSplitCellOption && { text: __('Split cell'), value: 'splitCell' }, + showMergeCellsOption && { + text: n__('Merge %d cell', 'Merge %d cells', numCellsToMerge), + value: 'mergeCells', + }, + ].filter(Boolean), + }, + { + items: [ + showDeleteRowOption && { + text: n__('Delete row', 'Delete %d rows', selectedRows), + value: 'deleteRow', + }, + showDeleteColumnOption && { + text: n__('Delete column', 'Delete %d columns', selectedCols), + value: 'deleteColumn', + }, + { text: __('Delete table'), value: 'deleteTable' }, + ].filter(Boolean), + }, + ].filter(({ items }) => items.length); +} + export default { name: 'TableCellBaseWrapper', components: { NodeViewWrapper, NodeViewContent, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlDisclosureDropdown, }, props: { + getPos: { + type: Function, + required: true, + }, cellType: { type: String, validator: (type) => [TABLE_CELL_HEADER, TABLE_CELL_BODY].includes(type), @@ -34,19 +83,17 @@ export default { data() { return { displayActionsDropdown: false, - preventHide: true, selectedRect: null, }; }, computed: { - totalRows() { - return this.selectedRect?.map.height; - }, - totalCols() { - return this.selectedRect?.map.width; - }, - isTableBodyCell() { - return this.cellType === TABLE_CELL_BODY; + dropdownItems() { + return getDropdownItems({ + selectedRect: this.selectedRect, + cellType: this.cellType, + rowspan: this.node.attrs.rowspan, + colspan: this.node.attrs.colspan, + }); }, }, mounted() { @@ -61,6 +108,8 @@ export default { const { state } = this.editor; const { $cursor } = state.selection; + this.selectedRect = getSelectedRect(state); + if (!$cursor) return; this.displayActionsDropdown = false; @@ -71,54 +120,34 @@ export default { break; } } - - if (this.displayActionsDropdown) { - this.selectedRect = getSelectedRect(state); - } }, - runCommand(command) { - this.editor.chain()[command]().run(); + + runCommand({ value: command }) { this.hideDropdown(); + this.editor.chain()[command]().run(); }, - handleHide($event) { - if (this.preventHide) { - $event.preventDefault(); - } - this.preventHide = true; - }, + hideDropdown() { - this.preventHide = false; - this.$refs.dropdown?.hide(); + this.$refs.dropdown?.close(); }, }, - i18n: { - insertColumnBefore: __('Insert column before'), - insertColumnAfter: __('Insert column after'), - insertRowBefore: __('Insert row before'), - insertRowAfter: __('Insert row after'), - deleteRow: __('Delete row'), - deleteColumn: __('Delete column'), - deleteTable: __('Delete table'), - editTableActions: __('Edit table'), - }, - dropdownPopperOpts: { - positionFixed: true, - }, }; </script> <template> <node-view-wrapper - class="gl-relative gl-padding-5 gl-min-w-10" :as="cellType" + :rowspan="node.attrs.rowspan || 1" + :colspan="node.attrs.colspan || 1" dir="auto" + class="gl-m-0! gl-p-0! gl-relative" @click="hideDropdown" > <span v-if="displayActionsDropdown" contenteditable="false" - class="gl-absolute gl-right-0 gl-top-0" + class="gl-absolute gl-right-0 gl-top-0 gl-pr-1 gl-pt-1" > - <gl-dropdown + <gl-disclosure-dropdown ref="dropdown" dropup icon="chevron-down" @@ -127,34 +156,12 @@ export default { boundary="viewport" no-caret text-sr-only - :text="$options.i18n.editTableActions" - :popper-opts="$options.dropdownPopperOpts" - @hide="handleHide($event)" - > - <gl-dropdown-item @click="runCommand('addColumnBefore')"> - {{ $options.i18n.insertColumnBefore }} - </gl-dropdown-item> - <gl-dropdown-item @click="runCommand('addColumnAfter')"> - {{ $options.i18n.insertColumnAfter }} - </gl-dropdown-item> - <gl-dropdown-item v-if="isTableBodyCell" @click="runCommand('addRowBefore')"> - {{ $options.i18n.insertRowBefore }} - </gl-dropdown-item> - <gl-dropdown-item @click="runCommand('addRowAfter')"> - {{ $options.i18n.insertRowAfter }} - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-item v-if="totalRows > 2 && isTableBodyCell" @click="runCommand('deleteRow')"> - {{ $options.i18n.deleteRow }} - </gl-dropdown-item> - <gl-dropdown-item v-if="totalCols > 1" @click="runCommand('deleteColumn')"> - {{ $options.i18n.deleteColumn }} - </gl-dropdown-item> - <gl-dropdown-item @click="runCommand('deleteTable')"> - {{ $options.i18n.deleteTable }} - </gl-dropdown-item> - </gl-dropdown> + :items="dropdownItems" + :toggle-text="__('Edit table')" + positioning-strategy="fixed" + @action="runCommand" + /> </span> - <node-view-content /> + <node-view-content as="div" class="gl-p-5 gl-min-w-10" /> </node-view-wrapper> </template> diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 8e169e8641b..d8734c8a2c4 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -451,7 +451,10 @@ export default { }, showStickyHeader() { - this.isStickyHeaderShowing = true; + // only if scrolled under the issue's title + if (this.$refs.title.$el.offsetTop < window.pageYOffset) { + this.isStickyHeaderShowing = true; + } }, handleSaveDescription(description) { @@ -501,6 +504,7 @@ export default { </div> <div v-else> <title-component + ref="title" :issuable-ref="issuableRef" :can-update="canUpdate" :title-html="state.titleHtml" diff --git a/app/assets/javascripts/labels/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue index 752fda83d6b..ab50e6cdcd3 100644 --- a/app/assets/javascripts/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/labels/components/promote_label_modal.vue @@ -3,8 +3,8 @@ import { GlSprintf, GlModal } from '@gitlab/ui'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; -import { stripQuotes } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import eventHub from '../event_hub'; export default { diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 963041dd5d0..42f481261a2 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -568,3 +568,12 @@ export const humanizeBranchValidationErrors = (invalidChars = []) => { } return ''; }; + +/** + * Strips enclosing quotations from a string if it has one. + * + * @param {String} value String to strip quotes from + * + * @returns {String} String without any enclosure + */ +export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2'); diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index e2a10a1c1f3..a67928c387b 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -1,6 +1,7 @@ import { isEqual } from 'lodash'; import { STATUS_CLOSED, STATUS_REOPENED } from '~/issues/constants'; import { isInMRPage } from '~/lib/utils/common_utils'; +import { uuids } from '~/lib/utils/uuids'; import * as constants from '../constants'; import * as types from './mutation_types'; import * as utils from './utils'; @@ -185,6 +186,7 @@ export default { } notesArr.push({ + id: uuids()[0], individual_note: true, isPlaceholderNote: true, placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE, diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue index 6ea1fff9ef0..37fc326f902 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue @@ -81,7 +81,6 @@ export default { const urlParams = new URLSearchParams(window.location.search); const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT); if (showAlert) { - // to be refactored to use gl-alert createAlert({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, variant: VARIANT_INFO }); const cleanUrl = window.location.href.split('?')[0]; historyReplaceState(cleanUrl); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue index 044ce4e6413..14d617a7a3c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue @@ -114,7 +114,6 @@ export default { const urlParams = new URLSearchParams(window.location.search); const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT); if (showAlert) { - // to be refactored to use gl-alert createAlert({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, variant: VARIANT_INFO }); const cleanUrl = window.location.href.split('?')[0]; historyReplaceState(cleanUrl); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 88062bf245f..042400d4340 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -13,10 +13,11 @@ import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searche import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import { createAlert } from '~/alert'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; import { SORT_DIRECTION } from './constants'; -import { filterEmptySearchTerm, stripQuotes, uniqueTokens } from './filtered_search_utils'; +import { filterEmptySearchTerm, uniqueTokens } from './filtered_search_utils'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js index 5cc96471aef..65c783ada55 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -5,15 +5,6 @@ import { queryToObject } from '~/lib/utils/url_utility'; import { MAX_RECENT_TOKENS_SIZE, FILTERED_SEARCH_TERM } from './constants'; /** - * Strips enclosing quotations from a string if it has one. - * - * @param {String} value String to strip quotes from - * - * @returns {String} String without any enclosure - */ -export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2'); - -/** * This method removes duplicate tokens from tokens array. * * @param {Array} tokens Array of tokens as defined by `GlFilteredSearch` diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index e2829d75ab1..5a7382bcd7c 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -9,12 +9,9 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants'; -import { - getRecentlyUsedSuggestions, - setTokenValueToRecentlyUsed, - stripQuotes, -} from '../filtered_search_utils'; +import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index c69a2927ec9..0ce784fab1a 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -3,8 +3,8 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { OPTIONS_NONE_ANY } from '../constants'; -import { stripQuotes } from '../filtered_search_utils'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 6a7dd6131e2..3dfdb15db31 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -3,10 +3,10 @@ import { GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; import { OPTIONS_NONE_ANY } from '../constants'; -import { stripQuotes } from '../filtered_search_utils'; import BaseToken from './base_token.vue'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 81b8a6c78fc..8322fe92de4 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -4,8 +4,8 @@ import { createAlert } from '~/alert'; import { __ } from '~/locale'; import { sortMilestonesByDueDate } from '~/milestones/utils'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { stripQuotes } from '~/lib/utils/text_utility'; import { DEFAULT_MILESTONES } from '../constants'; -import { stripQuotes } from '../filtered_search_utils'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/constants.js b/app/assets/javascripts/vue_shared/components/truncated_text/constants.js deleted file mode 100644 index c3b43d40adf..00000000000 --- a/app/assets/javascripts/vue_shared/components/truncated_text/constants.js +++ /dev/null @@ -1,9 +0,0 @@ -import { __ } from '~/locale'; - -export const SHOW_MORE = __('Show more'); -export const SHOW_LESS = __('Show less'); -export const STATES = { - INITIAL: 'initial', - TRUNCATED: 'truncated', - EXTENDED: 'extended', -}; diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js deleted file mode 100644 index 6a7ac72c31e..00000000000 --- a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js +++ /dev/null @@ -1,26 +0,0 @@ -import { escape } from 'lodash'; -import TruncatedText from './truncated_text.vue'; - -export default { - component: TruncatedText, - title: 'vue_shared/truncated_text', -}; - -const Template = (args, { argTypes }) => ({ - components: { TruncatedText }, - props: Object.keys(argTypes), - template: ` - <truncated-text v-bind="$props"> - <template v-if="${'default' in args}" v-slot> - <span style="white-space: pre-line;">${escape(args.default)}</span> - </template> - </truncated-text> - `, -}); - -export const Default = Template.bind({}); -Default.args = { - lines: 3, - mobileLines: 10, - default: [...Array(15)].map((_, i) => `line ${i + 1}`).join('\n'), -}; diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue deleted file mode 100644 index 96fc04ec825..00000000000 --- a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import { GlResizeObserverDirective, GlButton } from '@gitlab/ui'; -import { STATES, SHOW_MORE, SHOW_LESS } from './constants'; - -export default { - name: 'TruncatedText', - components: { - GlButton, - }, - directives: { - GlResizeObserver: GlResizeObserverDirective, - }, - props: { - lines: { - type: Number, - required: false, - default: 3, - }, - mobileLines: { - type: Number, - required: false, - default: 10, - }, - }, - data() { - return { - state: STATES.INITIAL, - }; - }, - computed: { - showTruncationToggle() { - return this.state !== STATES.INITIAL; - }, - truncationToggleText() { - if (this.state === STATES.TRUNCATED) { - return SHOW_MORE; - } - return SHOW_LESS; - }, - styleObject() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return { '--lines': this.lines, '--mobile-lines': this.mobileLines }; - }, - isTruncated() { - return this.state === STATES.EXTENDED ? null : 'gl-truncate-text-by-line gl-overflow-hidden'; - }, - }, - methods: { - onResize({ target }) { - if (target.scrollHeight > target.offsetHeight) { - this.state = STATES.TRUNCATED; - } else if (this.state === STATES.TRUNCATED) { - this.state = STATES.INITIAL; - } - }, - toggleTruncation() { - if (this.state === STATES.TRUNCATED) { - this.state = STATES.EXTENDED; - } else if (this.state === STATES.EXTENDED) { - this.state = STATES.TRUNCATED; - } - }, - }, -}; -</script> - -<template> - <section> - <article - ref="content" - v-gl-resize-observer="onResize" - :class="isTruncated" - :style="styleObject" - > - <slot></slot> - </article> - <gl-button v-if="showTruncationToggle" variant="link" @click="toggleTruncation">{{ - truncationToggleText - }}</gl-button> - </section> -</template> 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 8ea5873f73a..9c6ce7ab702 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -146,6 +146,9 @@ export default { this.track('click_toggle_work_item_confidentiality'); this.$emit('toggleWorkItemConfidentiality', !this.isConfidential); }, + handleDelete() { + this.$refs.modal.show(); + }, handleDeleteWorkItem() { this.track('click_delete_work_item'); this.$emit('deleteWorkItem'); @@ -288,13 +291,15 @@ export default { </template> <gl-dropdown-item v-if="canDelete" - v-gl-modal="'work-item-confirm-delete'" :data-testid="$options.deleteActionTestId" variant="danger" - >{{ i18n.deleteWorkItem }}</gl-dropdown-item + @click="handleDelete" > + {{ i18n.deleteWorkItem }} + </gl-dropdown-item> </gl-dropdown> <gl-modal + ref="modal" modal-id="work-item-confirm-delete" :title="i18n.deleteWorkItem" :ok-title="i18n.deleteWorkItem" diff --git a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue index 948258fbee6..144c29b8ec3 100644 --- a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue +++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue @@ -1,17 +1,15 @@ <script> import * as Sentry from '@sentry/browser'; +import { produce } from 'immer'; + import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import AwardsList from '~/vue_shared/components/awards_list.vue'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { TYPENAME_USER } from '~/graphql_shared/constants'; -import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import { - EMOJI_ACTION_REMOVE, - EMOJI_ACTION_ADD, - WIDGET_TYPE_AWARD_EMOJI, - EMOJI_THUMBSDOWN, - EMOJI_THUMBSUP, -} from '../constants'; + +import updateAwardEmojiMutation from '../graphql/update_award_emoji.mutation.graphql'; +import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; +import { EMOJI_THUMBSDOWN, EMOJI_THUMBSUP, WIDGET_TYPE_AWARD_EMOJI } from '../constants'; export default { defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN], @@ -20,14 +18,23 @@ export default { AwardsList, }, props: { - workItem: { - type: Object, + workItemId: { + type: String, + required: true, + }, + workItemFullpath: { + type: String, required: true, }, awardEmoji: { type: Object, required: true, }, + workItemIid: { + type: String, + required: false, + default: null, + }, }, computed: { currentUserId() { @@ -40,8 +47,7 @@ export default { * Parse and convert award emoji list to a format that AwardsList can understand */ awards() { - return this.awardEmoji.nodes.map((emoji, index) => ({ - id: index + 1, + return this.awardEmoji.nodes.map((emoji) => ({ name: emoji.name, user: { id: getIdFromGraphQLId(emoji.user.id), @@ -51,34 +57,107 @@ export default { }, }, methods: { - handleAward(name) { - // Decide action based on emoji given by current user. - const action = + getAwards() { + return this.awardEmoji.nodes.map((emoji) => ({ + name: emoji.name, + user: { + id: getIdFromGraphQLId(emoji.user.id), + name: emoji.user.name, + }, + })); + }, + isEmojiPresentForCurrentUser(name) { + return ( this.awards.findIndex( (emoji) => emoji.name === name && emoji.user.id === this.currentUserId, ) > -1 - ? EMOJI_ACTION_REMOVE - : EMOJI_ACTION_ADD; - const inputVariables = { - id: this.workItem.id, - awardEmojiWidget: { - action, + ); + }, + /** + * Prepare award emoji nodes based on emoji name + * and whether the user has toggled the emoji off or on + */ + getAwardEmojiNodes(name, toggledOn) { + // If the emoji toggled on, add the emoji + if (toggledOn) { + // If emoji is already present in award list, no action is needed + if (this.isEmojiPresentForCurrentUser(name)) { + return this.awardEmoji.nodes; + } + + // else make a copy of unmutable list and return the list after adding the new emoji + const awardEmojiNodes = [...this.awardEmoji.nodes]; + awardEmojiNodes.push({ name, - }, + __typename: 'AwardEmoji', + user: { + id: convertToGraphQLId(TYPENAME_USER, this.currentUserId), + name: this.currentUserFullName, + __typename: 'UserCore', + }, + }); + + return awardEmojiNodes; + } + + // else just filter the emoji + return this.awardEmoji.nodes.filter( + (emoji) => + !(emoji.name === name && getIdFromGraphQLId(emoji.user.id) === this.currentUserId), + ); + }, + updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn }) { + const query = { + query: workItemByIidQuery, + variables: { fullPath: this.workItemFullpath, iid: this.workItemIid }, + }; + + const sourceData = cache.readQuery(query); + + const newData = produce(sourceData, (draftState) => { + const { widgets } = draftState.workspace.workItems.nodes[0]; + const widgetAwardEmoji = widgets.find((widget) => widget.type === WIDGET_TYPE_AWARD_EMOJI); + + widgetAwardEmoji.awardEmoji.nodes = this.getAwardEmojiNodes(name, toggledOn); + }); + + cache.writeQuery({ ...query, data: newData }); + }, + handleAward(name) { + // Decide action based on emoji is already present + const inputVariables = { + awardableId: this.workItemId, + name, }; this.$apollo .mutate({ - mutation: updateWorkItemMutation, + mutation: updateAwardEmojiMutation, variables: { input: inputVariables, }, - optimisticResponse: this.getOptimisticResponse({ name, action }), + optimisticResponse: { + awardEmojiToggle: { + errors: [], + toggledOn: !this.isEmojiPresentForCurrentUser(name), + }, + }, + update: ( + cache, + { + data: { + awardEmojiToggle: { toggledOn }, + }, + }, + ) => { + // update the cache of award emoji widget object + this.updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn }); + }, }) .then( ({ data: { - workItemUpdate: { errors }, + awardEmojiToggle: { errors }, }, }) => { if (errors?.length) { @@ -91,52 +170,6 @@ export default { Sentry.captureException(error); }); }, - /** - * Prepare workItemUpdate for optimistic response - */ - getOptimisticResponse({ name, action }) { - let awardEmojiNodes = [ - ...this.awardEmoji.nodes, - { - name, - __typename: 'AwardEmoji', - user: { - id: convertToGraphQLId(TYPENAME_USER, this.currentUserId), - name: this.currentUserFullName, - __typename: 'UserCore', - }, - }, - ]; - // Exclude the award emoji node in case of remove action - if (action === EMOJI_ACTION_REMOVE) { - awardEmojiNodes = [ - ...this.awardEmoji.nodes.filter( - (emoji) => - !(emoji.name === name && getIdFromGraphQLId(emoji.user.id) === this.currentUserId), - ), - ]; - } - return { - workItemUpdate: { - errors: [], - workItem: { - ...this.workItem, - widgets: [ - { - type: WIDGET_TYPE_AWARD_EMOJI, - awardEmoji: { - nodes: awardEmojiNodes, - __typename: 'AwardEmojiConnection', - }, - __typename: 'WorkItemWidgetAwardEmoji', - }, - ], - __typename: 'WorkItem', - }, - __typename: 'WorkItemUpdatePayload', - }, - }; - }, }, }; </script> 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 0b7d9edd765..65e8999ae03 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -663,8 +663,10 @@ export default { /> <work-item-award-emoji v-if="workItemAwardEmoji" - :work-item="workItem" + :work-item-id="workItem.id" + :work-item-fullpath="workItem.project.fullPath" :award-emoji="workItemAwardEmoji.awardEmoji" + :work-item-iid="workItemIid" @error="updateError = $event" /> <work-item-tree diff --git a/app/assets/javascripts/work_items/graphql/update_award_emoji.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_award_emoji.mutation.graphql new file mode 100644 index 00000000000..1506d13d2da --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/update_award_emoji.mutation.graphql @@ -0,0 +1,6 @@ +mutation updateWorkItemAwardEmojiWidget($input: AwardEmojiToggleInput!) { + awardEmojiToggle(input: $input) { + errors + toggledOn + } +} diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 7f66d335f41..680b24be442 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -33,6 +33,17 @@ outline-offset: -3px; } + .selectedCell::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba($blue-200, 0.25); + pointer-events: none; + } + video { max-width: 400px; } @@ -115,6 +126,15 @@ display: inherit; } } + + .gl-new-dropdown-inner li { + margin-left: 0 !important; + + &.gl-new-dropdown-item { + padding-left: $gl-spacing-scale-2; + padding-right: $gl-spacing-scale-2; + } + } } .table-creator-grid-item { diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index fd378dc7008..08c4efce542 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -153,21 +153,3 @@ .gl-fill-red-500 { fill: $red-500; } - -/** - Note: used by app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue - Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab/-/issues/408643 - - Although this solution uses vendor-prefixes, it is supported by all browsers and it is - currently the only way to truncate text by lines. See https://caniuse.com/css-line-clamp -**/ -.gl-truncate-text-by-line { - // stylelint-disable-next-line value-no-vendor-prefix - display: -webkit-box; - -webkit-line-clamp: var(--lines); - -webkit-box-orient: vertical; - - @include gl-media-breakpoint-down(sm) { - -webkit-line-clamp: var(--mobile-lines); - } -} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9749af08dca..08e4f4956df 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -110,7 +110,7 @@ class ApplicationController < ActionController::Base rescue_from Gitlab::Git::ResourceExhaustedError do |e| response.headers.merge!(e.headers) - render plain: e.message, status: :too_many_requests + render plain: e.message, status: :service_unavailable end content_security_policy do |p| diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 11ac641419f..617a8aa1508 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -88,7 +88,7 @@ class GraphqlController < ApplicationController log_exception(exception) response.headers.merge!(exception.headers) - render_error(exception.message, status: :too_many_requests) + render_error(exception.message, status: :service_unavailable) end rescue_from Gitlab::Graphql::Variables::Invalid do |exception| diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index fd7a8708683..bf25ea7367c 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -81,7 +81,7 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord def cache ::Gitlab::SafeRequestStore.fetch(:broadcast_message_json_cache) do - Gitlab::JsonCache.new + Gitlab::Cache::JsonCaches::JsonKeyed.new end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 65e7f734233..5c91f2460c4 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -169,7 +169,9 @@ module Noteable def expire_note_etag_cache return unless discussions_rendered_on_frontend? return unless etag_caching_enabled? - return unless project.present? + + # TODO: We need to figure out a way to make ETag caching work for group-level work items + return if is_a?(Issue) && project.nil? Gitlab::EtagCaching::Store.new.touch(note_etag_key) end diff --git a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb index d4b42b4bfc3..02c762faf14 100644 --- a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb +++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb @@ -15,6 +15,7 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def execute + return if service_disabled? return if pipeline.parent_pipeline? # skip if child pipeline return unless project.auto_cancel_pending_pipelines? @@ -99,6 +100,15 @@ module Ci ) end end + + # Finding the pipelines to cancel is an expensive task that is not well + # covered by indexes for all project use-cases and sometimes it might + # harm other services. See https://gitlab.com/gitlab-com/gl-infra/production/-/issues/14758 + # This feature flag is in place to disable this feature for rogue projects. + # + def service_disabled? + Feature.enabled?(:disable_cancel_redundant_pipelines_service, project, type: :ops) + end end end end diff --git a/app/views/explore/projects/_head.html.haml b/app/views/explore/projects/_head.html.haml index 605d85f49e0..c1d37965cd6 100644 --- a/app/views/explore/projects/_head.html.haml +++ b/app/views/explore/projects/_head.html.haml @@ -3,7 +3,7 @@ = render_dashboard_ultimate_trial(current_user) -.page-title-holder.gl-display-flex.gl-align-items-center +.page-title-holder.gl-display-flex.gl-align-items-center{ data: { testid: 'explore-projects-title' } } %h1.page-title.gl-font-size-h-display= page_title .page-title-controls - if current_user&.can_create_project? diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 8fec5600780..f4749617463 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -38,6 +38,7 @@ = render 'groups/settings/lfs', f: f = render_if_exists 'groups/settings/code_suggestions', f: f, group: @group = render_if_exists 'groups/settings/ai_related_settings', f: f, group: @group + = render_if_exists 'groups/settings/ai_third_party_settings', f: f, group: @group = render 'groups/settings/git_access_protocols', f: f, group: @group = render 'groups/settings/project_creation_level', f: f, group: @group = render 'groups/settings/subgroup_creation_level', f: f, group: @group diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml index b065d5d2dc9..2f470d5ef53 100644 --- a/app/views/shared/_broadcast_message.html.haml +++ b/app/views/shared/_broadcast_message.html.haml @@ -3,7 +3,8 @@ - preview = local_assigns.fetch(:preview, false) - unless message.notification? - .gl-broadcast-message.broadcast-banner-message.banner{ role: "alert", class: "js-broadcast-notification-#{message.id} #{message.theme}" } + .gl-broadcast-message.broadcast-banner-message.banner{ role: "alert", + class: "js-broadcast-notification-#{message.id} #{message.theme}", data: { testid: 'banner-broadcast-message' } } .gl-broadcast-message-content .gl-broadcast-message-icon = sprite_icon(icon_name) |