diff --git a/.rubocop_todo/layout/first_array_element_indentation.yml b/.rubocop_todo/layout/first_array_element_indentation.yml index a6d75b57b2f..7c07126f698 100644 --- a/.rubocop_todo/layout/first_array_element_indentation.yml +++ b/.rubocop_todo/layout/first_array_element_indentation.yml @@ -61,26 +61,6 @@ Layout/FirstArrayElementIndentation: - 'spec/models/ci/daily_build_group_report_result_spec.rb' - 'spec/models/ci/pipeline_spec.rb' - 'spec/models/ci/runner_version_spec.rb' - - 'spec/models/ci/unit_test_spec.rb' - - 'spec/models/clusters/applications/cert_manager_spec.rb' - - 'spec/models/clusters/platforms/kubernetes_spec.rb' - - 'spec/models/commit_collection_spec.rb' - - 'spec/models/compare_spec.rb' - - 'spec/models/concerns/id_in_ordered_spec.rb' - - 'spec/models/concerns/noteable_spec.rb' - - 'spec/models/diff_note_spec.rb' - - 'spec/models/discussion_spec.rb' - - 'spec/models/group_spec.rb' - - 'spec/models/integration_spec.rb' - - 'spec/models/integrations/chat_message/issue_message_spec.rb' - - 'spec/models/integrations/chat_message/wiki_page_message_spec.rb' - - 'spec/models/integrations/jira_spec.rb' - - 'spec/models/label_note_spec.rb' - - 'spec/models/merge_request/cleanup_schedule_spec.rb' - - 'spec/models/merge_request_diff_spec.rb' - - 'spec/models/merge_request_spec.rb' - - 'spec/models/operations/feature_flags/strategy_spec.rb' - - 'spec/models/project_group_link_spec.rb' - 'spec/models/repository_spec.rb' - 'spec/models/user_preference_spec.rb' - 'spec/models/user_spec.rb' diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 207b5d92e1e..b6ad514204b 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -2ff0039f15ef06063925ff6c0406f6e092ad2435 +5cba52f4acb04ddbe27d8b7cb2e936ea0be45ae1 diff --git a/app/assets/javascripts/content_editor/components/reference_dropdown.vue b/app/assets/javascripts/content_editor/components/reference_dropdown.vue new file mode 100644 index 00000000000..7869df31a4d --- /dev/null +++ b/app/assets/javascripts/content_editor/components/reference_dropdown.vue @@ -0,0 +1,146 @@ + + + diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js index 48303cdeca4..41903162ba5 100644 --- a/app/assets/javascripts/content_editor/extensions/heading.js +++ b/app/assets/javascripts/content_editor/extensions/heading.js @@ -1 +1,15 @@ -export { Heading as default } from '@tiptap/extension-heading'; +import { Heading } from '@tiptap/extension-heading'; +import { textblockTypeInputRule } from '@tiptap/core'; + +export default Heading.extend({ + addInputRules() { + return this.options.levels.map((level) => { + return textblockTypeInputRule({ + // make sure heading regex doesn't conflict with issue references + find: new RegExp(`^(#{1,${level}})[ \t]$`), + type: this.type, + getAttributes: { level }, + }); + }); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index 5e459e65de2..9ac8129335e 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -57,7 +57,7 @@ export default Node.create({ 'a', { class: node.attrs.className, - href: node.attrs.href, + href: '#', 'data-reference-type': node.attrs.referenceType, 'data-original': node.attrs.originalText, }, diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js new file mode 100644 index 00000000000..b43357c1fb9 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -0,0 +1,150 @@ +import { Node } from '@tiptap/core'; +import { VueRenderer } from '@tiptap/vue-2'; +import tippy from 'tippy.js'; +import Suggestion from '@tiptap/suggestion'; +import { PluginKey } from 'prosemirror-state'; +import { isFunction } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import Reference from '../components/reference_dropdown.vue'; + +function createSuggestionPlugin({ editor, char, dataSource, search, referenceProps }) { + return Suggestion({ + editor, + char, + pluginKey: new PluginKey(`reference_${referenceProps.referenceType}`), + command: ({ editor: tiptapEditor, range, props }) => { + tiptapEditor + .chain() + .focus() + .insertContentAt(range, [{ type: 'reference', attrs: props }]) + .run(); + }, + + async items({ query }) { + if (!dataSource) return []; + + try { + const items = await (isFunction(dataSource) ? dataSource() : axios.get(dataSource)); + return items.data.filter(search(query)); + } catch { + return []; + } + }, + + render: () => { + let component; + let popup; + + return { + onStart: (props) => { + component = new VueRenderer(Reference, { + propsData: { + ...props, + char, + referenceProps, + }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }); + }, + + onUpdate(props) { + component.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup?.[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0].hide(); + + return true; + } + + return component.ref?.onKeyDown(props); + }, + + onExit() { + popup?.[0].destroy(); + component.destroy(); + }, + }; + }, + }); +} + +export default Node.create({ + name: 'suggestions', + + addProseMirrorPlugins() { + return [ + createSuggestionPlugin({ + editor: this.editor, + char: '@', + dataSource: gl.GfmAutoComplete?.dataSources.members, + referenceProps: { + className: 'gfm gfm-project_member', + referenceType: 'user', + }, + search: (query) => ({ name, username }) => + name.toLocaleLowerCase().includes(query.toLocaleLowerCase()) || + username.toLocaleLowerCase().includes(query.toLocaleLowerCase()), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '#', + dataSource: gl.GfmAutoComplete?.dataSources.issues, + referenceProps: { + className: 'gfm gfm-issue', + referenceType: 'issue', + }, + search: (query) => ({ iid, title }) => + String(iid).toLocaleLowerCase().includes(query.toLocaleLowerCase()) || + title.toLocaleLowerCase().includes(query.toLocaleLowerCase()), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '!', + dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests, + referenceProps: { + className: 'gfm gfm-issue', + referenceType: 'merge_request', + }, + search: (query) => ({ iid, title }) => + String(iid).toLocaleLowerCase().includes(query.toLocaleLowerCase()) || + title.toLocaleLowerCase().includes(query.toLocaleLowerCase()), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '%', + dataSource: gl.GfmAutoComplete?.dataSources.milestones, + referenceProps: { + className: 'gfm gfm-milestone', + referenceType: 'milestone', + }, + search: (query) => ({ iid, title }) => + String(iid).toLocaleLowerCase().includes(query.toLocaleLowerCase()) || + title.toLocaleLowerCase().includes(query.toLocaleLowerCase()), + }), + ]; + }, +}); 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 5ed7f3dc23d..a4621ce0c04 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -46,6 +46,7 @@ import ReferenceDefinition from '../extensions/reference_definition'; import Sourcemap from '../extensions/sourcemap'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; +import Suggestions from '../extensions/suggestions'; import Superscript from '../extensions/superscript'; import Table from '../extensions/table'; import TableCell from '../extensions/table_cell'; @@ -133,6 +134,7 @@ export const createContentEditor = ({ Sourcemap, Strike, Subscript, + Suggestions, Superscript, TableCell, TableHeader, diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index c2ab7c4f298..21bf11f7b93 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -38,7 +38,12 @@ export default { }, }, mounted() { - this.$refs.textarea.focus(); + this.focus(); + }, + methods: { + focus() { + this.$refs.textarea?.focus(); + }, }, }; diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index f479c8ae78d..0c6b61fb893 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -1,7 +1,6 @@ @@ -194,7 +182,7 @@ export default { >
- +
@@ -220,6 +208,7 @@ export default { :markdown-docs-path="markdownDocsPath" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" + @input="updateDescriptionDraft" /> diff --git a/app/assets/javascripts/lib/utils/autosave.js b/app/assets/javascripts/lib/utils/autosave.js index dac1da743a2..01316be06a2 100644 --- a/app/assets/javascripts/lib/utils/autosave.js +++ b/app/assets/javascripts/lib/utils/autosave.js @@ -1,8 +1,27 @@ +import { isString } from 'lodash'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +const normalizeKey = (autosaveKey) => { + let normalizedKey; + + if (Array.isArray(autosaveKey) && autosaveKey.every(isString)) { + normalizedKey = autosaveKey.join('/'); + } else if (isString(autosaveKey)) { + normalizedKey = autosaveKey; + } else { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Invalid autosave key'); + } + + return `autosave/${normalizedKey}`; +}; + +const lockVersionKey = (autosaveKey) => `${normalizeKey(autosaveKey)}/lockVersion`; + export const clearDraft = (autosaveKey) => { try { - window.localStorage.removeItem(`autosave/${autosaveKey}`); + window.localStorage.removeItem(normalizeKey(autosaveKey)); + window.localStorage.removeItem(lockVersionKey(autosaveKey)); } catch (e) { // eslint-disable-next-line no-console console.error(e); @@ -11,7 +30,7 @@ export const clearDraft = (autosaveKey) => { export const getDraft = (autosaveKey) => { try { - return window.localStorage.getItem(`autosave/${autosaveKey}`); + return window.localStorage.getItem(normalizeKey(autosaveKey)); } catch (e) { // eslint-disable-next-line no-console console.error(e); @@ -19,9 +38,22 @@ export const getDraft = (autosaveKey) => { } }; -export const updateDraft = (autosaveKey, text) => { +export const getLockVersion = (autosaveKey) => { try { - window.localStorage.setItem(`autosave/${autosaveKey}`, text); + return window.localStorage.getItem(lockVersionKey(autosaveKey)); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return null; + } +}; + +export const updateDraft = (autosaveKey, text, lockVersion) => { + try { + window.localStorage.setItem(normalizeKey(autosaveKey), text); + if (lockVersion) { + window.localStorage.setItem(lockVersionKey(autosaveKey), lockVersion); + } } catch (e) { // eslint-disable-next-line no-console console.error(e); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 9e8eb92d87a..597df2b9bc3 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue @@ -1,6 +1,6 @@ @@ -112,10 +140,22 @@ export default { @selectRevision="onSelectRevision" />
- ... + + + + {{ mode.content }} + +
{ if (data.issuableSetAttribute?.errors?.length) { - createFlash({ + createAlert({ message: data.issuableSetAttribute.errors[0], captureError: true, error: data.issuableSetAttribute.errors[0], @@ -290,7 +290,7 @@ export default { } }) .catch((error) => { - createFlash({ message: this.i18n.updateError, captureError: true, error }); + createAlert({ message: this.i18n.updateError, captureError: true, error }); }) .finally(() => { this.updating = false; 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 61e4aa5a8e6..99e7c825b72 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 @@