diff options
97 files changed, 1158 insertions, 268 deletions
diff --git a/.gitlab/ci/package-and-test/main.gitlab-ci.yml b/.gitlab/ci/package-and-test/main.gitlab-ci.yml index 9bcbb780dae..966c1c2f502 100644 --- a/.gitlab/ci/package-and-test/main.gitlab-ci.yml +++ b/.gitlab/ci/package-and-test/main.gitlab-ci.yml @@ -8,7 +8,7 @@ include: - local: .gitlab/ci/package-and-test/rules.gitlab-ci.yml - local: .gitlab/ci/package-and-test/variables.gitlab-ci.yml - project: gitlab-org/quality/pipeline-common - ref: 2.1.1 + ref: 2.2.0 file: - /ci/base.gitlab-ci.yml - /ci/allure-report.yml diff --git a/.gitlab/ci/review-apps/qa.gitlab-ci.yml b/.gitlab/ci/review-apps/qa.gitlab-ci.yml index 6c81bc1972b..12a7ddebc45 100644 --- a/.gitlab/ci/review-apps/qa.gitlab-ci.yml +++ b/.gitlab/ci/review-apps/qa.gitlab-ci.yml @@ -1,6 +1,6 @@ include: - project: gitlab-org/quality/pipeline-common - ref: 2.1.1 + ref: 2.2.0 file: - /ci/base.gitlab-ci.yml - /ci/allure-report.yml @@ -33,9 +33,6 @@ gem 'sprockets', '~> 3.7.0' gem 'view_component', '~> 2.74.1' -# Default values for AR models -gem 'default_value_for', '~> 3.4.0' - # Supported DBs gem 'pg', '~> 1.4.5' diff --git a/Gemfile.checksum b/Gemfile.checksum index f70a84109a5..38a57d2b85c 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -104,7 +104,6 @@ {"name":"deckar01-task_list","version":"2.3.2","platform":"ruby","checksum":"5a19092548d24309d8b2c2704d64cdc08a4a615823c9a722f4142edec1de8805"}, {"name":"declarative","version":"0.0.20","platform":"ruby","checksum":"8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9"}, {"name":"declarative_policy","version":"1.1.0","platform":"ruby","checksum":"9af4cf299ade03f2bbf63908f2ce6a117d132fc714c39a128596667fb13331cb"}, -{"name":"default_value_for","version":"3.4.0","platform":"ruby","checksum":"35d2dc51675a6bedfa875778628d44b823e0d7336da9432519477174ebb0f40f"}, {"name":"deprecation_toolkit","version":"1.5.1","platform":"ruby","checksum":"a8a1ab1a19ae40ea12560b65010e099f3459ebde390b76621ef0c21c516a04ba"}, {"name":"derailed_benchmarks","version":"2.1.2","platform":"ruby","checksum":"eaadc6206ceeb5538ff8f5e04a0023d54ebdd95d04f33e8960fb95a5f189a14f"}, {"name":"descendants_tracker","version":"0.0.4","platform":"ruby","checksum":"e9c41dd4cfbb85829a9301ea7e7c48c2a03b26f09319db230e6479ccdc780897"}, diff --git a/Gemfile.lock b/Gemfile.lock index 36440bde526..9ae73215463 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -339,8 +339,6 @@ GEM html-pipeline declarative (0.0.20) declarative_policy (1.1.0) - default_value_for (3.4.0) - activerecord (>= 3.2.0, < 7.0) deprecation_toolkit (1.5.1) activesupport (>= 4.2) derailed_benchmarks (2.1.2) @@ -1645,7 +1643,6 @@ DEPENDENCIES database_cleaner (~> 1.7.0) deckar01-task_list (= 2.3.2) declarative_policy (~> 1.1.0) - default_value_for (~> 3.4.0) deprecation_toolkit (~> 1.5.1) derailed_benchmarks device_detector diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index 7dd33da435a..cc8913c2f45 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -2,7 +2,7 @@ import { GlButton, GlAlert, GlTabs, GlTab } from '@gitlab/ui'; import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; -import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { fetchPolicies } from '~/lib/graphql'; import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status'; import { typeSet, i18n, tabIndices } from '../constants'; diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js index 2e64312b0e0..e03ebffd17a 100644 --- a/app/assets/javascripts/alerts_settings/utils/cache_updates.js +++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js @@ -1,5 +1,5 @@ import produce from 'immer'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { DELETE_INTEGRATION_ERROR, ADD_INTEGRATION_ERROR } from './error_messages'; diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue index 354db88f11c..06b80a65528 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue @@ -7,6 +7,7 @@ import Heading from '../../extensions/heading'; import Audio from '../../extensions/audio'; import Video from '../../extensions/video'; import Image from '../../extensions/image'; +import DrawioDiagram from '../../extensions/drawio_diagram'; import ToolbarButton from '../toolbar_button.vue'; import BubbleMenu from './bubble_menu.vue'; @@ -26,7 +27,7 @@ export default { if (from === to) return false; const includes = [Paragraph.name, Heading.name]; - const excludes = [Image.name, Audio.name, Video.name]; + const excludes = [Image.name, Audio.name, Video.name, DrawioDiagram.name]; return ( includes.some((type) => editor.isActive(type)) && diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue index 310bb1be81f..a14d49922fb 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue @@ -11,23 +11,26 @@ import { } from '@gitlab/ui'; import { __ } from '~/locale'; import Audio from '../../extensions/audio'; +import DrawioDiagram from '../../extensions/drawio_diagram'; import Image from '../../extensions/image'; import Video from '../../extensions/video'; import EditorStateObserver from '../editor_state_observer.vue'; import { acceptedMimes } from '../../services/upload_helpers'; import BubbleMenu from './bubble_menu.vue'; -const MEDIA_TYPES = [Audio.name, Image.name, Video.name]; +const MEDIA_TYPES = [Audio.name, Image.name, Video.name, DrawioDiagram.name]; export default { i18n: { copySourceLabels: { [Audio.name]: __('Copy audio URL'), + [DrawioDiagram.name]: __('Copy diagram URL'), [Image.name]: __('Copy image URL'), [Video.name]: __('Copy video URL'), }, editLabels: { [Audio.name]: __('Edit audio description'), + [DrawioDiagram.name]: __('Edit diagram description'), [Image.name]: __('Edit image description'), [Video.name]: __('Edit video description'), }, @@ -38,6 +41,7 @@ export default { }, deleteLabels: { [Audio.name]: __('Delete audio'), + [DrawioDiagram.name]: __('Delete diagram'), [Image.name]: __('Delete image'), [Video.name]: __('Delete video'), }, @@ -86,6 +90,9 @@ export default { showProgressIndicator() { return this.isUploading || this.isUpdating; }, + isDrawioDiagram() { + return this.mediaType === DrawioDiagram.name; + }, }, methods: { shouldShow() { @@ -156,10 +163,21 @@ export default { this.isUpdating = false; }, + resetMediaInfo() { + this.mediaTitle = null; + this.mediaAlt = null; + this.mediaCanonicalSrc = null; + this.isUploading = false; + }, + replaceMedia() { this.$refs.fileSelector.click(); }, + editDiagram() { + this.tiptapEditor.chain().focus().createOrEditDiagram().run(); + }, + onFileSelect(e) { this.tiptapEditor .chain() @@ -191,6 +209,8 @@ export default { class="gl-shadow gl-rounded-base gl-bg-white" plugin-key="bubbleMenuMedia" :should-show="shouldShow" + @show="updateMediaInfoToState" + @hidden="resetMediaInfo" > <editor-state-observer @transaction="updateMediaInfoToState"> <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> @@ -240,6 +260,19 @@ export default { @click="startEditingMedia" /> <gl-button + v-if="isDrawioDiagram" + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="edit-diagram" + :aria-label="replaceLabel" + title="Edit diagram" + icon="diagram" + @click="editDiagram" + /> + <gl-button + v-else v-gl-tooltip variant="default" category="tertiary" 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 ca17443081c..7edc99d0e6b 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -54,6 +54,10 @@ export default { action: () => this.insert('diagram', { language: 'plantuml' }), }, { + text: __('Create or edit diagram'), + action: () => this.execute('createOrEditDiagram', 'drawioDiagram'), + }, + { text: __('Table of contents'), action: () => this.execute('insertTableOfContents', 'tableOfContents'), }, diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js index 14862727811..6a3740a5952 100644 --- a/app/assets/javascripts/content_editor/constants/index.js +++ b/app/assets/javascripts/content_editor/constants/index.js @@ -47,6 +47,7 @@ export const KEYDOWN_EVENT = 'keydown'; export const PARSE_HTML_PRIORITY_LOWEST = 1; export const PARSE_HTML_PRIORITY_DEFAULT = 50; +export const PARSE_HTML_PRIORITY_HIGH = 75; export const PARSE_HTML_PRIORITY_HIGHEST = 100; export const EXTENSION_PRIORITY_LOWER = 75; diff --git a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js new file mode 100644 index 00000000000..8c3012ecf59 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js @@ -0,0 +1,41 @@ +import { create } from '~/drawio/content_editor_facade'; +import { launchDrawioEditor } from '~/drawio/drawio_editor'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import createAssetResolver from '../services/asset_resolver'; +import Image from './image'; + +export default Image.extend({ + name: 'drawioDiagram', + addOptions() { + return { + ...this.parent?.(), + uploadsPath: null, + renderMarkdown: null, + }; + }, + parseHTML() { + return [ + { + priority: PARSE_HTML_PRIORITY_HIGHEST, + tag: 'a.no-attachment-icon[data-canonical-src$="drawio.svg"]', + }, + { + tag: 'img[src]', + }, + ]; + }, + addCommands() { + return { + createOrEditDiagram: () => () => { + launchDrawioEditor({ + editorFacade: create({ + tiptapEditor: this.editor, + drawioNodeName: this.name, + uploadsPath: this.options.uploadsPath, + assetResolver: createAssetResolver({ renderMarkdown: this.options.renderMarkdown }), + }), + }); + }, + }; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index fc4c108b773..58c16297886 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,5 +1,5 @@ import { Image } from '@tiptap/extension-image'; -import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import { PARSE_HTML_PRIORITY_HIGH } from '../constants'; const resolveImageEl = (element) => element.nodeName === 'IMG' ? element : element.querySelector('img'); @@ -77,7 +77,7 @@ export default Image.extend({ parseHTML() { return [ { - priority: PARSE_HTML_PRIORITY_HIGHEST, + priority: PARSE_HTML_PRIORITY_HIGH, tag: 'a.no-attachment-icon', }, { 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 61c6be574d0..c9ed0bb2757 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -16,6 +16,7 @@ import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; import Diagram from '../extensions/diagram'; +import DrawioDiagram from '../extensions/drawio_diagram'; import Document from '../extensions/document'; import Dropcursor from '../extensions/dropcursor'; import Emoji from '../extensions/emoji'; @@ -109,6 +110,7 @@ export const createContentEditor = ({ DetailsContent, Document, Diagram, + DrawioDiagram.configure({ uploadsPath, renderMarkdown }), Dropcursor, Emoji, Figure, diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 4e29f85004b..e27a427372c 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -12,6 +12,7 @@ import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; +import DrawioDiagram from '../extensions/drawio_diagram'; import Comment from '../extensions/comment'; import Diagram from '../extensions/diagram'; import Emoji from '../extensions/emoji'; @@ -134,6 +135,10 @@ const defaultSerializerConfig = { [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), [Comment.name]: renderComment, [Diagram.name]: preserveUnchanged(renderCodeBlock), + [DrawioDiagram.name]: preserveUnchanged({ + render: renderImage, + inline: true, + }), [DescriptionList.name]: renderHTMLNode('dl', true), [DescriptionItem.name]: (state, node, parent, index) => { if (index === 1) state.ensureNewLine(); diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js index 09f0738b51b..abfb73183dd 100644 --- a/app/assets/javascripts/content_editor/services/upload_helpers.js +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -4,17 +4,27 @@ import { __ } from '~/locale'; import { extractFilename, readFileAsDataURL } from './utils'; export const acceptedMimes = { - image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'], - audio: [ - 'audio/basic', - 'audio/mid', - 'audio/mpeg', - 'audio/x-aiff', - 'audio/ogg', - 'audio/vorbis', - 'audio/vnd.wav', - ], - video: ['video/mp4', 'video/quicktime'], + drawioDiagram: { + mimes: ['image/svg+xml'], + ext: 'drawio.svg', + }, + image: { + mimes: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'], + }, + audio: { + mimes: [ + 'audio/basic', + 'audio/mid', + 'audio/mpeg', + 'audio/x-aiff', + 'audio/ogg', + 'audio/vorbis', + 'audio/vnd.wav', + ], + }, + video: { + mimes: ['video/mp4', 'video/quicktime'], + }, }; const extractAttachmentLinkUrl = (html) => { @@ -128,8 +138,8 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => { if (!file) return false; - for (const [type, mimes] of Object.entries(acceptedMimes)) { - if (mimes.includes(file?.type)) { + for (const [type, { mimes, ext }] of Object.entries(acceptedMimes)) { + if (mimes.includes(file?.type) && (!ext || file?.name.endsWith(ext))) { uploadContent({ type, editor, file, uploadsPath, renderMarkdown, eventHub }); return true; diff --git a/app/assets/javascripts/drawio/constants.js b/app/assets/javascripts/drawio/constants.js index a5f1d1e71d2..2e1e074db3b 100644 --- a/app/assets/javascripts/drawio/constants.js +++ b/app/assets/javascripts/drawio/constants.js @@ -11,3 +11,5 @@ export const DARK_BACKGROUND_COLOR = '#202020'; export const DIAGRAM_BACKGROUND_COLOR = '#ffffff'; export const DRAWIO_IFRAME_TIMEOUT = 4000; + +export const DIAGRAM_MAX_SIZE = 10 * 1024 * 1024; // 1MB diff --git a/app/assets/javascripts/drawio/content_editor_facade.js b/app/assets/javascripts/drawio/content_editor_facade.js new file mode 100644 index 00000000000..1c41194c1f5 --- /dev/null +++ b/app/assets/javascripts/drawio/content_editor_facade.js @@ -0,0 +1,80 @@ +import axios from '~/lib/utils/axios_utils'; + +/** + * A set of functions to decouple the content_editor component from + * the draw.io editor. + * It allows the draw.io editor to obtain a selected drawio_diagram + * and replace it or insert a new drawio_diagram node without coupling + * the drawio_editor to the Content Editor implementation details + * * + * @param {Object} params Factory function parameters + * @param {Object} params.tiptapEditor See https://tiptap.dev/api/editor + * @param {String} params.drawioNodeName Name of the drawio_diagram node in + * the ProseMirror document + * @param {String} params.uploadsPath API endpoint to upload files + * @param {Object} params.assetResolver See + * app/assets/javascripts/content_editor/services/asset_resolver.js + * + * @returns A content_editor_facade object with operations + * to get a selected diagram, upload a diagram, insert a new one in the + * Content Editor, and update an existing’s diagram URL. + */ +export const create = ({ tiptapEditor, drawioNodeName, uploadsPath, assetResolver }) => ({ + getDiagram: async () => { + const { node } = tiptapEditor.state.selection; + + if (!node || node.type.name !== drawioNodeName) { + return null; + } + + const { src } = node.attrs; + const response = await axios.get(src, { responseType: 'text' }); + const diagramSvg = response.data; + const contentType = response.headers['content-type']; + const filename = src.split('/').pop(); + + return { + diagramURL: src, + filename, + diagramSvg, + contentType, + }; + }, + updateDiagram: async ({ uploadResults: { file_path: canonicalSrc } }) => { + const src = await assetResolver.resolveUrl(canonicalSrc); + + tiptapEditor + .chain() + .focus() + .updateAttributes(drawioNodeName, { + src, + canonicalSrc, + }) + .run(); + }, + insertDiagram: async ({ uploadResults: { file_path: canonicalSrc } }) => { + const src = await assetResolver.resolveUrl(canonicalSrc); + + tiptapEditor + .chain() + .focus() + .insertContent({ + type: drawioNodeName, + attrs: { + src, + canonicalSrc, + }, + }) + .run(); + }, + uploadDiagram: async ({ filename, diagramSvg }) => { + const blob = new Blob([diagramSvg], { type: 'image/svg+xml' }); + const formData = new FormData(); + + formData.append('file', blob, filename); + + const response = await axios.post(uploadsPath, formData); + + return response.data; + }, +}); diff --git a/app/assets/javascripts/drawio/drawio_editor.js b/app/assets/javascripts/drawio/drawio_editor.js index 06e7f536426..9668c2835ce 100644 --- a/app/assets/javascripts/drawio/drawio_editor.js +++ b/app/assets/javascripts/drawio/drawio_editor.js @@ -9,6 +9,7 @@ import { DRAWIO_FRAME_ID, DIAGRAM_BACKGROUND_COLOR, DRAWIO_IFRAME_TIMEOUT, + DIAGRAM_MAX_SIZE, } from './constants'; function updateDrawioEditorState(drawIOEditorState, data) { @@ -109,14 +110,24 @@ async function loadExistingDiagram(drawIOEditorState, editorFacade) { try { diagram = await editorFacade.getDiagram(); } catch (e) { - throw new Error(__('Cannot load the diagram into the draw.io editor')); + throw new Error(__('Cannot load the diagram into the diagrams.net editor')); } if (diagram) { - const { diagramMarkdown, filename, diagramSvg, contentType } = diagram; + const { diagramMarkdown, filename, diagramSvg, contentType, diagramURL } = diagram; + const resolvedURL = new URL(diagramURL, window.location.origin); + const diagramSvgSize = new Blob([diagramSvg]).size; if (contentType !== 'image/svg+xml') { - throw new Error(__('The selected image is not a diagram')); + throw new Error(__('The selected image is not a valid SVG diagram')); + } + + if (resolvedURL.origin !== window.location.origin) { + throw new Error(__('The selected image is not an asset uploaded in the application')); + } + + if (diagramSvgSize > DIAGRAM_MAX_SIZE) { + throw new Error(__('The selected image is too large.')); } updateDrawioEditorState(drawIOEditorState, { @@ -142,7 +153,7 @@ async function prepareEditor(drawIOEditorState, editorFacade) { try { await loadExistingDiagram(drawIOEditorState, editorFacade); - iframe.style.visibility = ''; + iframe.style.visibility = 'visible'; iframe.style.cursor = ''; window.scrollTo(0, 0); } catch (e) { @@ -212,23 +223,15 @@ function createEditorIFrame(drawIOEditorState) { setAttributes(iframe, { id: DRAWIO_FRAME_ID, src: DRAWIO_EDITOR_URL, + class: 'drawio-editor', }); - iframe.style.position = 'absolute'; - iframe.style.border = '0'; - iframe.style.top = '0px'; - iframe.style.left = '0px'; - iframe.style.width = '100%'; - iframe.style.height = '100%'; - iframe.style.zIndex = '1100'; - iframe.style.visibility = 'hidden'; - document.body.appendChild(iframe); setTimeout(() => { if (drawIOEditorState.initialized === false) { disposeDrawioEditor(drawIOEditorState); - createAlert({ message: __('The draw.io editor could not be loaded.') }); + createAlert({ message: __('The diagrams.net editor could not be loaded.') }); } }, DRAWIO_IFRAME_TIMEOUT); diff --git a/app/assets/javascripts/drawio/markdown_field_editor_facade.js b/app/assets/javascripts/drawio/markdown_field_editor_facade.js index b2506ce6bf8..4ef203c7aa0 100644 --- a/app/assets/javascripts/drawio/markdown_field_editor_facade.js +++ b/app/assets/javascripts/drawio/markdown_field_editor_facade.js @@ -32,6 +32,7 @@ export const create = ({ textArea, markdownPreviewPath, uploadsPath }) => ({ const contentType = response.headers['content-type']; return { + diagramURL: imageURL, diagramMarkdown: imageMarkdown, filename, diagramSvg, diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue index 89400bc4742..420c34a88f1 100644 --- a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue +++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue @@ -8,7 +8,7 @@ import { GlSearchBoxByType, } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue index 736da92fa9f..c1de507cd80 100644 --- a/app/assets/javascripts/issuable/components/csv_export_modal.vue +++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue @@ -1,7 +1,7 @@ <script> import { GlModal, GlSprintf, GlIcon } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, n__ } from '~/locale'; -import { ISSUABLE_TYPE } from '../constants'; export default { actionCancel: { @@ -19,7 +19,7 @@ export default { }, inject: { issuableType: { - default: ISSUABLE_TYPE.issues, + default: TYPE_ISSUE, }, email: { default: '', @@ -47,14 +47,17 @@ export default { href: this.exportCsvPath, variant: 'confirm', 'data-method': 'post', - 'data-qa-selector': `export_${this.issuableType}_button`, + 'data-qa-selector': `export_issues_button`, 'data-track-action': 'click_button', - 'data-track-label': `export_${this.issuableType}_csv`, + 'data-track-label': this.dataTrackLabel, }, }; }, isIssue() { - return this.issuableType === ISSUABLE_TYPE.issues; + return this.issuableType === TYPE_ISSUE; + }, + dataTrackLabel() { + return this.isIssue ? 'export_issues_csv' : 'export_merge-requests_csv'; }, exportText() { return this.isIssue ? __('Export issues') : __('Export merge requests'); diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue index dadb1419649..2cc01c302ec 100644 --- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue +++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue @@ -7,8 +7,8 @@ import { GlTooltipDirective, GlModalDirective, } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; -import { ISSUABLE_TYPE } from '../constants'; import CsvExportModal from './csv_export_modal.vue'; import CsvImportModal from './csv_import_modal.vue'; @@ -34,7 +34,7 @@ export default { }, inject: { issuableType: { - default: ISSUABLE_TYPE.issues, + default: TYPE_ISSUE, }, showExportButton: { default: false, diff --git a/app/assets/javascripts/issuable/constants.js b/app/assets/javascripts/issuable/constants.js index 5327f251fda..88fc6859acd 100644 --- a/app/assets/javascripts/issuable/constants.js +++ b/app/assets/javascripts/issuable/constants.js @@ -1,11 +1 @@ export const EVENT_ISSUABLE_VUE_APP_CHANGE = 'issuable_vue_app:change'; - -export const ISSUABLE_TYPE = { - issues: 'issues', - mergeRequests: 'merge-requests', -}; - -export const ISSUABLE_INDEX = { - ISSUE: 'issue_', - MERGE_REQUEST: 'merge_request_', -}; 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 index 40cb7fbb0ff..c2b7d33c14c 100644 --- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -113,7 +113,7 @@ export default { > <div v-if="hasTimelineEvents" - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-flex-shrink-0 gl-p-3 gl-z-index-1" > <gl-icon name="comment" class="note-icon" /> </div> 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 7944362a40f..243666b2323 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 @@ -255,11 +255,10 @@ export default { </gl-form-group> </div> <gl-form-group class="gl-mb-0"> - <div class="gl-display-flex"> + <div class="gl-display-flex gl-flex-wrap gl-gap-3"> <gl-button variant="confirm" category="primary" - class="gl-mr-3" data-testid="save-button" :disabled="!isTimelineTextValid" :loading="isEventProcessed" @@ -271,7 +270,6 @@ export default { v-if="showSaveAndAdd" variant="confirm" category="secondary" - class="gl-mr-3 gl-ml-n2" data-testid="save-and-add-button" :disabled="!isTimelineTextValid" :loading="isEventProcessed" @@ -279,7 +277,7 @@ export default { > {{ $options.i18n.saveAndAdd }} </gl-button> - <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> + <gl-button :disabled="isEventProcessed" @click="$emit('cancel')"> {{ $options.i18n.cancel }} </gl-button> <gl-button 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 fdc6e75c932..ea6ebb614f4 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 @@ -28,6 +28,9 @@ export default { }, }, computed: { + isPrivatePackage() { + return !this.packageEntity.publicPackage; + }, pypiPipCommand() { // eslint-disable-next-line @gitlab/require-i18n-strings return `pip install ${this.packageEntity.name} --index-url ${this.packageEntity.pypiUrl}`; @@ -75,7 +78,7 @@ password = <your personal access token>`; :tracking-action="$options.tracking.TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND" :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" /> - <template #description> + <template v-if="isPrivatePackage" #description> <gl-sprintf :message="$options.i18n.tokenText"> <template #link="{ content }"> <gl-link 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 109d535469b..b5313f929f8 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 @@ -15,6 +15,7 @@ query getPackageDetails( updatedAt status canDestroy + publicPackage npmUrl mavenUrl conanUrl diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index af75c05b300..3ae8018714a 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -3,10 +3,9 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { initBulkUpdateSidebar, initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; -import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST); +initBulkUpdateSidebar('merge_request_'); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration'); diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue index 6e7c85053dc..6edd6530bc5 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -71,7 +71,7 @@ export default { }, computed: { forcePushAttributes() { - const { allowForcePush } = this.branchProtection; + const { allowForcePush } = this.branchProtection || {}; const icon = allowForcePush ? REQUIRED_ICON : NOT_REQUIRED_ICON; const iconClass = allowForcePush ? REQUIRED_ICON_CLASS : NOT_REQUIRED_ICON_CLASS; const title = allowForcePush @@ -81,7 +81,7 @@ export default { return { icon, iconClass, title }; }, codeOwnersApprovalAttributes() { - const { codeOwnerApprovalRequired } = this.branchProtection; + const { codeOwnerApprovalRequired } = this.branchProtection || {}; const icon = codeOwnerApprovalRequired ? REQUIRED_ICON : NOT_REQUIRED_ICON; const iconClass = codeOwnerApprovalRequired ? REQUIRED_ICON_CLASS : NOT_REQUIRED_ICON_CLASS; const title = codeOwnerApprovalRequired 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 e7d028e8d23..270d7f0d182 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 @@ -1,7 +1,7 @@ <script> import { GlToast, GlTooltipDirective, GlModal } from '@gitlab/ui'; import Vue from 'vue'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { s__ } from '~/locale'; import { updateUserStatus } from '~/rest_api'; 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 f7526bcff3d..3038cec03eb 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -1,6 +1,6 @@ <script> import { GlSprintf, GlButton } from '@gitlab/ui'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import { confidentialityQueries } from '../../constants'; 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 c2f239b56c7..9177baec246 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -1,7 +1,7 @@ <script> import produce from 'immer'; import Vue from 'vue'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { __, sprintf } from '~/locale'; import { confidentialityQueries, Tracking } from '../../constants'; import SidebarEditableItem from '../sidebar_editable_item.vue'; diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index 891eaf07ac9..190b8c1de62 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { TYPE_ISSUE } from '~/issues/constants'; import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue index f7daad63f45..6db332a82da 100644 --- a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue +++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { logError } from '~/lib/logger'; import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue'; import { diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js index 2dab97826b9..06030003f3c 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js @@ -1,4 +1,4 @@ -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue index 36a40369d95..21bcb51f7b1 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue @@ -8,7 +8,7 @@ import { GlLoadingIcon, } from '@gitlab/ui'; import produce from 'immer'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { WORKSPACE_GROUP } from '~/issues/constants'; import { __ } from '~/locale'; import { workspaceLabelsQueries } from '../../../constants'; diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue index c1939dc7785..e664d6b4bd6 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue @@ -1,7 +1,7 @@ <script> import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { workspaceLabelsQueries } from '../../../constants'; diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue index d7e094ad340..3aa4215443e 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue @@ -2,7 +2,7 @@ import { debounce } from 'lodash'; import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_TEST_CASE } from '~/issues/constants'; diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index df03af346c0..606d374158b 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui'; import $ from 'jquery'; import { mapActions } from 'vuex'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { __, sprintf } from '~/locale'; import eventHub from '../../event_hub'; @@ -49,11 +49,11 @@ export default { fullPath: this.fullPath, }) .catch(() => { - const flashMessage = __( + const alertMessage = __( 'Something went wrong trying to change the locked state of this %{issuableDisplayName}', ); createAlert({ - message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }), + message: sprintf(alertMessage, { issuableDisplayName: this.issuableDisplayName }), }); }) .finally(() => { diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index 9d8f1304911..8c2024129e2 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -4,7 +4,7 @@ import { mapGetters, mapActions } from 'vuex'; import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import toast from '~/vue_shared/plugins/global_toast'; import eventHub from '../../event_hub'; import EditForm from './edit_form.vue'; @@ -92,11 +92,11 @@ export default { } }) .catch(() => { - const flashMessage = __( + const alertMessage = __( 'Something went wrong trying to change the locked state of this %{issuableDisplayName}', ); createAlert({ - message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }), + message: sprintf(alertMessage, { issuableDisplayName: this.issuableDisplayName }), }); }) .finally(() => { diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue index 7068ba98966..50b4284cde0 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue @@ -27,7 +27,7 @@ import { LocalizedIssuableAttributeType, noAttributeId, } from 'ee_else_ce/sidebar/constants'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { PathIdSeparator } from '~/related_issues/constants'; export default { diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 660e7b98155..19e72da65f2 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui'; import { kebabCase, snakeCase } from 'lodash'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index 226785d859c..4a5ec124e5d 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -1,6 +1,6 @@ <script> import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { TYPE_EPIC, TYPE_MERGE_REQUEST } from '~/issues/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index 4a7528d9c8e..151c38d01dc 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -2,7 +2,7 @@ import { GlButton, GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui'; import eventHub from '~/blob/components/eventhub'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { redirectTo, joinPaths } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { @@ -141,7 +141,7 @@ export default { Object.assign(e, { returnValue }); return returnValue; }, - flashAPIFailure(err) { + alertAPIFailure(err) { const defaultErrorMsg = this.newSnippet ? SNIPPET_CREATE_MUTATION_ERROR : SNIPPET_UPDATE_MUTATION_ERROR; @@ -190,7 +190,7 @@ export default { const errors = baseObj?.errors; if (errors?.length) { - this.flashAPIFailure(errors[0]); + this.alertAPIFailure(errors[0]); } else { redirectTo(baseObj.snippet.webUrl); } @@ -199,7 +199,7 @@ export default { // eslint-disable-next-line no-console console.error('[gitlab] unexpected error while updating snippet', e); - this.flashAPIFailure(getErrorMessage(e)); + this.alertAPIFailure(getErrorMessage(e)); }); }, updateActions(actions) { diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 7e80928cbea..021bd23781e 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { getBaseURL, joinPaths } from '~/lib/utils/url_utility'; import { sprintf } from '~/locale'; @@ -60,9 +60,9 @@ export default { .then((res) => { this.notifyAboutUpdates({ content: res.data }); }) - .catch((e) => this.flashAPIFailure(e)); + .catch((e) => this.alertAPIFailure(e)); }, - flashAPIFailure(err) { + alertAPIFailure(err) { createAlert({ message: sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }) }); }, }, diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 231eaff41b5..881e06113d9 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -19,7 +19,7 @@ import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { createAlert, VARIANT_DANGER, VARIANT_SUCCESS } from '~/flash'; +import { createAlert, VARIANT_DANGER, VARIANT_SUCCESS } from '~/alert'; import DeleteSnippetMutation from '../mutations/delete_snippet.mutation.graphql'; diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index 134c2858849..1aa3baca165 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -5,6 +5,7 @@ /*= provides zen_mode:enter */ /*= provides zen_mode:leave */ +import autosize from 'autosize'; import Dropzone from 'dropzone'; import $ from 'jquery'; import Mousetrap from 'mousetrap'; @@ -39,6 +40,7 @@ export default class ZenMode { constructor() { this.active_backdrop = null; this.active_textarea = null; + this.storedStyle = null; $(document).on('click', '.js-zen-enter', (e) => { e.preventDefault(); return $(e.currentTarget).trigger('zen_mode:enter'); @@ -68,6 +70,7 @@ export default class ZenMode { this.active_backdrop.addClass('fullscreen'); this.active_textarea = this.active_backdrop.find('textarea'); // Prevent a user-resized textarea from persisting to fullscreen + this.storedStyle = this.active_textarea.attr('style'); this.active_textarea.removeAttr('style'); this.active_textarea.focus(); } @@ -77,6 +80,11 @@ export default class ZenMode { Mousetrap.unpause(); this.active_textarea.closest('.zen-backdrop').removeClass('fullscreen'); scrollToElement(this.active_textarea, { duration: 0, offset: -100 }); + this.active_textarea.attr('style', this.storedStyle); + + autosize(this.active_textarea); + autosize.update(this.active_textarea); + this.active_textarea = null; this.active_backdrop = null; diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss index 55628603570..d7d454bde45 100644 --- a/app/assets/stylesheets/page_bundles/wiki.scss +++ b/app/assets/stylesheets/page_bundles/wiki.scss @@ -195,3 +195,20 @@ ul.wiki-pages-list.content-list { display: none; } } + +.drawio-editor { + position: fixed; + top: calc(var(--header-height, 48px)); + left: 0; + bottom: 0; + width: 100%; + height: calc(100% - var(--header-height, 48px)); + border: 0; + z-index: 1100; + visibility: hidden; +} + +.with-performance-bar .drawio-editor { + top: calc(var(--header-height, 48px) + 35px); + height: calc(100% - var(--header-height, 48px) - 35px); +} diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb index f63b41b3c92..e00d6eac72f 100644 --- a/app/graphql/types/packages/package_details_type.rb +++ b/app/graphql/types/packages/package_details_type.rb @@ -63,11 +63,11 @@ module Types end def pypi_url - pypi_registry_url(object.project.id) + pypi_registry_url(object.project) end def public_package - object.project.public? || object.project.project_feature.package_registry_access_level == ProjectFeature::PUBLIC + object.project.project_feature.public_packages? end end end diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index f9ec20bdd01..dec1943db54 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -27,9 +27,14 @@ module PackagesHelper presenter.detail_view.to_json end - def pypi_registry_url(project_id) - full_url = expose_url(api_v4_projects_packages_pypi_simple_package_name_path({ id: project_id, package_name: '' }, true)) - full_url.sub!('://', '://__token__:<your_personal_token>@') + def pypi_registry_url(project) + full_url = expose_url(api_v4_projects_packages_pypi_simple_package_name_path({ id: project.id, package_name: '' }, true)) + + if project.project_feature.public_packages? + full_url + else + full_url.sub!('://', '://__token__:<your_personal_token>@') + end end def composer_registry_url(group_id) diff --git a/app/models/dependency_proxy/registry.rb b/app/models/dependency_proxy/registry.rb index 6492acf325a..3073dd59c7b 100644 --- a/app/models/dependency_proxy/registry.rb +++ b/app/models/dependency_proxy/registry.rb @@ -33,3 +33,5 @@ class DependencyProxy::Registry end end end + +::DependencyProxy::Registry.prepend_mod diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 1c7a8d93e6e..c52f8a58c00 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -145,7 +145,7 @@ module ErrorTracking ensure_issue_belongs_to_project!(issue_to_be_updated.project_id) handle_exceptions do - { updated: sentry_client.update_issue(opts) } + { updated: sentry_client.update_issue(**opts) } end end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 168646bbe41..053ccfac050 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -162,6 +162,12 @@ class ProjectFeature < ApplicationRecord end end + def public_packages? + return false unless Gitlab.config.packages.enabled + + package_registry_access_level == PUBLIC || project.public? + end + private def set_pages_access_level diff --git a/app/views/projects/issues/service_desk/_nav_btns.html.haml b/app/views/projects/issues/service_desk/_nav_btns.html.haml index 8d16c3d978f..818de77dc89 100644 --- a/app/views/projects/issues/service_desk/_nav_btns.html.haml +++ b/app/views/projects/issues/service_desk/_nav_btns.html.haml @@ -1,7 +1,7 @@ - show_feed_buttons = local_assigns.fetch(:show_feed_buttons, true) - show_import_button = local_assigns.fetch(:show_import_button, true) && can?(current_user, :import_issues, @project) - show_export_button = local_assigns.fetch(:show_export_button, true) -- issuable_type = 'issues' +- issuable_type = 'issue' - can_edit = can?(current_user, :admin_project, @project) - notification_email = @current_user.present? ? @current_user.notification_email_or_default : nil diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml index 1efea6a1d37..beb6de4698c 100644 --- a/app/views/projects/merge_requests/_nav_btns.html.haml +++ b/app/views/projects/merge_requests/_nav_btns.html.haml @@ -1,4 +1,4 @@ -- issuable_type = 'merge-requests' +- issuable_type = 'merge_request' - notification_email = @current_user.present? ? @current_user.notification_email_or_default : nil = render 'shared/issuable/feed_buttons', show_calendar_button: false diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index 37f7fbc0de5..ad6e5578878 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -4,7 +4,6 @@ - opened_issues_count = issuables_count_for_state(:issues, :opened) - is_opened_state = params[:state] == 'opened' - is_closed_state = params[:state] == 'closed' -- issuable_type = 'issues' - can_edit = can?(current_user, :admin_project, @project) .row.empty-state @@ -43,7 +42,7 @@ = link_to _('New issue'), button_path, class: 'gl-button btn btn-confirm', id: 'new_issue_link' - if show_import_button - .js-csv-import-export-buttons{ data: { show_import_button: 'true', issuable_type: issuable_type, import_csv_issues_path: import_csv_namespace_project_issues_path, can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), container_class: 'gl-w-full gl-sm-w-auto gl-sm-mr-3 gl-display-inline-flex gl-vertical-align-middle', show_label: 'true' } } + .js-csv-import-export-buttons{ data: { show_import_button: 'true', issuable_type: 'issue', import_csv_issues_path: import_csv_namespace_project_issues_path, can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), container_class: 'gl-w-full gl-sm-w-auto gl-sm-mr-3 gl-display-inline-flex gl-vertical-align-middle', show_label: 'true' } } %hr %p.gl-text-center.gl-mb-0 %strong diff --git a/db/post_migrate/20230303120531_schedule_temporary_partitioning_indexes_removal.rb b/db/post_migrate/20230303120531_schedule_temporary_partitioning_indexes_removal.rb new file mode 100644 index 00000000000..73334be4214 --- /dev/null +++ b/db/post_migrate/20230303120531_schedule_temporary_partitioning_indexes_removal.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class ScheduleTemporaryPartitioningIndexesRemoval < Gitlab::Database::Migration[2.1] + INDEXES = [ + [:ci_pipelines, :tmp_index_ci_pipelines_on_partition_id_and_id], + [:ci_stages, :tmp_index_ci_stages_on_partition_id_and_id], + [:ci_builds, :tmp_index_ci_builds_on_partition_id_and_id], + [:ci_build_needs, :tmp_index_ci_build_needs_on_partition_id_and_id], + [:ci_build_report_results, :tmp_index_ci_build_report_results_on_partition_id_and_build_id], + [:ci_build_trace_metadata, :tmp_index_ci_build_trace_metadata_on_partition_id_and_id], + [:ci_job_artifacts, :tmp_index_ci_job_artifacts_on_partition_id_and_id], + [:ci_pipeline_variables, :tmp_index_ci_pipeline_variables_on_partition_id_and_id], + [:ci_job_variables, :tmp_index_ci_job_variables_on_partition_id_and_id], + [:ci_sources_pipelines, :tmp_index_ci_sources_pipelines_on_partition_id_and_id], + [:ci_sources_pipelines, :tmp_index_ci_sources_pipelines_on_source_partition_id_and_id], + [:ci_running_builds, :tmp_index_ci_running_builds_on_partition_id_and_id], + [:ci_pending_builds, :tmp_index_ci_pending_builds_on_partition_id_and_id], + [:ci_builds_runner_session, :tmp_index_ci_builds_runner_session_on_partition_id_and_id] + ] + + def up + INDEXES.each do |table_name, index_name| + prepare_async_index_removal table_name, nil, name: index_name + end + end + + def down + INDEXES.each do |table_name, index_name| + unprepare_async_index table_name, nil, name: index_name + end + end +end diff --git a/db/schema_migrations/20230303120531 b/db/schema_migrations/20230303120531 new file mode 100644 index 00000000000..5c042677e67 --- /dev/null +++ b/db/schema_migrations/20230303120531 @@ -0,0 +1 @@ +6af890fe88f25be54d18cf3b3caa14830a3d627e7ff256d7a4ae03f9f1c7170c
\ No newline at end of file diff --git a/doc/architecture/blueprints/runner_tokens/index.md b/doc/architecture/blueprints/runner_tokens/index.md index b5be1689785..807ce381357 100644 --- a/doc/architecture/blueprints/runner_tokens/index.md +++ b/doc/architecture/blueprints/runner_tokens/index.md @@ -393,12 +393,15 @@ scope. | GitLab Rails app | `%15.9` | Implement new GraphQL user-authenticated API to create a new runner. | | GitLab Rails app | `%15.10` | Return token and runner ID information from `/runners/verify` REST endpoint. | | GitLab Runner | `%15.10` | [Modify register command to allow new flow with glrt- prefixed authentication tokens](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/29613). | +| GitLab Runner | `%15.10` | Make the `gitlab-runner register` command happen in a single operation. | | GitLab Rails app | `%15.10` | Define feature flag and policies for "New Runner creation workflow" for groups and projects. | +| GitLab Rails app | `%15.11` | Only update runner `contacted_at` and `status` when polled for jobs. | | GitLab Rails app | `%15.11` | Update service and mutation to accept groups and projects. | | GitLab Rails app | `%15.11` | Implement UI to create new runner. | | GitLab Rails app | `%15.11` | GraphQL changes to `CiRunner` type. (?) | | GitLab Rails app | `%15.11` | UI changes to runner details view (listing of platform, architecture, IP address, etc.) (?) | -| GitLab Rails app | `%15.11` | Adapt `POST /api/v4/runners` REST endpoint to accept a request from an authorized user with a scope instead of a registration token. || GitLab Rails app | `%15.9` | Implement new GraphQL user-authenticated API to create a new runner. | +| GitLab Rails app | `%15.11` | Adapt `POST /api/v4/runners` REST endpoint to accept a request from an authorized user with a scope instead of a registration token. | +| GitLab Runner | `%15.11` | Handle glrt- runner tokens in `unregister` command. | ### Stage 5 - Optional disabling of registration token diff --git a/doc/ci/pipelines/downstream_pipelines.md b/doc/ci/pipelines/downstream_pipelines.md index 3e1728a6db9..bdc81e64f88 100644 --- a/doc/ci/pipelines/downstream_pipelines.md +++ b/doc/ci/pipelines/downstream_pipelines.md @@ -231,37 +231,43 @@ configuration for jobs that use the Windows runner, like scripts, use <code>\ ### Run child pipelines with merge request pipelines -To trigger a child pipeline as a [merge request pipeline](merge_request_pipelines.md): +Pipelines, including child pipelines, run as branch pipelines by default when not using +[`rules`](../yaml/index.md#rules) or [`workflow:rules`](../yaml/index.md#workflowrules). +To configure child pipelines to run when triggered from a [merge request (parent) pipeline](merge_request_pipelines.md), use `rules` or `workflow:rules`. +For example, using `rules`: -1. Set the trigger job to run on merge requests in the parent pipeline's configuration file: +1. Set the parent pipeline's trigger job to run on merge requests: ```yaml - microservice_a: + trigger-child-pipeline-job: trigger: - include: path/to/microservice_a.yml + include: path/to/child-pipeline-configuration.yml rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" ``` -1. Configure the child pipeline jobs to run in merge request pipelines with [`rules`](../yaml/index.md#rules) - or [`workflow:rules`](../yaml/index.md#workflowrules). - For example, with `rules` in a child pipeline's configuration file: +1. Use `rules` to configure the child pipeline jobs to run when triggered by the parent pipeline: ```yaml job1: - script: echo "Child pipeline job 1" + script: echo "This child pipeline job runs any time the parent pipeline triggers it." rules: - - if: $CI_MERGE_REQUEST_ID + - if: $CI_PIPELINE_SOURCE == "parent_pipeline" job2: - script: echo "Child pipeline job 2" + script: echo "This child pipeline job runs only when the parent pipeline is a merge request pipeline" rules: - if: $CI_MERGE_REQUEST_ID ``` - In child pipelines, `$CI_PIPELINE_SOURCE` always has a value of `parent_pipeline` - and cannot be used to identify merge request pipelines. Use `$CI_MERGE_REQUEST_ID` - instead, which is always present in merge request pipelines. +In child pipelines, `$CI_PIPELINE_SOURCE` always has a value of `parent_pipeline`, so: + +- You can use `if: $CI_PIPELINE_SOURCE == "parent_pipeline"` to ensure child pipeline jobs always run. +- You _can't_ use `if: $CI_PIPELINE_SOURCE == "merge_request_event"` to configure child pipeline + jobs to run for merge request pipelines. Instead, use `if: $CI_MERGE_REQUEST_ID` + to set child pipeline jobs to run only when the parent pipeline is a merge request pipeline. The parent pipeline's + [`CI_MERGE_REQUEST_*` predefined variables](../variables/predefined_variables.md#predefined-variables-for-merge-request-pipelines) + are passed to the child pipeline jobs. ### Specify a branch for multi-project pipelines @@ -657,6 +663,16 @@ With multi-project pipelines, the trigger job fails and does not create the down to run pipelines against the protected branch. See [pipeline security for protected branches](index.md#pipeline-security-on-protected-branches) for more information. +### Job in child pipeline is not created when the pipeline runs + +If the parent pipeline is a [merge request pipeline](merge_request_pipelines.md), +the child pipeline must [use `workflow:rules` or `rules` to ensure the jobs run](#run-child-pipelines-with-merge-request-pipelines). + +If no jobs in the child pipeline can run due to missing or incorrect `rules` configuration: + +- The child pipeline fails to start. +- The parent pipeline's trigger job fails with: `downstream pipeline can not be creaed, Pipeline will not run for the selected trigger. The rules configuration prevented any jobs from being added to the pipeline.` + ### `Ref is ambiguous` You cannot trigger a multi-project pipeline with a tag when a branch exists with the same diff --git a/doc/development/database/required_stops.md b/doc/development/database/required_stops.md index 46fabb5c1b4..b706babbc5e 100644 --- a/doc/development/database/required_stops.md +++ b/doc/development/database/required_stops.md @@ -11,6 +11,36 @@ disruptive effect on customers. Before adding a required stop, consider if any alternative approaches exist to avoid a required stop. Sometimes a required stop is unavoidable. In those cases, follow the instructions below. +## Common scenarios that require stops + +### Long running migrations being finalized + +If a migration takes a long time, it could cause a large number of customers to encounter timeouts +during upgrades. The increased support volume may cause us to introduce a required stop. While any +background migration may cause these issues with particularly large customers, we typically only +introduce stops when the impact is widespread. + +- **Cause:** When an upgrade takes more than an hour, omnibus times out. +- **Mitigation:** Schedule finalization for the first minor version after the next required stop. + +### Improperly finalized background migrations + +You may need to introduce a required stop for mitigation when: + +- A background migration is not finalized, and +- A migration is written that depends on that background migration. + +- **Cause:** The dependent migration may fail if the background migration is incomplete. +- **Mitigation:** Ensure that all background migrations are finalized before authoring dependent migrations. + +### Bugs in migration related tooling + +In a few circumstances, bugs in migration related tooling has required us to introduce stops. While we aim +to prevent these in testing, sometimes they happen. + +- **Cause:** There have been a few different causes where we recognized these too late. +- **Mitigation:** Typically we try to backport fixes for migrations, but in some cases this is not possible. + ## Before the required stop is released Before releasing a known required stop, complete these steps. If the required stop diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md index 5dfad57facf..2d0e642b3ef 100644 --- a/doc/user/group/saml_sso/index.md +++ b/doc/user/group/saml_sso/index.md @@ -52,29 +52,28 @@ If you have any questions on configuring the SAML app, contact your provider's s ### Set up Azure -Follow the Azure documentation on [configuring single sign-on to applications](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/add-application-portal-setup-sso), and use the following notes when needed. +1. [Use Azure to configure SSO for an application](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/add-application-portal-setup-sso). The following GitLab settings correspond to the Azure fields. -<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> -For a demo of the Azure SAML setup including SCIM, see [SCIM Provisioning on Azure Using SAML SSO for Groups Demo](https://youtu.be/24-ZxmTeEBU). -The video is outdated in regard to objectID mapping and you should follow the [SCIM documentation](scim_setup.md#configure-azure-active-directory). - -| GitLab Setting | Azure Field | -| ------------------------------------ | ------------------------------------------ | -| Identifier | Identifier (Entity ID) | -| Assertion consumer service URL | Reply URL (Assertion Consumer Service URL) | -| GitLab single sign-on URL | Sign on URL | -| Identity provider single sign-on URL | Login URL | -| Certificate fingerprint | Thumbprint | + | GitLab setting | Azure field | + | ------------------------------------ | ------------------------------------------ | + | Identifier | Identifier (Entity ID) | + | Assertion consumer service URL | Reply URL (Assertion Consumer Service URL) | + | GitLab single sign-on URL | Sign on URL | + | Identity provider single sign-on URL | Login URL | + | Certificate fingerprint | Thumbprint | -You should set the following attributes: +1. You should set the following attributes: + - **Unique User Identifier (Name identifier)** to `user.objectID`. + - **nameid-format** to persistent. + - **Additional claims** to [supported attributes](#user-attributes). -- **Unique User Identifier (Name identifier)** to `user.objectID`. -- **nameid-format** to persistent. -- Additional claims to [supported attributes](#user-attributes). +1. Optional. If you use [Group Sync](#group-sync), customize the name of the + group claim to match the required attribute. -If using [Group Sync](#group-sync), customize the name of the group claim to match the required attribute. +<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> +View a demo of [SCIM provisioning on Azure using SAML SSO for groups](https://youtu.be/24-ZxmTeEBU). The `objectID` mapping is outdated in this video. Follow the [SCIM documentation](scim_setup.md#configure-azure-active-directory) instead. -See our [example configuration page](example_saml_config.md#azure-active-directory). +View an [example configuration page](example_saml_config.md#azure-active-directory). ### Set up Google Workspace diff --git a/doc/user/markdown.md b/doc/user/markdown.md index cf749bb5b8b..057e080dff0 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -559,25 +559,45 @@ On wikis, you can use the [diagrams.net](https://www.diagrams.net/) editor to create diagrams. You can also edit diagrams previously created with the editor. -To create a diagram: +To create a diagram in the Markdown editor: -1. Select **Insert or edit diagram** in the Markdown editor. +1. Select **Insert or edit diagram** (**{diagram}**) in the editor's toolbar. 1. Use the diagrams.net editor to build the diagram. 1. Select **Save & exit**. A Markdown image declaration pointing to the diagram is inserted in the wiki content. -To edit a diagram: +To edit a diagram in the Markdown editor: 1. Place the Markdown editor’s text field cursor in a Markdown image declaration that contains the diagram. -1. Select **Insert or edit diagram** in the Markdown editor. +1. Select **Insert or edit diagram** (**{diagram}**) in the Markdown editor. 1. Use the diagrams.net editor to edit the diagram. 1. Select **Save & exit**. A Markdown image declaration pointing to the diagram is inserted in the wiki content, replacing the previous diagram. +You can also create and edit diagrams when editing Markdown in the Content Editor. + +To create a diagram in the Content Editor: + +1. Select **More options** (**{plus}**) in the editor’s toolbar. +1. Select **Create or edit diagram** in the dropdown menu. +1. Use the diagrams.net editor to build the diagram. +1. Select **Save & exit**. + +The diagram as visualized in the diagrams.net editor is inserted in the wiki content. + +To edit a diagram in the Content Editor: + +1. Select the diagram that you want to edit. +1. Select **Edit diagram** (**{diagram}**) in the floating toolbar. +1. Use the diagrams.net editor to edit the diagram. +1. Select **Save & exit**. + +The selected diagram is replaced with an updated version. + ## GitLab-specific references GitLab Flavored Markdown renders GitLab-specific references. For example, you can reference diff --git a/lib/gitlab/checks/base_single_checker.rb b/lib/gitlab/checks/base_single_checker.rb index 435f4ccf5ba..755778efa60 100644 --- a/lib/gitlab/checks/base_single_checker.rb +++ b/lib/gitlab/checks/base_single_checker.rb @@ -5,7 +5,7 @@ module Gitlab class BaseSingleChecker < BaseChecker attr_reader :change_access - delegate(*SingleChangeAccess::ATTRIBUTES, to: :change_access) + delegate(*SingleChangeAccess::ATTRIBUTES, :branch_ref?, :tag_ref?, to: :change_access) def initialize(change_access) @change_access = change_access diff --git a/lib/gitlab/checks/changes_access.rb b/lib/gitlab/checks/changes_access.rb index 3676189fe37..194e3f6e938 100644 --- a/lib/gitlab/checks/changes_access.rb +++ b/lib/gitlab/checks/changes_access.rb @@ -89,7 +89,7 @@ module Gitlab @single_changes_accesses ||= changes.map do |change| commits = - if blank_rev?(change[:newrev]) + if !commitish_ref?(change[:ref]) || blank_rev?(change[:newrev]) [] else Gitlab::Lazy.new { commits_for(change[:oldrev], change[:newrev]) } @@ -122,6 +122,14 @@ module Gitlab def blank_rev?(rev) rev.blank? || Gitlab::Git.blank_ref?(rev) end + + # refs/notes/commits contains commits added via `git-notes`. We currently + # have no features that check notes so we can skip them. To future-proof + # we are skipping anything that isn't a branch or tag ref as those are + # the only refs that can contain commits. + def commitish_ref?(ref) + Gitlab::Git.branch_ref?(ref) || Gitlab::Git.tag_ref?(ref) + end end end end diff --git a/lib/gitlab/checks/diff_check.rb b/lib/gitlab/checks/diff_check.rb index d8f5cec8a4a..083c2448a0a 100644 --- a/lib/gitlab/checks/diff_check.rb +++ b/lib/gitlab/checks/diff_check.rb @@ -10,6 +10,10 @@ module Gitlab }.freeze def validate! + # git-notes stores notes history as commits in refs/notes/commits (by + # default but is configurable) so we restrict the diff checks to tag + # and branch refs + return unless tag_ref? || branch_ref? return if deletion? return unless should_run_validations? return if commits.empty? diff --git a/lib/gitlab/checks/single_change_access.rb b/lib/gitlab/checks/single_change_access.rb index 2fd48dfbfe2..9f427e98e55 100644 --- a/lib/gitlab/checks/single_change_access.rb +++ b/lib/gitlab/checks/single_change_access.rb @@ -14,7 +14,9 @@ module Gitlab protocol:, logger:, commits: nil ) @oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref) + @branch_ref = Gitlab::Git.branch_ref?(@ref) @branch_name = Gitlab::Git.branch_name(@ref) + @tag_ref = Gitlab::Git.tag_ref?(@ref) @tag_name = Gitlab::Git.tag_name(@ref) @user_access = user_access @project = project @@ -38,6 +40,14 @@ module Gitlab @commits ||= project.repository.new_commits(newrev) end + def branch_ref? + @branch_ref + end + + def tag_ref? + @tag_ref + end + protected def ref_level_checks diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6a8ed0085a3..3651f944e03 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8151,7 +8151,7 @@ msgstr "" msgid "Cannot import because issues are not available in this project." msgstr "" -msgid "Cannot load the diagram into the draw.io editor" +msgid "Cannot load the diagram into the diagrams.net editor" msgstr "" msgid "Cannot make the epic confidential if it contains non-confidential child epics" @@ -11473,6 +11473,9 @@ msgstr "" msgid "Copy commit SHA" msgstr "" +msgid "Copy diagram URL" +msgstr "" + msgid "Copy environment" msgstr "" @@ -11908,6 +11911,9 @@ msgstr "" msgid "Create or close an issue." msgstr "" +msgid "Create or edit diagram" +msgstr "" + msgid "Create or import your first project" msgstr "" @@ -13544,6 +13550,9 @@ msgstr "" msgid "Delete deploy key" msgstr "" +msgid "Delete diagram" +msgstr "" + msgid "Delete epic" msgstr "" @@ -15343,6 +15352,9 @@ msgstr "" msgid "Edit description" msgstr "" +msgid "Edit diagram description" +msgstr "" + msgid "Edit environment" msgstr "" @@ -43170,6 +43182,9 @@ msgstr "" msgid "The deployment of this job to %{environmentLink} did not succeed." msgstr "" +msgid "The diagrams.net editor could not be loaded." +msgstr "" + msgid "The directory has been successfully created." msgstr "" @@ -43182,9 +43197,6 @@ msgstr "" msgid "The download link will expire in 24 hours." msgstr "" -msgid "The draw.io editor could not be loaded." -msgstr "" - msgid "The environment tiers must be from %{environment_tiers}." msgstr "" @@ -43489,7 +43501,13 @@ msgstr "" msgid "The secret is only available when you create the application or renew the secret." msgstr "" -msgid "The selected image is not a diagram" +msgid "The selected image is not a valid SVG diagram" +msgstr "" + +msgid "The selected image is not an asset uploaded in the application" +msgstr "" + +msgid "The selected image is too large." msgstr "" msgid "The snippet can be accessed without any authentication." diff --git a/package.json b/package.json index 2bf5ac780d9..d01e5e40e63 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@gitlab/at.js": "1.5.7", "@gitlab/favicon-overlay": "2.0.0", "@gitlab/fonts": "^1.2.0", - "@gitlab/svgs": "3.23.0", + "@gitlab/svgs": "3.24.0", "@gitlab/ui": "56.2.0", "@gitlab/visual-review-tools": "1.7.3", "@gitlab/web-ide": "0.0.1-dev-20230223005157", diff --git a/spec/fixtures/diagram.drawio.svg b/spec/fixtures/diagram.drawio.svg new file mode 100644 index 00000000000..3eb6eb29921 --- /dev/null +++ b/spec/fixtures/diagram.drawio.svg @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" + width="177px" height="97px" viewBox="-0.5 -0.5 177 97" + content="<mxfile host="embed.diagrams.net" modified="2022-11-18T14:21:55.551Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36" version="20.5.3" etag="cTK3wL1ch5_8VL-J45NP" type="embed"><diagram id="mWELjHy14aEMRdjyCi3_" name="Page-1">jZLBcoQgDIafhrvItPVcu+1eevLQMyOpMAPGYbFqn75Ygq7d2ZmeSL4kkPyEidrNb14O+h0VWFYWambihZUlr554PFayJFI9FAl03ihK2kFjvoFgThuNgsshMSDaYIYjbLHvoQ0HJr3H6Zj2ifb46iA7uAFNK+0t/TAq6D9TrPwMptP5ZV5QxMmcTOCipcLpCokTE7VHDMlycw12FS/rkupe70S3xjz04T8FZSr4knak2ZSRnZeO2gtLntnj2CtYywomnidtAjSDbNfoFH85Mh2cjR6PJt0KPsB8tzO+zRsXBdBB8EtMoQKRNaMdiSD50644fySmr9SuiEn65G67etchGiRFdnfJf2NXiytOPw==</diagram></mxfile>" + style="background-color: rgb(255, 255, 255);"> + <defs /> + <g> + <rect x="8" y="8" width="160" height="80" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" + pointer-events="all" /> + <g transform="translate(-0.5 -0.5)"> + <switch> + <foreignObject pointer-events="none" width="100%" height="100%" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + style="overflow: visible; text-align: left;"> + <div xmlns="http://www.w3.org/1999/xhtml" + style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 48px; margin-left: 9px;"> + <div data-drawio-colors="color: rgb(0, 0, 0); " + style="box-sizing: border-box; font-size: 0px; text-align: center;"> + <div + style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;"> + diagram</div> + </div> + </div> + </foreignObject> + <text x="88" y="52" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" + text-anchor="middle">diagram</text> + </switch> + </g> + </g> + <switch> + <g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" /> + <a transform="translate(0,-5)" + xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank"> + <text text-anchor="middle" font-size="10px" x="50%" y="100%">Text is not SVG - cannot display</text> + </a> + </switch> +</svg> diff --git a/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js index 13c6495ac41..215fe02b805 100644 --- a/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js @@ -4,22 +4,28 @@ import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue'; import { stubComponent } from 'helpers/stub_component'; import eventHubFactory from '~/helpers/event_hub_factory'; -import Image from '~/content_editor/extensions/image'; import Audio from '~/content_editor/extensions/audio'; +import DrawioDiagram from '~/content_editor/extensions/drawio_diagram'; +import Image from '~/content_editor/extensions/image'; import Video from '~/content_editor/extensions/video'; import { createTestEditor, emitEditorEvent, mockChainedCommands } from '../../test_utils'; import { PROJECT_WIKI_ATTACHMENT_IMAGE_HTML, PROJECT_WIKI_ATTACHMENT_AUDIO_HTML, PROJECT_WIKI_ATTACHMENT_VIDEO_HTML, + PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML, } from '../../test_constants'; -const TIPTAP_IMAGE_HTML = `<p> +const TIPTAP_AUDIO_HTML = `<p> + <span class="media-container audio-container"><audio src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></audio><a href="https://gitlab.com/favicon.png">gitlab favicon</a></span> +</p>`; + +const TIPTAP_DIAGRAM_HTML = `<p> <img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon"> </p>`; -const TIPTAP_AUDIO_HTML = `<p> - <span class="media-container audio-container"><audio src="https://gitlab.com/favicon.png" controls="true" data-setup="{}" data-title="gitlab favicon"></audio><a href="https://gitlab.com/favicon.png">gitlab favicon</a></span> +const TIPTAP_IMAGE_HTML = `<p> + <img src="https://gitlab.com/favicon.png" alt="gitlab favicon" title="gitlab favicon"> </p>`; const TIPTAP_VIDEO_HTML = `<p> @@ -29,10 +35,11 @@ const TIPTAP_VIDEO_HTML = `<p> const createFakeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); describe.each` - mediaType | mediaHTML | filePath | mediaOutputHTML - ${'image'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${'test-file.png'} | ${TIPTAP_IMAGE_HTML} - ${'audio'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${'test-file.mp3'} | ${TIPTAP_AUDIO_HTML} - ${'video'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${'test-file.mp4'} | ${TIPTAP_VIDEO_HTML} + mediaType | mediaHTML | filePath | mediaOutputHTML + ${'image'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${'test-file.png'} | ${TIPTAP_IMAGE_HTML} + ${'drawio_diagram'} | ${PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML} | ${'test-file.drawio.svg'} | ${TIPTAP_DIAGRAM_HTML} + ${'audio'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${'test-file.mp3'} | ${TIPTAP_AUDIO_HTML} + ${'video'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${'test-file.mp4'} | ${TIPTAP_VIDEO_HTML} `( 'content_editor/components/bubble_menus/media_bubble_menu ($mediaType)', ({ mediaType, mediaHTML, filePath, mediaOutputHTML }) => { @@ -43,7 +50,7 @@ describe.each` let eventHub; const buildEditor = () => { - tiptapEditor = createTestEditor({ extensions: [Image, Audio, Video] }); + tiptapEditor = createTestEditor({ extensions: [Image, Audio, Video, DrawioDiagram] }); contentEditor = { resolveUrl: jest.fn() }; eventHub = eventHubFactory(); }; @@ -114,6 +121,24 @@ describe.each` expect(link.text()).toBe(filePath); }); + describe('when BubbleMenu emits hidden event', () => { + it('resets media bubble menu state', async () => { + // Switch to edit mode to access component state in form fields + await wrapper.findByTestId('edit-media').vm.$emit('click'); + + const mediaSrcInput = wrapper.findByTestId('media-src').vm.$el; + const mediaAltInput = wrapper.findByTestId('media-alt').vm.$el; + + expect(mediaSrcInput.value).not.toBe(''); + expect(mediaAltInput.value).not.toBe(''); + + await wrapper.findComponent(BubbleMenu).vm.$emit('hidden'); + + expect(mediaSrcInput.value).toBe(''); + expect(mediaAltInput.value).toBe(''); + }); + }); + describe('copy button', () => { it(`copies the canonical link to the ${mediaType} to clipboard`, async () => { jest.spyOn(navigator.clipboard, 'writeText'); @@ -133,23 +158,39 @@ describe.each` }); describe(`replace ${mediaType} button`, () => { - it('uploads and replaces the selected image when file input changes', async () => { - const commands = mockChainedCommands(tiptapEditor, [ - 'focus', - 'deleteSelection', - 'uploadAttachment', - 'run', - ]); - const file = new File(['foo'], 'foo.png', { type: 'image/png' }); - - await wrapper.findByTestId('replace-media').vm.$emit('click'); - await selectFile(file); - - expect(commands.focus).toHaveBeenCalled(); - expect(commands.deleteSelection).toHaveBeenCalled(); - expect(commands.uploadAttachment).toHaveBeenCalledWith({ file }); - expect(commands.run).toHaveBeenCalled(); - }); + if (mediaType !== 'drawio_diagram') { + it('uploads and replaces the selected image when file input changes', async () => { + const commands = mockChainedCommands(tiptapEditor, [ + 'focus', + 'deleteSelection', + 'uploadAttachment', + 'run', + ]); + const file = new File(['foo'], 'foo.png', { type: 'image/png' }); + + await wrapper.findByTestId('replace-media').vm.$emit('click'); + await selectFile(file); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.deleteSelection).toHaveBeenCalled(); + expect(commands.uploadAttachment).toHaveBeenCalledWith({ file }); + expect(commands.run).toHaveBeenCalled(); + }); + } else { + // draw.io diagrams are replaced using the edit diagram button + it('invokes editDiagram command', async () => { + const commands = mockChainedCommands(tiptapEditor, [ + 'focus', + 'createOrEditDiagram', + 'run', + ]); + await wrapper.findByTestId('edit-diagram').vm.$emit('click'); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.createOrEditDiagram).toHaveBeenCalled(); + expect(commands.run).toHaveBeenCalled(); + }); + } }); describe('edit button', () => { diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js index d4fc47601cf..5dc4288a169 100644 --- a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js @@ -40,16 +40,17 @@ describe('content_editor/components/toolbar_more_dropdown', () => { }); describe.each` - name | contentType | command | params - ${'Code block'} | ${'codeBlock'} | ${'setNode'} | ${['codeBlock']} - ${'Details block'} | ${'details'} | ${'toggleList'} | ${['details', 'detailsContent']} - ${'Bullet list'} | ${'bulletList'} | ${'toggleList'} | ${['bulletList', 'listItem']} - ${'Ordered list'} | ${'orderedList'} | ${'toggleList'} | ${['orderedList', 'listItem']} - ${'Task list'} | ${'taskList'} | ${'toggleList'} | ${['taskList', 'taskItem']} - ${'Mermaid diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'mermaid' }]} - ${'PlantUML diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'plantuml' }]} - ${'Table of contents'} | ${'tableOfContents'} | ${'insertTableOfContents'} | ${[]} - ${'Horizontal rule'} | ${'horizontalRule'} | ${'setHorizontalRule'} | ${[]} + name | contentType | command | params + ${'Code block'} | ${'codeBlock'} | ${'setNode'} | ${['codeBlock']} + ${'Details block'} | ${'details'} | ${'toggleList'} | ${['details', 'detailsContent']} + ${'Bullet list'} | ${'bulletList'} | ${'toggleList'} | ${['bulletList', 'listItem']} + ${'Ordered list'} | ${'orderedList'} | ${'toggleList'} | ${['orderedList', 'listItem']} + ${'Task list'} | ${'taskList'} | ${'toggleList'} | ${['taskList', 'taskItem']} + ${'Mermaid diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'mermaid' }]} + ${'PlantUML diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'plantuml' }]} + ${'Table of contents'} | ${'tableOfContents'} | ${'insertTableOfContents'} | ${[]} + ${'Horizontal rule'} | ${'horizontalRule'} | ${'setHorizontalRule'} | ${[]} + ${'Create or edit diagram'} | ${'drawioDiagram'} | ${'createOrEditDiagram'} | ${[]} `('when option $name is clicked', ({ name, command, contentType, params }) => { let commands; let btn; diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index 6b804b3b4c6..d02184f143f 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -2,6 +2,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import Attachment from '~/content_editor/extensions/attachment'; +import DrawioDiagram from '~/content_editor/extensions/drawio_diagram'; import Image from '~/content_editor/extensions/image'; import Audio from '~/content_editor/extensions/audio'; import Video from '~/content_editor/extensions/video'; @@ -16,6 +17,7 @@ import { PROJECT_WIKI_ATTACHMENT_AUDIO_HTML, PROJECT_WIKI_ATTACHMENT_VIDEO_HTML, PROJECT_WIKI_ATTACHMENT_LINK_HTML, + PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML, } from '../test_constants'; describe('content_editor/extensions/attachment', () => { @@ -24,6 +26,7 @@ describe('content_editor/extensions/attachment', () => { let p; let image; let audio; + let drawioDiagram; let video; let loading; let link; @@ -35,6 +38,7 @@ describe('content_editor/extensions/attachment', () => { const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); const audioFile = new File(['foo'], 'test-file.mp3', { type: 'audio/mpeg' }); const videoFile = new File(['foo'], 'test-file.mp4', { type: 'video/mp4' }); + const drawioDiagramFile = new File(['foo'], 'test-file.drawio.svg', { type: 'image/svg+xml' }); const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' }); const expectDocumentAfterTransaction = ({ number, expectedDoc, action }) => { @@ -67,12 +71,13 @@ describe('content_editor/extensions/attachment', () => { Image, Audio, Video, + DrawioDiagram, Attachment.configure({ renderMarkdown, uploadsPath, eventHub }), ], }); ({ - builders: { doc, p, image, audio, video, loading, link }, + builders: { doc, p, image, audio, video, loading, link, drawioDiagram }, } = createDocBuilder({ tiptapEditor, names: { @@ -81,6 +86,7 @@ describe('content_editor/extensions/attachment', () => { link: { nodeType: Link.name }, audio: { nodeType: Audio.name }, video: { nodeType: Video.name }, + drawioDiagram: { nodeType: DrawioDiagram.name }, }, })); @@ -113,10 +119,11 @@ describe('content_editor/extensions/attachment', () => { }); describe.each` - nodeType | mimeType | html | file | mediaType - ${'image'} | ${'image/png'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${imageFile} | ${(attrs) => image(attrs)} - ${'audio'} | ${'audio/mpeg'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${audioFile} | ${(attrs) => audio(attrs)} - ${'video'} | ${'video/mp4'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${videoFile} | ${(attrs) => video(attrs)} + nodeType | mimeType | html | file | mediaType + ${'image'} | ${'image/png'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${imageFile} | ${(attrs) => image(attrs)} + ${'audio'} | ${'audio/mpeg'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${audioFile} | ${(attrs) => audio(attrs)} + ${'video'} | ${'video/mp4'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${videoFile} | ${(attrs) => video(attrs)} + ${'drawioDiagram'} | ${'image/svg+xml'} | ${PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML} | ${drawioDiagramFile} | ${(attrs) => drawioDiagram(attrs)} `('when the file has $nodeType mime type', ({ mimeType, html, file, mediaType }) => { const base64EncodedFile = `data:${mimeType};base64,Zm9v`; @@ -151,7 +158,7 @@ describe('content_editor/extensions/attachment', () => { mediaType({ canonicalSrc: file.name, src: base64EncodedFile, - alt: 'test-file', + alt: expect.stringContaining('test-file'), uploading: false, }), ), diff --git a/spec/frontend/content_editor/extensions/drawio_diagram_spec.js b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js new file mode 100644 index 00000000000..61dc164c99a --- /dev/null +++ b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js @@ -0,0 +1,103 @@ +import DrawioDiagram from '~/content_editor/extensions/drawio_diagram'; +import Image from '~/content_editor/extensions/image'; +import createAssetResolver from '~/content_editor/services/asset_resolver'; +import { create } from '~/drawio/content_editor_facade'; +import { launchDrawioEditor } from '~/drawio/drawio_editor'; +import { createTestEditor, createDocBuilder } from '../test_utils'; +import { + PROJECT_WIKI_ATTACHMENT_IMAGE_HTML, + PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML, +} from '../test_constants'; + +jest.mock('~/content_editor/services/asset_resolver'); +jest.mock('~/drawio/content_editor_facade'); +jest.mock('~/drawio/drawio_editor'); + +describe('content_editor/extensions/drawio_diagram', () => { + let tiptapEditor; + let doc; + let paragraph; + let image; + let drawioDiagram; + const uploadsPath = '/uploads'; + const renderMarkdown = () => {}; + + beforeEach(() => { + tiptapEditor = createTestEditor({ + extensions: [Image, DrawioDiagram.configure({ uploadsPath, renderMarkdown })], + }); + const { builders } = createDocBuilder({ + tiptapEditor, + names: { + image: { nodeType: Image.name }, + drawioDiagram: { nodeType: DrawioDiagram.name }, + }, + }); + + doc = builders.doc; + paragraph = builders.paragraph; + image = builders.image; + drawioDiagram = builders.drawioDiagram; + }); + + describe('parsing', () => { + it('distinguishes a drawio diagram from an image', () => { + const expectedDocWithDiagram = doc( + paragraph( + drawioDiagram({ + alt: 'test-file', + canonicalSrc: 'test-file.drawio.svg', + src: '/group1/project1/-/wikis/test-file.drawio.svg', + }), + ), + ); + const expectedDocWithImage = doc( + paragraph( + image({ + alt: 'test-file', + canonicalSrc: 'test-file.png', + src: '/group1/project1/-/wikis/test-file.png', + }), + ), + ); + tiptapEditor.commands.setContent(PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDocWithDiagram.toJSON()); + + tiptapEditor.commands.setContent(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDocWithImage.toJSON()); + }); + }); + + describe('createOrEditDiagram command', () => { + let editorFacade; + let assetResolver; + + beforeEach(() => { + editorFacade = {}; + assetResolver = {}; + tiptapEditor.commands.createOrEditDiagram(); + + create.mockReturnValueOnce(editorFacade); + createAssetResolver.mockReturnValueOnce(assetResolver); + }); + + it('creates a new instance of asset resolver', () => { + expect(createAssetResolver).toHaveBeenCalledWith({ renderMarkdown }); + }); + + it('creates a new instance of the content_editor_facade', () => { + expect(create).toHaveBeenCalledWith({ + tiptapEditor, + drawioNodeName: DrawioDiagram.name, + uploadsPath, + assetResolver, + }); + }); + + it('calls launchDrawioEditor and provides content_editor_facade', () => { + expect(launchDrawioEditor).toHaveBeenCalledWith({ editorFacade }); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js index e1a30819ac8..bb86e12c0b0 100644 --- a/spec/frontend/content_editor/services/create_content_editor_spec.js +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -82,4 +82,14 @@ describe('content_editor/services/create_content_editor', () => { renderMarkdown, }); }); + + it('provides uploadsPath and renderMarkdown function to DrawioDiagram extension', () => { + expect( + editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'drawioDiagram') + .options, + ).toMatchObject({ + uploadsPath, + renderMarkdown, + }); + }); }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 2cd8b8a0d6f..c4d302547a5 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -8,6 +8,7 @@ import DescriptionItem from '~/content_editor/extensions/description_item'; import DescriptionList from '~/content_editor/extensions/description_list'; import Details from '~/content_editor/extensions/details'; import DetailsContent from '~/content_editor/extensions/details_content'; +import DrawioDiagram from '~/content_editor/extensions/drawio_diagram'; import Emoji from '~/content_editor/extensions/emoji'; import Figure from '~/content_editor/extensions/figure'; import FigureCaption from '~/content_editor/extensions/figure_caption'; @@ -57,6 +58,7 @@ const { div, descriptionItem, descriptionList, + drawioDiagram, emoji, footnoteDefinition, footnoteReference, @@ -96,6 +98,7 @@ const { detailsContent: { nodeType: DetailsContent.name }, descriptionItem: { nodeType: DescriptionItem.name }, descriptionList: { nodeType: DescriptionList.name }, + drawioDiagram: { nodeType: DrawioDiagram.name }, emoji: { markType: Emoji.name }, figure: { nodeType: Figure.name }, figureCaption: { nodeType: FigureCaption.name }, @@ -397,6 +400,12 @@ this is not really json:table but just trying out whether this case works or not ); }); + it('correctly serializes a drawio_diagram', () => { + expect( + serialize(paragraph(drawioDiagram({ src: 'diagram.drawio.svg', alt: 'Draw.io Diagram' }))), + ).toBe('![Draw.io Diagram](diagram.drawio.svg)'); + }); + it.each` width | height | outputAttributes ${300} | ${undefined} | ${'width=300'} diff --git a/spec/frontend/content_editor/test_constants.js b/spec/frontend/content_editor/test_constants.js index 45a0e4a8bd1..bd462ecec22 100644 --- a/spec/frontend/content_editor/test_constants.js +++ b/spec/frontend/content_editor/test_constants.js @@ -20,6 +20,12 @@ export const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74" </span> </p>`; +export const PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML = `<p data-sourcepos="1:1-1:27" dir="auto"> + <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.drawio.svg" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.drawio.svg"> + <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.drawio.svg" data-canonical-src="test-file.drawio.svg"> + </a> +</p>`; + export const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> </p>`; diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index 0fa0e65cd26..16f90a15c24 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -17,6 +17,7 @@ import DescriptionList from '~/content_editor/extensions/description_list'; import Details from '~/content_editor/extensions/details'; import DetailsContent from '~/content_editor/extensions/details_content'; import Diagram from '~/content_editor/extensions/diagram'; +import DrawioDiagram from '~/content_editor/extensions/drawio_diagram'; import Emoji from '~/content_editor/extensions/emoji'; import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; import FootnoteReference from '~/content_editor/extensions/footnote_reference'; @@ -218,6 +219,7 @@ export const createTiptapEditor = (extensions = []) => DescriptionList, Details, DetailsContent, + DrawioDiagram, Diagram, Emoji, FootnoteDefinition, diff --git a/spec/frontend/drawio/content_editor_facade_spec.js b/spec/frontend/drawio/content_editor_facade_spec.js new file mode 100644 index 00000000000..673968bac9f --- /dev/null +++ b/spec/frontend/drawio/content_editor_facade_spec.js @@ -0,0 +1,138 @@ +import AxiosMockAdapter from 'axios-mock-adapter'; +import { create } from '~/drawio/content_editor_facade'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import DrawioDiagram from '~/content_editor/extensions/drawio_diagram'; +import axios from '~/lib/utils/axios_utils'; +import { PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML } from '../content_editor/test_constants'; +import { createTestEditor } from '../content_editor/test_utils'; + +describe('drawio/contentEditorFacade', () => { + let tiptapEditor; + let axiosMock; + let contentEditorFacade; + let assetResolver; + const imageURL = '/group1/project1/-/wikis/test-file.drawio.svg'; + const diagramSvg = '<svg></svg>'; + const contentType = 'image/svg+xml'; + const filename = 'test-file.drawio.svg'; + const uploadsPath = '/uploads'; + const canonicalSrc = '/new-diagram.drawio.svg'; + const src = `/uploads${canonicalSrc}`; + + beforeEach(() => { + assetResolver = { + resolveUrl: jest.fn(), + }; + tiptapEditor = createTestEditor({ extensions: [DrawioDiagram] }); + contentEditorFacade = create({ + tiptapEditor, + drawioNodeName: DrawioDiagram.name, + uploadsPath, + assetResolver, + }); + }); + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + tiptapEditor.destroy(); + }); + + describe('getDiagram', () => { + describe('when there is a selected diagram', () => { + beforeEach(() => { + tiptapEditor + .chain() + .setContent(PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML) + .setNodeSelection(1) + .run(); + axiosMock + .onGet(imageURL) + .reply(HTTP_STATUS_OK, diagramSvg, { 'content-type': contentType }); + }); + + it('returns diagram information', async () => { + const diagram = await contentEditorFacade.getDiagram(); + + expect(diagram).toEqual({ + diagramURL: imageURL, + filename, + diagramSvg, + contentType, + }); + }); + }); + + describe('when there is not a selected diagram', () => { + beforeEach(() => { + tiptapEditor.chain().setContent('<p>text</p>').setNodeSelection(1).run(); + }); + + it('returns null', async () => { + const diagram = await contentEditorFacade.getDiagram(); + + expect(diagram).toBe(null); + }); + }); + }); + + describe('updateDiagram', () => { + beforeEach(() => { + tiptapEditor + .chain() + .setContent(PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML) + .setNodeSelection(1) + .run(); + + assetResolver.resolveUrl.mockReturnValueOnce(src); + contentEditorFacade.updateDiagram({ uploadResults: { file_path: canonicalSrc } }); + }); + + it('updates selected diagram diagram node src and canonicalSrc', () => { + tiptapEditor.commands.setNodeSelection(1); + expect(tiptapEditor.state.selection.node.attrs).toMatchObject({ + src, + canonicalSrc, + }); + }); + }); + + describe('insertDiagram', () => { + beforeEach(() => { + tiptapEditor.chain().setContent('<p></p>').run(); + + assetResolver.resolveUrl.mockReturnValueOnce(src); + contentEditorFacade.insertDiagram({ uploadResults: { file_path: canonicalSrc } }); + }); + + it('inserts a new draw.io diagram in the document', () => { + tiptapEditor.commands.setNodeSelection(1); + expect(tiptapEditor.state.selection.node.attrs).toMatchObject({ + src, + canonicalSrc, + }); + }); + }); + + describe('uploadDiagram', () => { + it('sends a post request to the uploadsPath containing the diagram svg', async () => { + const link = { markdown: '![](diagram.drawio.svg)' }; + const blob = new Blob([diagramSvg], { type: 'image/svg+xml' }); + const formData = new FormData(); + + formData.append('file', blob, filename); + + axiosMock.onPost(uploadsPath, formData).reply(HTTP_STATUS_OK, { + data: { + link, + }, + }); + + const response = await contentEditorFacade.uploadDiagram({ diagramSvg, filename }); + + expect(response).not.toBe(link); + }); + }); +}); diff --git a/spec/frontend/drawio/drawio_editor_spec.js b/spec/frontend/drawio/drawio_editor_spec.js index bcf0179e2e2..5ef26c04204 100644 --- a/spec/frontend/drawio/drawio_editor_spec.js +++ b/spec/frontend/drawio/drawio_editor_spec.js @@ -4,6 +4,7 @@ import { DRAWIO_FRAME_ID, DIAGRAM_BACKGROUND_COLOR, DRAWIO_IFRAME_TIMEOUT, + DIAGRAM_MAX_SIZE, } from '~/drawio/constants'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; @@ -14,8 +15,10 @@ jest.useFakeTimers(); describe('drawio/drawio_editor', () => { let editorFacade; let drawioIFrameReceivedMessages; + const diagramURL = `${window.location.origin}/uploads/diagram.drawio.svg`; const testSvg = '<svg></svg>'; const testEncodedSvg = `data:image/svg+xml;base64,${btoa(testSvg)}`; + const filename = 'diagram.drawio.svg'; const findDrawioIframe = () => document.getElementById(DRAWIO_FRAME_ID); const waitForDrawioIFrameMessage = ({ messageNumber = 1 } = {}) => @@ -71,6 +74,10 @@ describe('drawio/drawio_editor', () => { it('creates the drawio editor iframe and attaches it to the body', () => { expect(findDrawioIframe().getAttribute('src')).toBe(DRAWIO_EDITOR_URL); }); + + it('sets drawio-editor classname to the iframe', () => { + expect(findDrawioIframe().classList).toContain('drawio-editor'); + }); }); describe(`when parent window does not receive configure event after ${DRAWIO_IFRAME_TIMEOUT} ms`, () => { @@ -88,7 +95,7 @@ describe('drawio/drawio_editor', () => { jest.runAllTimers(); expect(createAlert).toHaveBeenCalledWith({ - message: 'The draw.io editor could not be loaded.', + message: 'The diagrams.net editor could not be loaded.', }); }); }); @@ -149,10 +156,10 @@ describe('drawio/drawio_editor', () => { describe('when there is a diagram selected', () => { const diagramSvg = '<svg></svg>'; - const filename = 'diagram.drawio.svg'; beforeEach(() => { editorFacade.getDiagram.mockResolvedValueOnce({ + diagramURL, diagramSvg, filename, contentType: 'image/svg+xml', @@ -177,14 +184,43 @@ describe('drawio/drawio_editor', () => { }, }); }); + + it('sets the drawio iframe as visible and resets cursor', async () => { + await waitForDrawioIFrameMessage(); + + expect(findDrawioIframe().style.visibility).toBe('visible'); + expect(findDrawioIframe().style.cursor).toBe(''); + }); + + it('scrolls window to the top', async () => { + await waitForDrawioIFrameMessage(); + + expect(window.scrollX).toBe(0); + }); }); - describe('when there is an image selected that is not a diagram', () => { + describe.each` + description | errorMessage | diagram + ${'when there is an image selected that is not an svg file'} | ${'The selected image is not a valid SVG diagram'} | ${{ + diagramURL, + contentType: 'image/png', + filename: 'image.png', +}} + ${'when the selected image is not an asset upload'} | ${'The selected image is not an asset uploaded in the application'} | ${{ + diagramSvg: '<svg></svg>', + filename, + contentType: 'image/svg+xml', + diagramURL: 'https://example.com/image.drawio.svg', +}} + ${'when the selected image is too large'} | ${'The selected image is too large.'} | ${{ + diagramSvg: 'x'.repeat(DIAGRAM_MAX_SIZE + 1), + filename, + contentType: 'image/svg+xml', + diagramURL, +}} + `('$description', ({ errorMessage, diagram }) => { beforeEach(() => { - editorFacade.getDiagram.mockResolvedValueOnce({ - contentType: 'image/png', - filename: 'image.png', - }); + editorFacade.getDiagram.mockResolvedValueOnce(diagram); launchDrawioEditor({ editorFacade }); @@ -193,7 +229,7 @@ describe('drawio/drawio_editor', () => { it('displays an error alert indicating that the image is not a diagram', async () => { expect(createAlert).toHaveBeenCalledWith({ - message: 'The selected image is not a diagram', + message: errorMessage, error: expect.any(Error), }); }); @@ -214,7 +250,7 @@ describe('drawio/drawio_editor', () => { it('displays an error alert indicating the failure', async () => { expect(createAlert).toHaveBeenCalledWith({ - message: 'Cannot load the diagram into the draw.io editor', + message: 'Cannot load the diagram into the diagrams.net editor', error: expect.any(Error), }); }); diff --git a/spec/frontend/drawio/markdown_field_editor_facade_spec.js b/spec/frontend/drawio/markdown_field_editor_facade_spec.js index 992dcf0017c..e3eafc63839 100644 --- a/spec/frontend/drawio/markdown_field_editor_facade_spec.js +++ b/spec/frontend/drawio/markdown_field_editor_facade_spec.js @@ -57,6 +57,7 @@ describe('drawio/textareaMarkdownEditor', () => { ); expect(diagram).toEqual({ + diagramURL: imageURL, diagramMarkdown, filename, diagramSvg, diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js index f798f87b6b2..1c604318c72 100644 --- a/spec/frontend/issuable/components/csv_export_modal_spec.js +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -17,7 +17,7 @@ describe('CsvExportModal', () => { ...props, }, provide: { - issuableType: 'issues', + issuableType: 'issue', ...injectedProperties, }, stubs: { @@ -38,10 +38,10 @@ describe('CsvExportModal', () => { describe('template', () => { describe.each` - issuableType | modalTitle - ${'issues'} | ${'Export issues'} - ${'merge-requests'} | ${'Export merge requests'} - `('with the issuableType "$issuableType"', ({ issuableType, modalTitle }) => { + issuableType | modalTitle | dataTrackLabel + ${'issue'} | ${'Export issues'} | ${'export_issues_csv'} + ${'merge_request'} | ${'Export merge requests'} | ${'export_merge-requests_csv'} + `('with the issuableType "$issuableType"', ({ issuableType, modalTitle, dataTrackLabel }) => { beforeEach(() => { wrapper = createComponent({ injectedProperties: { issuableType } }); }); @@ -57,9 +57,9 @@ describe('CsvExportModal', () => { href: 'export/csv/path', variant: 'confirm', 'data-method': 'post', - 'data-qa-selector': `export_${issuableType}_button`, + 'data-qa-selector': `export_issues_button`, 'data-track-action': 'click_button', - 'data-track-label': `export_${issuableType}_csv`, + 'data-track-label': dataTrackLabel, }, }); }); diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js index c933ed5c3e1..265f72ff344 100644 --- a/spec/frontend/jobs/components/log/log_spec.js +++ b/spec/frontend/jobs/components/log/log_spec.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import Log from '~/jobs/components/log/log.vue'; +import LogLineHeader from '~/jobs/components/log/line_header.vue'; import { logLinesParser } from '~/jobs/store/utils'; import { jobLog } from './mock_data'; @@ -10,6 +11,7 @@ describe('Job Log', () => { let actions; let state; let store; + let toggleCollapsibleLineMock; Vue.use(Vuex); @@ -20,8 +22,9 @@ describe('Job Log', () => { }; beforeEach(() => { + toggleCollapsibleLineMock = jest.fn(); actions = { - toggleCollapsibleLine: () => {}, + toggleCollapsibleLine: toggleCollapsibleLineMock, }; state = { @@ -37,11 +40,7 @@ describe('Job Log', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - - const findCollapsibleLine = () => wrapper.find('.collapsible-line'); + const findCollapsibleLine = () => wrapper.findComponent(LogLineHeader); describe('line numbers', () => { it('renders a line number for each open line', () => { @@ -68,11 +67,9 @@ describe('Job Log', () => { describe('on click header section', () => { it('calls toggleCollapsibleLine', () => { - jest.spyOn(wrapper.vm, 'toggleCollapsibleLine'); - findCollapsibleLine().trigger('click'); - expect(wrapper.vm.toggleCollapsibleLine).toHaveBeenCalled(); + expect(toggleCollapsibleLineMock).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js index 4a27f8011df..7eae5b77158 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js @@ -29,10 +29,13 @@ password = <your personal access token>`; const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); const findSetupDocsLink = () => wrapper.findByTestId('pypi-docs-link'); - function createComponent() { + function createComponent(props = {}) { wrapper = mountExtended(PypiInstallation, { propsData: { - packageEntity, + packageEntity: { + ...packageEntity, + ...props, + }, }, stubs: { GlSprintf, @@ -86,6 +89,12 @@ password = <your personal access token>`; }); }); + it('does not have a link to personal access token docs when package is public', () => { + createComponent({ publicPackage: true }); + + expect(findAccessTokenLink().exists()).toBe(false); + }); + it('has a link to the docs', () => { expect(findSetupDocsLink().attributes()).toMatchObject({ href: PYPI_HELP_PATH, diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index d897be1f344..19c098e1f82 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -147,6 +147,7 @@ export const packageData = (extend) => ({ conanUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/conan', pypiUrl: 'http://__token__:<your_personal_token>@gdk.test:3000/api/v4/projects/1/packages/pypi/simple', + publicPackage: false, pypiSetupUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/pypi', ...extend, }); diff --git a/spec/frontend/zen_mode_spec.js b/spec/frontend/zen_mode_spec.js index 85f1dbdc305..025a92464f1 100644 --- a/spec/frontend/zen_mode_spec.js +++ b/spec/frontend/zen_mode_spec.js @@ -15,6 +15,8 @@ describe('ZenMode', () => { let dropzoneForElementSpy; const fixtureName = 'snippets/show.html'; + const getTextarea = () => $('.notes-form textarea'); + function enterZen() { $('.notes-form .js-zen-enter').click(); } @@ -24,7 +26,7 @@ describe('ZenMode', () => { } function escapeKeydown() { - $('.notes-form textarea').trigger( + getTextarea().trigger( $.Event('keydown', { keyCode: 27, }), @@ -50,6 +52,12 @@ describe('ZenMode', () => { }); afterEach(() => { + $(document).off('click', '.js-zen-enter'); + $(document).off('click', '.js-zen-leave'); + $(document).off('zen_mode:enter'); + $(document).off('zen_mode:leave'); + $(document).off('keydown'); + resetHTMLFixture(); }); @@ -62,14 +70,14 @@ describe('ZenMode', () => { $('.div-dropzone').addClass('js-invalid-dropzone'); exitZen(); - expect(dropzoneForElementSpy.mock.calls.length).toEqual(0); + expect(dropzoneForElementSpy).not.toHaveBeenCalled(); }); it('should call dropzone if element is dropzone valid', () => { $('.div-dropzone').removeClass('js-invalid-dropzone'); exitZen(); - expect(dropzoneForElementSpy.mock.calls.length).toEqual(2); + expect(dropzoneForElementSpy).toHaveBeenCalledTimes(1); }); }); @@ -82,10 +90,10 @@ describe('ZenMode', () => { }); it('removes textarea styling', () => { - $('.notes-form textarea').attr('style', 'height: 400px'); + getTextarea().attr('style', 'height: 400px'); enterZen(); - expect($('.notes-form textarea')).not.toHaveAttr('style'); + expect(getTextarea()).not.toHaveAttr('style'); }); }); @@ -116,4 +124,15 @@ describe('ZenMode', () => { expect(utils.scrollToElement).toHaveBeenCalled(); }); }); + + it('restores textarea style', () => { + const style = 'color: red; overflow-y: hidden;'; + getTextarea().attr('style', style); + expect(getTextarea()).toHaveAttr('style', style); + + enterZen(); + exitZen(); + + expect(getTextarea()).toHaveAttr('style', style); + }); }); diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb index fc69aee4e04..b6546a2eaf3 100644 --- a/spec/helpers/packages_helper_spec.rb +++ b/spec/helpers/packages_helper_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe PackagesHelper do +RSpec.describe PackagesHelper, feature_category: :package_registry do using RSpec::Parameterized::TableSyntax let_it_be_with_reload(:project) { create(:project) } @@ -38,11 +38,18 @@ RSpec.describe PackagesHelper do describe '#pypi_registry_url' do let_it_be(:base_url_with_token) { base_url.sub('://', '://__token__:<your_personal_token>@') } + let_it_be(:public_project) { create(:project, :public) } - it 'returns the pypi registry url' do - url = helper.pypi_registry_url(1) + it 'returns the pypi registry url with token when project is private' do + url = helper.pypi_registry_url(project) - expect(url).to eq("#{base_url_with_token}projects/1/packages/pypi/simple") + expect(url).to eq("#{base_url_with_token}projects/#{project.id}/packages/pypi/simple") + end + + it 'returns the pypi registry url without token when project is public' do + url = helper.pypi_registry_url(public_project) + + expect(url).to eq("#{base_url}projects/#{public_project.id}/packages/pypi/simple") end end diff --git a/spec/lib/gitlab/checks/changes_access_spec.rb b/spec/lib/gitlab/checks/changes_access_spec.rb index 60118823b5a..552afcdb180 100644 --- a/spec/lib/gitlab/checks/changes_access_spec.rb +++ b/spec/lib/gitlab/checks/changes_access_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Checks::ChangesAccess do +RSpec.describe Gitlab::Checks::ChangesAccess, feature_category: :source_code_management do include_context 'changes access checks context' subject { changes_access } @@ -47,6 +47,16 @@ RSpec.describe Gitlab::Checks::ChangesAccess do expect(subject.commits).to match_array([]) end + context 'when change is for notes ref' do + let(:changes) do + [{ oldrev: oldrev, newrev: newrev, ref: 'refs/notes/commit' }] + end + + it 'does not return any commits' do + expect(subject.commits).to match_array([]) + end + end + context 'when changes contain empty revisions' do let(:expected_commit) { instance_double(Commit) } diff --git a/spec/lib/gitlab/checks/diff_check_spec.rb b/spec/lib/gitlab/checks/diff_check_spec.rb index 6b45b8d4628..0845c746545 100644 --- a/spec/lib/gitlab/checks/diff_check_spec.rb +++ b/spec/lib/gitlab/checks/diff_check_spec.rb @@ -2,10 +2,20 @@ require 'spec_helper' -RSpec.describe Gitlab::Checks::DiffCheck do +RSpec.describe Gitlab::Checks::DiffCheck, feature_category: :source_code_management do include_context 'change access checks context' describe '#validate!' do + context 'when ref is not tag or branch ref' do + let(:ref) { 'refs/notes/commit' } + + it 'does not call find_changed_paths' do + expect(project.repository).not_to receive(:find_changed_paths) + + subject.validate! + end + end + context 'when commits is empty' do it 'does not call find_changed_paths' do expect(project.repository).not_to receive(:find_changed_paths) diff --git a/spec/models/error_tracking/project_error_tracking_setting_spec.rb b/spec/models/error_tracking/project_error_tracking_setting_spec.rb index d48f6f7f3e4..bb32cae6b1f 100644 --- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb +++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do +RSpec.describe ErrorTracking::ProjectErrorTrackingSetting, feature_category: :error_tracking do include ReactiveCachingHelpers include Gitlab::Routing @@ -352,7 +352,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do context 'when sentry response is successful' do before do - allow(sentry_client).to receive(:update_issue).with(opts).and_return(true) + allow(sentry_client).to receive(:update_issue).with(**opts).and_return(true) end it 'returns the successful response' do @@ -362,7 +362,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do context 'when sentry raises an error' do before do - allow(sentry_client).to receive(:update_issue).with(opts).and_raise(StandardError) + allow(sentry_client).to receive(:update_issue).with(**opts).and_raise(StandardError) end it 'returns the successful response' do @@ -391,7 +391,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do setting.update!(sentry_project_id: nil) allow(sentry_client).to receive(:projects).and_return(sentry_projects) - allow(sentry_client).to receive(:update_issue).with(opts).and_return(true) + allow(sentry_client).to receive(:update_issue).with(**opts).and_return(true) end it 'tries to backfill it from sentry API' do diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index fe0b46c3117..5da6a66b3ae 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -314,6 +314,40 @@ RSpec.describe ProjectFeature, feature_category: :projects do end end + describe '#public_packages?' do + let_it_be(:public_project) { create(:project, :public) } + + context 'with packages config enabled' do + context 'when project is private' do + it 'returns false' do + expect(project.project_feature.public_packages?).to eq(false) + end + + context 'with package_registry_access_level set to public' do + before do + project.project_feature.update!(package_registry_access_level: ProjectFeature::PUBLIC) + end + + it 'returns true' do + expect(project.project_feature.public_packages?).to eq(true) + end + end + end + + context 'when project is public' do + it 'returns true' do + expect(public_project.project_feature.public_packages?).to eq(true) + end + end + end + + it 'returns false if packages config is not enabled' do + stub_config(packages: { enabled: false }) + + expect(public_project.project_feature.public_packages?).to eq(false) + end + end + # rubocop:disable Gitlab/FeatureAvailableUsage describe '#feature_available?' do let(:features) { ProjectFeature::FEATURES } diff --git a/spec/requests/api/graphql/packages/package_spec.rb b/spec/requests/api/graphql/packages/package_spec.rb index 82fcc5254ad..7610a4aaac1 100644 --- a/spec/requests/api/graphql/packages/package_spec.rb +++ b/spec/requests/api/graphql/packages/package_spec.rb @@ -270,6 +270,31 @@ RSpec.describe 'package details', feature_category: :package_registry do it 'returns composer_config_repository_url correctly' do expect(graphql_data_at(:package, :composer_config_repository_url)).to eq("localhost/#{group.id}") end + + context 'with access to package registry for everyone' do + before do + project.project_feature.update!(package_registry_access_level: ProjectFeature::PUBLIC) + subject + end + + it 'returns pypi_url correctly' do + expect(graphql_data_at(:package, :pypi_url)).to eq("http://__token__:<your_personal_token>@localhost/api/v4/projects/#{project.id}/packages/pypi/simple") + end + end + + context 'when project is public' do + let_it_be(:public_project) { create(:project, :public, group: group) } + let_it_be(:composer_package) { create(:composer_package, project: public_project) } + let(:package_global_id) { global_id_of(composer_package) } + + before do + subject + end + + it 'returns pypi_url correctly' do + expect(graphql_data_at(:package, :pypi_url)).to eq("http://localhost/api/v4/projects/#{public_project.id}/packages/pypi/simple") + end + end end context 'web_path' do diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb index 6cd9c4ce1c4..c7849c524ff 100644 --- a/spec/support/shared_examples/features/content_editor_shared_examples.rb +++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb @@ -40,6 +40,16 @@ RSpec.shared_examples 'edits content using the content editor' do expect(page).to have_field('wiki[content]', with: value, type: 'hidden') end + def display_media_bubble_menu(media_element_selector, fixture_file) + upload_asset fixture_file + + wait_for_requests + + expect(page).to have_css(media_element_selector) + + page.find(media_element_selector).click + end + it 'saves page content in local storage if the user navigates away' do switch_to_content_editor @@ -92,25 +102,45 @@ RSpec.shared_examples 'edits content using the content editor' do open_insert_media_dropdown end - def test_displays_media_bubble_menu(media_element_selector, fixture_file) - upload_asset fixture_file - - wait_for_requests + it 'displays correct media bubble menu for images', :js do + display_media_bubble_menu '[data-testid="content_editor_editablebox"] img[src]', 'dk.png' - expect(page).to have_css(media_element_selector) + expect_formatting_menu_to_be_hidden + expect_media_bubble_menu_to_be_visible + end - page.find(media_element_selector).click + it 'displays correct media bubble menu for video', :js do + display_media_bubble_menu '[data-testid="content_editor_editablebox"] video', 'video_sample.mp4' expect_formatting_menu_to_be_hidden expect_media_bubble_menu_to_be_visible end + end - it 'displays correct media bubble menu for images', :js do - test_displays_media_bubble_menu '[data-testid="content_editor_editablebox"] img[src]', 'dk.png' + describe 'diagrams.net editor' do + def click_edit_diagram_button + page.find('[data-testid="edit-diagram"]').click end - it 'displays correct media bubble menu for video', :js do - test_displays_media_bubble_menu '[data-testid="content_editor_editablebox"] video', 'video_sample.mp4' + def expect_drawio_editor_is_opened + expect(page).to have_css('#drawio-frame', visible: :hidden) + end + + before do + switch_to_content_editor + + open_insert_media_dropdown + end + + it 'displays correct media bubble menu with edit diagram button' do + display_media_bubble_menu '[data-testid="content_editor_editablebox"] img[src]', 'diagram.drawio.svg' + + expect_formatting_menu_to_be_hidden + expect_media_bubble_menu_to_be_visible + + click_edit_diagram_button + + expect_drawio_editor_is_opened end end diff --git a/spec/tooling/danger/stable_branch_spec.rb b/spec/tooling/danger/stable_branch_spec.rb index f4008e09ef2..6b5c0b8cf27 100644 --- a/spec/tooling/danger/stable_branch_spec.rb +++ b/spec/tooling/danger/stable_branch_spec.rb @@ -94,7 +94,7 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do [ { 'name' => 'e2e:package-and-test', - 'status' => 'success', + 'status' => pipeline_bridge_state, 'downstream_pipeline' => { 'id' => '123', 'status' => package_and_qa_state @@ -103,6 +103,7 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do ] end + let(:pipeline_bridge_state) { 'running' } let(:package_and_qa_state) { 'success' } let(:parsed_response) do @@ -183,10 +184,10 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do it_behaves_like 'bypassing when flaky test or docs only' end - context 'when package-and-test job is in manual state' do - let(:package_and_qa_state) { 'manual' } + context 'when package-and-test job is being created' do + let(:pipeline_bridge_state) { 'created' } - it_behaves_like 'with a failure', described_class::NEEDS_PACKAGE_AND_TEST_MESSAGE + it_behaves_like 'with a warning', described_class::WARN_PACKAGE_AND_TEST_MESSAGE it_behaves_like 'bypassing when flaky test or docs only' end @@ -197,6 +198,13 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do it_behaves_like 'bypassing when flaky test or docs only' end + context 'when package-and-test job is in manual state' do + let(:package_and_qa_state) { 'manual' } + + it_behaves_like 'with a failure', described_class::NEEDS_PACKAGE_AND_TEST_MESSAGE + it_behaves_like 'bypassing when flaky test or docs only' + end + context 'when package-and-test job is canceled' do let(:package_and_qa_state) { 'canceled' } diff --git a/tooling/danger/stable_branch.rb b/tooling/danger/stable_branch.rb index 2d1a4ef0845..65086c8485c 100644 --- a/tooling/danger/stable_branch.rb +++ b/tooling/danger/stable_branch.rb @@ -69,7 +69,7 @@ module Tooling fail PIPELINE_EXPEDITE_ERROR_MESSAGE if has_pipeline_expedite_label? - status = package_and_test_status + status = package_and_test_bridge_and_pipeline_status if status.nil? || FAILING_PACKAGE_AND_TEST_STATUSES.include?(status) # rubocop:disable Style/GuardClause fail NEEDS_PACKAGE_AND_TEST_MESSAGE @@ -91,15 +91,26 @@ module Tooling !!stable_target_branch && !helper.security_mr? end - def package_and_test_status + def package_and_test_bridge_and_pipeline_status mr_head_pipeline_id = gitlab.mr_json.dig('head_pipeline', 'id') return unless mr_head_pipeline_id - pipeline = package_and_test_pipeline(mr_head_pipeline_id) + bridge = package_and_test_bridge(mr_head_pipeline_id) - return unless pipeline + return unless bridge - pipeline['status'] + if bridge['status'] == 'created' + bridge['status'] + else + bridge.fetch('downstream_pipeline').fetch('status') + end + end + + def package_and_test_bridge(mr_head_pipeline_id) + gitlab + .api + .pipeline_bridges(helper.mr_target_project_id, mr_head_pipeline_id) + &.find { |bridge| bridge['name'] == 'e2e:package-and-test' } end def stable_target_branch @@ -202,17 +213,6 @@ module Tooling def version_to_minor_string(version) "#{version[:major]}.#{version[:minor]}" end - - def package_and_test_pipeline(mr_head_pipeline_id) - package_and_test_bridge = gitlab - .api - .pipeline_bridges(helper.mr_target_project_id, mr_head_pipeline_id) - &.find { |bridge| bridge['name'] == 'e2e:package-and-test' } - - return unless package_and_test_bridge - - package_and_test_bridge['downstream_pipeline'] - end end end end diff --git a/yarn.lock b/yarn.lock index de413a5f5c8..3f42a34b62d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1221,10 +1221,10 @@ stylelint-declaration-strict-value "1.8.0" stylelint-scss "4.2.0" -"@gitlab/svgs@3.23.0": - version "3.23.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.23.0.tgz#92ed37ebd2058f1c1ed4651f86d4a20736790afb" - integrity sha512-rq6md86C+2AH75wk3zY0e+aPRRK1QuBdhNPex/Q7IfR8gm+kADhYj1GSS6bnU80rfG6Fk49xi6VpSHWRlQZ0Zg== +"@gitlab/svgs@3.24.0": + version "3.24.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.24.0.tgz#bc8265919aa04b06cd08be91637471bad195936d" + integrity sha512-R4s5qJUFUIbPflknpw1aI/PchiNq65vY7LVsJZnQkY+vi+AgmsETdut/AdferbGWmeWMU0q2wuVu9phE8lDUgA== "@gitlab/ui@56.2.0": version "56.2.0" |