summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue23
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue54
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_alert.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue11
-rw-r--r--app/assets/javascripts/content_editor/extensions/attachment.js34
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/loading.js24
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js6
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js2
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js2
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js8
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js214
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js22
-rw-r--r--app/assets/javascripts/diffs/utils/diff_file.js4
-rw-r--r--app/assets/javascripts/diffs/utils/merge_request.js10
-rw-r--r--app/assets/javascripts/lib/utils/tappable_promise.js49
-rw-r--r--app/assets/javascripts/notes/components/comment_field_layout.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue2
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_add_note.vue11
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue3
24 files changed, 324 insertions, 177 deletions
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index 6aa5bb715b2..d2db61e096a 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -117,7 +117,7 @@ export default {
{{ __('Summary comment (optional)') }}
</template>
<div class="common-note-form gfm-form">
- <div class="comment-warning-wrapper-large gl-border-0 gl-bg-white">
+ <div class="comment-warning-wrapper-large gl-border-0 gl-bg-white gl-overflow-hidden">
<markdown-field
:is-submitting="isSubmitting"
:add-spacing-classes="false"
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
index fd696670ddf..7c5cc1ea6ee 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
@@ -1,11 +1,13 @@
<script>
import {
GlLink,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
GlButton,
GlButtonGroup,
+ GlLoadingIcon,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { getMarkType, getMarkRange } from '@tiptap/core';
@@ -16,12 +18,14 @@ import BubbleMenu from './bubble_menu.vue';
export default {
components: {
BubbleMenu,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
GlLink,
GlButton,
GlButtonGroup,
+ GlLoadingIcon,
EditorStateObserver,
},
directives: {
@@ -35,6 +39,9 @@ export default {
linkText: undefined,
isEditing: false,
+
+ uploading: false,
+ uploadProgress: 0,
};
},
methods: {
@@ -129,9 +136,11 @@ export default {
updateLinkToState() {
const editor = this.tiptapEditor;
- const { href, canonicalSrc } = editor.getAttributes(Link.name);
+ const { href, canonicalSrc, uploading } = editor.getAttributes(Link.name);
const text = this.linkTextInDoc();
+ this.uploading = uploading;
+
if (
canonicalSrc === this.linkCanonicalSrc &&
href === this.linkHref &&
@@ -150,6 +159,11 @@ export default {
if (transaction.getMeta('creatingLink')) {
this.isEditing = true;
}
+
+ const { filename = '', progress = 0 } = transaction.getMeta('uploadProgress') || {};
+ if (this.uploading === filename) {
+ this.uploadProgress = Math.round(progress * 100);
+ }
},
copyLinkHref() {
@@ -203,7 +217,14 @@ export default {
@hidden="resetBubbleMenuState"
>
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
+ <gl-loading-icon v-if="uploading" class="gl-pl-4 gl-pr-3" />
+ <span v-if="uploading" class="gl-text-secondary gl-pr-3">
+ <gl-sprintf :message="__('Uploading: %{progress}')">
+ <template #progress>{{ uploadProgress }}&percnt;</template>
+ </gl-sprintf>
+ </span>
<gl-link
+ v-else
v-gl-tooltip
:href="linkHref"
:aria-label="linkCanonicalSrc"
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 1bfa635c03b..6bb6bdc4e65 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
@@ -1,6 +1,7 @@
<script>
import {
GlLink,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
@@ -48,6 +49,7 @@ export default {
},
components: {
BubbleMenu,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
@@ -71,7 +73,10 @@ export default {
isEditing: false,
isUpdating: false,
- isUploading: false,
+
+ uploading: false,
+
+ uploadProgress: 0,
};
},
computed: {
@@ -88,7 +93,7 @@ export default {
return this.$options.i18n.deleteLabels[this.mediaType];
},
showProgressIndicator() {
- return this.isUploading || this.isUpdating;
+ return this.uploading || this.isUpdating;
},
isDrawioDiagram() {
return this.mediaType === DrawioDiagram.name;
@@ -157,17 +162,27 @@ export default {
this.mediaTitle = title;
this.mediaAlt = alt;
this.mediaCanonicalSrc = canonicalSrc || src;
- this.isUploading = uploading;
+ this.uploading = uploading;
+
this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc);
this.isUpdating = false;
},
+ onTransaction({ transaction }) {
+ const { filename = '', progress = 0 } = transaction.getMeta('uploadProgress') || {};
+ if (this.uploading === filename) {
+ this.uploadProgress = Math.round(progress * 100);
+ }
+ },
+
resetMediaInfo() {
this.mediaTitle = null;
this.mediaAlt = null;
this.mediaCanonicalSrc = null;
- this.isUploading = false;
+ this.uploading = false;
+
+ this.uploadProgress = 0;
},
replaceMedia() {
@@ -204,17 +219,26 @@ export default {
};
</script>
<template>
- <bubble-menu
- data-testid="media-bubble-menu"
- class="gl-shadow gl-rounded-base gl-bg-white"
- plugin-key="bubbleMenuMedia"
- :should-show="shouldShow"
- @show="updateMediaInfoToState"
- @hidden="resetMediaInfo"
+ <editor-state-observer
+ :debounce="0"
+ @selectionUpdate="updateMediaInfoToState"
+ @transaction="onTransaction"
>
- <editor-state-observer :debounce="0" @transaction="updateMediaInfoToState">
+ <bubble-menu
+ data-testid="media-bubble-menu"
+ class="gl-shadow gl-rounded-base gl-bg-white"
+ plugin-key="bubbleMenuMedia"
+ :should-show="shouldShow"
+ @show="updateMediaInfoToState"
+ @hidden="resetMediaInfo"
+ >
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
<gl-loading-icon v-if="showProgressIndicator" class="gl-pl-4 gl-pr-3" />
+ <span v-if="uploading" class="gl-text-secondary gl-pr-3">
+ <gl-sprintf :message="__('Uploading: %{progress}')">
+ <template #progress>{{ uploadProgress }}&percnt;</template>
+ </gl-sprintf>
+ </span>
<input
ref="fileSelector"
type="file"
@@ -280,7 +304,7 @@ export default {
data-testid="replace-media"
:aria-label="replaceLabel"
:title="replaceLabel"
- icon="upload"
+ icon="retry"
@click="replaceMedia"
/>
<gl-button
@@ -315,6 +339,6 @@ export default {
<gl-button variant="confirm" type="submit">{{ __('Apply') }}</gl-button>
</div>
</gl-form>
- </editor-state-observer>
- </bubble-menu>
+ </bubble-menu>
+ </editor-state-observer>
</template>
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 2b2c4a5ac1c..7fee798c65a 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -220,7 +220,7 @@ export default {
<div
data-testid="content-editor"
data-qa-selector="content_editor_container"
- class="md-area"
+ class="md-area gl-border-none! gl-shadow-none!"
:class="{ 'is-focused': focused }"
>
<formatting-toolbar ref="toolbar" @enableMarkdownEditor="$emit('enableMarkdownEditor')" />
diff --git a/app/assets/javascripts/content_editor/components/content_editor_alert.vue b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
index 87eff2451ec..59d71169dd3 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_alert.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
@@ -34,7 +34,6 @@ export default {
<editor-state-observer @alert="displayAlert">
<gl-alert
v-if="message"
- class="gl-mb-6"
:variant="variant"
:primary-button-text="actionLabel"
@dismiss="dismissAlert"
diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
index 1f18090e7d7..5d98fdeef02 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
@@ -23,13 +23,9 @@ export default {
this.$refs.fileSelector.click();
},
onFileSelect(e) {
- this.tiptapEditor
- .chain()
- .focus()
- .uploadAttachment({
- file: e.target.files[0],
- })
- .run();
+ for (const file of e.target.files) {
+ this.tiptapEditor.chain().focus().uploadAttachment({ file }).run();
+ }
// Reset the file input so that the same file can be uploaded again
this.$refs.fileSelector.value = '';
@@ -53,6 +49,7 @@ export default {
<input
ref="fileSelector"
type="file"
+ multiple
name="content_editor_image"
class="gl-display-none"
data-qa-selector="file_upload_field"
diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js
index 0d5b8e56a6c..e7a6af30266 100644
--- a/app/assets/javascripts/content_editor/extensions/attachment.js
+++ b/app/assets/javascripts/content_editor/extensions/attachment.js
@@ -2,6 +2,20 @@ import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { handleFileEvent } from '../services/upload_helpers';
+const processFiles = ({ files, uploadsPath, renderMarkdown, eventHub, editor }) => {
+ if (!files.length) {
+ return false;
+ }
+
+ let handled = true;
+
+ for (const file of files) {
+ handled = handled && handleFileEvent({ editor, file, uploadsPath, renderMarkdown, eventHub });
+ }
+
+ return handled;
+};
+
export default Extension.create({
name: 'attachment',
@@ -36,25 +50,17 @@ export default Extension.create({
key: new PluginKey('attachment'),
props: {
handlePaste: (_, event) => {
- const { uploadsPath, renderMarkdown, eventHub } = this.options;
-
- return handleFileEvent({
+ return processFiles({
+ files: event.clipboardData.files,
editor,
- file: event.clipboardData.files[0],
- uploadsPath,
- renderMarkdown,
- eventHub,
+ ...this.options,
});
},
handleDrop: (_, event) => {
- const { uploadsPath, renderMarkdown, eventHub } = this.options;
-
- return handleFileEvent({
+ return processFiles({
+ files: event.dataTransfer.files,
editor,
- file: event.dataTransfer.files[0],
- uploadsPath,
- renderMarkdown,
- eventHub,
+ ...this.options,
});
},
},
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index 314d5230b01..b83814103d1 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -29,7 +29,6 @@ export default Link.extend({
addInputRules() {
const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm;
- const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim;
return [
markInputRule({
@@ -37,16 +36,15 @@ export default Link.extend({
type: this.type,
getAttributes: extractHrefFromMarkdownLink,
}),
- markInputRule({
- find: urlSyntaxRegExp,
- type: this.type,
- getAttributes: extractHrefFromMatch,
- }),
];
},
addAttributes() {
return {
...this.parent?.(),
+ uploading: {
+ default: false,
+ renderHTML: ({ uploading }) => (uploading ? { class: 'with-attachment-icon' } : {}),
+ },
href: {
default: null,
parseHTML: (element) => element.getAttribute('href'),
diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js
deleted file mode 100644
index 2324e9b132d..00000000000
--- a/app/assets/javascripts/content_editor/extensions/loading.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Node } from '@tiptap/core';
-
-export default Node.create({
- name: 'loading',
- inline: true,
- group: 'inline',
-
- addAttributes() {
- return {
- label: {
- default: null,
- },
- };
- },
-
- renderHTML({ node }) {
- return [
- 'span',
- { class: 'gl-display-inline-flex gl-align-items-center' },
- ['span', { class: 'gl-spinner gl-mx-2' }],
- ['span', { class: 'gl-link' }, node.attrs.label],
- ];
- },
-});
diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js
index 01ffc217894..47766c966a1 100644
--- a/app/assets/javascripts/content_editor/extensions/playable.js
+++ b/app/assets/javascripts/content_editor/extensions/playable.js
@@ -61,7 +61,11 @@ export default Node.create({
...this.extraElementAttrs,
},
],
- ['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''],
+ [
+ 'a',
+ { href: node.attrs.src, class: 'with-attachment-icon' },
+ node.attrs.title || node.attrs.alt || '',
+ ],
];
},
});
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 1b366136bfa..3958f77745a 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -40,7 +40,6 @@ import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
-import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
@@ -136,7 +135,6 @@ export const createContentEditor = ({
ExternalKeydownHandler.configure({ eventHub }),
Link,
ListItem,
- Loading,
MathInline,
OrderedList,
Paragraph,
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 9ff50b45088..3b77064e903 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -32,7 +32,6 @@ import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
-import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
@@ -195,7 +194,6 @@ const defaultSerializerConfig = {
inline: true,
}),
[ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item),
- [Loading.name]: () => {},
[OrderedList.name]: preserveUnchanged(renderOrderedList),
[Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph),
[Reference.name]: renderReference,
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 48367ac42f5..478b87372d7 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -309,15 +309,13 @@ export function renderHardBreak(state, node, parent, index) {
export function renderImage(state, node) {
const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs;
- let realSrc = canonicalSrc || src || '';
+ const realSrc = canonicalSrc || src || '';
// eslint-disable-next-line @gitlab/require-i18n-strings
- if (realSrc.startsWith('data:')) realSrc = '';
+ if (realSrc.startsWith('data:') || realSrc.startsWith('blob:')) return;
if (isString(src) || isString(canonicalSrc)) {
const quotedTitle = title ? ` ${state.quote(title)}` : '';
- const sourceExpression = isReference
- ? `[${canonicalSrc}]`
- : `(${state.esc(realSrc)}${quotedTitle})`;
+ const sourceExpression = isReference ? `[${canonicalSrc}]` : `(${realSrc}${quotedTitle})`;
const sizeAttributes = [];
if (width) {
diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
index 548f5cdf19c..f5785397bf0 100644
--- a/app/assets/javascripts/content_editor/services/upload_helpers.js
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -1,7 +1,33 @@
import { VARIANT_DANGER } from '~/alert';
import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import { extractFilename, readFileAsDataURL } from './utils';
+import { __, sprintf } from '~/locale';
+import { bytesToMiB } from '~/lib/utils/number_utils';
+import TappablePromise from '~/lib/utils/tappable_promise';
+import { ALERT_EVENT } from '../constants';
+
+const chain = (editor) => editor.chain().setMeta('preventAutolink', true);
+
+const findUploadedFilePosition = (editor, filename) => {
+ let position;
+
+ editor.view.state.doc.descendants((descendant, pos) => {
+ if (descendant.attrs.uploading === filename) {
+ position = pos;
+ return false;
+ }
+
+ for (const mark of descendant.marks) {
+ if (mark.type.name === 'link' && mark.attrs.uploading === filename) {
+ position = pos + 1;
+ return false;
+ }
+ }
+
+ return true;
+ });
+
+ return position;
+};
export const acceptedMimes = {
drawioDiagram: {
@@ -47,6 +73,18 @@ const extractAttachmentLinkUrl = (html) => {
return { src, canonicalSrc };
};
+class UploadError extends Error {}
+
+const notifyUploadError = (eventHub, error) => {
+ eventHub.$emit(ALERT_EVENT, {
+ message:
+ error instanceof UploadError
+ ? error.message
+ : __('An error occurred while uploading the file. Please try again.'),
+ variant: VARIANT_DANGER,
+ });
+};
+
/**
* Uploads a file with a post request to the URL indicated
* in the uploadsPath parameter. The expected response of the
@@ -64,85 +102,147 @@ const extractAttachmentLinkUrl = (html) => {
* and returns a rendered version in HTML format.
* @param {File} params.file The file to upload
*
- * @returns Returns an object with two properties:
+ * @returns {TappablePromise} Returns an object with two properties:
*
* canonicalSrc: The URL as defined in the Markdown
* src: The absolute URL that points to the resource in the server
*/
-export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
- const formData = new FormData();
- formData.append('file', file, file.name);
+export const uploadFile = ({ uploadsPath, renderMarkdown, file }) => {
+ return new TappablePromise(async (tap) => {
+ const maxFileSize = (gon.max_file_size || 10).toFixed(0);
+ const fileSize = bytesToMiB(file.size);
+ if (fileSize > maxFileSize) {
+ throw new UploadError(
+ sprintf(__('File is too big (%{fileSize}MiB). Max filesize: %{maxFileSize}MiB.'), {
+ fileSize: fileSize.toFixed(2),
+ maxFileSize,
+ }),
+ );
+ }
- const { data } = await axios.post(uploadsPath, formData);
- const { markdown } = data.link;
- const rendered = await renderMarkdown(markdown);
+ const formData = new FormData();
+ formData.append('file', file, file.name);
- return extractAttachmentLinkUrl(rendered);
+ const { data } = await axios.post(uploadsPath, formData, {
+ onUploadProgress: (e) => tap(e.loaded / e.total),
+ });
+ const { markdown } = data.link;
+ const rendered = await renderMarkdown(markdown);
+
+ return extractAttachmentLinkUrl(rendered);
+ });
};
-const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => {
- const encodedSrc = await readFileAsDataURL(file);
- const { view } = editor;
+const uploadMedia = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => {
+ // needed to avoid mismatched transaction error
+ await Promise.resolve();
+
+ const objectUrl = URL.createObjectURL(file);
+ const { selection } = editor.view.state;
+ const currentNode = selection.$to.node();
+
+ let position = selection.to;
+ let content = {
+ type,
+ attrs: { uploading: file.name, src: objectUrl, alt: file.name },
+ };
+ let selectionIncrement = 0;
+
+ // if the current node is not empty, we need to wrap the content in a new paragraph
+ if (currentNode.content.size > 0 || currentNode.type.name === 'doc') {
+ content = {
+ type: 'paragraph',
+ content: [content],
+ };
+ selectionIncrement = 1;
+ }
- editor.commands.insertContent({ type, attrs: { uploading: true, src: encodedSrc } });
+ chain(editor)
+ .insertContentAt(position, content)
+ .setNodeSelection(position + selectionIncrement)
+ .run();
- const { state } = view;
- const position = state.selection.from - 1;
- const { tr } = state;
+ uploadFile({ file, uploadsPath, renderMarkdown })
+ .tap((progress) => {
+ chain(editor).setMeta('uploadProgress', { filename: file.name, progress }).run();
+ })
+ .then(({ canonicalSrc }) => {
+ // the position might have changed while uploading, so we need to find it again
+ position = findUploadedFilePosition(editor, file.name);
- editor.commands.setNodeSelection(position);
+ editor.view.dispatch(
+ editor.state.tr.setMeta('preventAutolink', true).setNodeMarkup(position, undefined, {
+ uploading: false,
+ src: objectUrl,
+ alt: file.name,
+ canonicalSrc,
+ }),
+ );
- try {
- const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
+ chain(editor).setNodeSelection(position).run();
+ })
+ .catch((e) => {
+ position = findUploadedFilePosition(editor, file.name);
- view.dispatch(
- tr.setNodeMarkup(position, undefined, {
- uploading: false,
- src: encodedSrc,
- alt: extractFilename(src),
- canonicalSrc,
- }),
- );
+ chain(editor)
+ .deleteRange({ from: position, to: position + 1 })
+ .run();
- editor.commands.setNodeSelection(position);
- } catch (e) {
- editor.commands.deleteRange({ from: position, to: position + 1 });
- eventHub.$emit('alert', {
- message: __('An error occurred while uploading the file. Please try again.'),
- variant: VARIANT_DANGER,
+ notifyUploadError(eventHub, e);
});
- }
};
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
+ // needed to avoid mismatched transaction error
await Promise.resolve();
- const { view } = editor;
+ const objectUrl = URL.createObjectURL(file);
+ const { selection } = editor.view.state;
+ const currentNode = selection.$to.node();
+
+ let position = selection.to;
+ let content = {
+ type: 'text',
+ text: file.name,
+ marks: [{ type: 'link', attrs: { href: objectUrl, uploading: file.name } }],
+ };
- const text = extractFilename(file.name);
+ // if the current node is not empty, we need to wrap the content in a new paragraph
+ if (currentNode.content.size > 0 || currentNode.type.name === 'doc') {
+ content = {
+ type: 'paragraph',
+ content: [content],
+ };
+ }
- const { state } = view;
- const { from } = state.selection;
+ chain(editor).insertContentAt(position, content).extendMarkRange('link').run();
- editor.commands.insertContent({
- type: 'loading',
- attrs: { label: text },
- });
+ uploadFile({ file, uploadsPath, renderMarkdown })
+ .tap((progress) => {
+ chain(editor).setMeta('uploadProgress', { filename: file.name, progress }).run();
+ })
+ .then(({ src, canonicalSrc }) => {
+ // the position might have changed while uploading, so we need to find it again
+ position = findUploadedFilePosition(editor, file.name);
- try {
- const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
-
- editor.commands.insertContentAt(
- { from, to: from + 1 },
- { type: 'text', text, marks: [{ type: 'link', attrs: { href: src, canonicalSrc } }] },
- );
- } catch (e) {
- editor.commands.deleteRange({ from, to: from + 1 });
- eventHub.$emit('alert', {
- message: __('An error occurred while uploading the file. Please try again.'),
- variant: VARIANT_DANGER,
+ chain(editor)
+ .setTextSelection(position)
+ .extendMarkRange('link')
+ .updateAttributes('link', { href: src, canonicalSrc, uploading: false })
+ .run();
+ })
+ .catch((e) => {
+ position = findUploadedFilePosition(editor, file.name);
+
+ chain(editor)
+ .setTextSelection(position)
+ .extendMarkRange('link')
+ .unsetLink()
+ .deleteSelection()
+ .run();
+
+ notifyUploadError(eventHub, e);
});
- }
};
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
@@ -150,7 +250,7 @@ export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eve
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 });
+ uploadMedia({ type, editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
}
diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js
index e352fa8a9db..1c128b4aa19 100644
--- a/app/assets/javascripts/content_editor/services/utils.js
+++ b/app/assets/javascripts/content_editor/services/utils.js
@@ -4,26 +4,4 @@ export const hasSelection = (tiptapEditor) => {
return from < to;
};
-/**
- * Extracts filename from a URL
- *
- * @example
- * > extractFilename('https://gitlab.com/images/logo-full.png')
- * < 'logo-full'
- *
- * @param {string} src The URL to extract filename from
- * @returns {string}
- */
-export const extractFilename = (src) => {
- return src.replace(/^.*\/|\.[^.]+?$/g, '');
-};
-
-export const readFileAsDataURL = (file) => {
- return new Promise((resolve) => {
- const reader = new FileReader();
- reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
- reader.readAsDataURL(file);
- });
-};
-
export const clamp = (n, min, max) => Math.max(Math.min(n, max), min);
diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js
index bcd9fa01278..e2fb24f7b57 100644
--- a/app/assets/javascripts/diffs/utils/diff_file.js
+++ b/app/assets/javascripts/diffs/utils/diff_file.js
@@ -39,12 +39,12 @@ function collapsed(file) {
}
function identifier(file) {
- const { userOrGroup, project, id } = getDerivedMergeRequestInformation({
+ const { namespace, project, id } = getDerivedMergeRequestInformation({
endpoint: file.load_collapsed_diff_url,
});
return uuids({
- seeds: [userOrGroup, project, id, file.file_identifier_hash, file.blob?.id],
+ seeds: [namespace, project, id, file.file_identifier_hash, file.blob?.id],
})[0];
}
diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js
index 6847b8900d2..bc81c0b0a05 100644
--- a/app/assets/javascripts/diffs/utils/merge_request.js
+++ b/app/assets/javascripts/diffs/utils/merge_request.js
@@ -1,6 +1,6 @@
import { ZERO_CHANGES_ALT_DISPLAY } from '../constants';
-const endpointRE = /^(\/?(.+?)\/(.+?)\/-\/merge_requests\/(\d+)).*$/i;
+const endpointRE = /^(\/?(.+\/)+(.+)\/-\/merge_requests\/(\d+)).*$/i;
function getVersionInfo({ endpoint } = {}) {
const dummyRoot = 'https://gitlab.com';
@@ -28,7 +28,7 @@ export function updateChangesTabCount({
export function getDerivedMergeRequestInformation({ endpoint } = {}) {
let mrPath;
- let userOrGroup;
+ let namespace;
let project;
let id;
let diffId;
@@ -36,13 +36,15 @@ export function getDerivedMergeRequestInformation({ endpoint } = {}) {
const matches = endpointRE.exec(endpoint);
if (matches) {
- [, mrPath, userOrGroup, project, id] = matches;
+ [, mrPath, namespace, project, id] = matches;
({ diffId, startSha } = getVersionInfo({ endpoint }));
+
+ namespace = namespace.replace(/\/$/, '');
}
return {
mrPath,
- userOrGroup,
+ namespace,
project,
id,
diffId,
diff --git a/app/assets/javascripts/lib/utils/tappable_promise.js b/app/assets/javascripts/lib/utils/tappable_promise.js
new file mode 100644
index 00000000000..8d327dabe1b
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/tappable_promise.js
@@ -0,0 +1,49 @@
+/**
+ * A promise that is also tappable, i.e. something you can subscribe
+ * to to get progress of a promise until it resolves.
+ *
+ * @example Usage
+ * const tp = new TappablePromise((resolve, reject, tap) => {
+ * for (let i = 0; i < 10; i++) {
+ * tap(i/10);
+ * }
+ * resolve();
+ * });
+ *
+ * tp.tap((progress) => {
+ * console.log(progress);
+ * }).then(() => {
+ * console.log('done');
+ * });
+ *
+ * // Output:
+ * // 0
+ * // 0.1
+ * // 0.2
+ * // ...
+ * // 0.9
+ * // done
+ *
+ *
+ * @param {(resolve: Function, reject: Function, tap: Function) => void} callback
+ * @returns {Promise & { tap: Function }}}
+ */
+export default function TappablePromise(callback) {
+ let progressCallback;
+
+ const promise = new Promise((resolve, reject) => {
+ try {
+ const tap = (progress) => progressCallback?.(progress);
+ resolve(callback(tap, resolve, reject));
+ } catch (e) {
+ reject(e);
+ }
+ });
+
+ promise.tap = function tap(_progressCallback) {
+ progressCallback = _progressCallback;
+ return this;
+ };
+
+ return promise;
+}
diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue
index cfe4baaa1f9..bde7d219e9f 100644
--- a/app/assets/javascripts/notes/components/comment_field_layout.vue
+++ b/app/assets/javascripts/notes/components/comment_field_layout.vue
@@ -67,7 +67,7 @@ export default {
</script>
<template>
<div
- class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-lg gl-border-gray-100 gl-bg-white"
+ class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-lg gl-border-gray-100 gl-bg-white gl-overflow-hidden"
>
<div
v-if="withAlertContainer"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 3c4070105d1..518b28afd9b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -350,8 +350,7 @@ export default {
<template>
<div
ref="gl-form"
- :class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }"
- class="js-vue-markdown-field md-area position-relative gfm-form"
+ class="js-vue-markdown-field md-area position-relative gfm-form gl-border-none! gl-shadow-none!"
:data-uploads-path="uploadsPath"
>
<markdown-header
diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
index 06d1779b180..af78cc7b5ca 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -218,7 +218,7 @@ export default {
};
</script>
<template>
- <div>
+ <div class="md-area gl-px-0! gl-overflow-hidden">
<local-storage-sync
:value="editingMode"
as-string
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
index f9f4bf260a1..e10a82b5197 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
@@ -142,10 +142,13 @@ export default {
return this.isNewDiscussion ? __('Comment') : __('Reply');
},
timelineEntryClass() {
- return this.isNewDiscussion
- ? 'timeline-entry note-form'
- : // eslint-disable-next-line @gitlab/require-i18n-strings
- 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix gl-bg-white! gl-pt-0!';
+ return {
+ 'timeline-entry note-form': this.isNewDiscussion,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix': !this
+ .isNewDiscussion,
+ 'gl-bg-white! gl-pt-0!': this.isEditing,
+ };
},
},
watch: {
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
index f9f24366725..cea28b30d42 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
@@ -208,7 +208,7 @@ export default {
:form-field-props="formFieldProps"
:add-spacing-classes="false"
data-testid="work-item-add-comment"
- class="gl-mb-3"
+ class="gl-mb-5"
use-bottom-toolbar
supports-quick-actions
:autofocus="autofocus"
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index f3c94732aae..a4cbc430b84 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -69,9 +69,6 @@ export default {
update(data) {
return data.workspace.workItems.nodes[0];
},
- skip() {
- return !this.workItemIid;
- },
result() {
if (this.isEditing) {
this.checkForConflicts();