diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-20 13:37:47 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-20 13:37:47 +0000 |
commit | aee0a117a889461ce8ced6fcf73207fe017f1d99 (patch) | |
tree | 891d9ef189227a8445d83f35c1b0fc99573f4380 /app/assets/javascripts/vue_shared | |
parent | 8d46af3258650d305f53b819eabf7ab18d22f59e (diff) | |
download | gitlab-ce-aee0a117a889461ce8ced6fcf73207fe017f1d99.tar.gz |
Add latest changes from gitlab-org/gitlab@14-6-stable-eev14.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared')
93 files changed, 3140 insertions, 1737 deletions
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index c24318cb9ad..489d4afa41f 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -220,16 +220,17 @@ export default { class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between" > {{ __('Assignee') }} - <a + <gl-button v-if="isEditable" ref="editButton" - class="btn-link" - href="#" + category="tertiary" + size="small" + class="gl-text-black-normal!" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > {{ __('Edit') }} - </a> + </gl-button> </p> <gl-dropdown diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue index eaa5fc5af04..c512585b980 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue @@ -100,7 +100,8 @@ export default { <gl-button v-if="isEditable" class="gl-text-black-normal!" - variant="link" + category="tertiary" + size="small" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql index f0095abfca1..0460d250f75 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql @@ -2,6 +2,7 @@ mutation createAlertIssue($projectPath: ID!, $iid: String!) { createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) { errors issue { + id iid webUrl } diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql index 0c26fcc0ab2..0ea209ffd39 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql @@ -3,6 +3,7 @@ query alertDetailsAssignees($fullPath: ID!, $alertId: String) { project(fullPath: $fullPath) { + id alertManagementAlerts(iid: $alertId) { nodes { ...AlertDetailItem diff --git a/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue new file mode 100644 index 00000000000..ffbcdefc924 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue @@ -0,0 +1,133 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { GlFormInput } from '@gitlab/ui'; +import { + DurationParseError, + outputChronicDuration, + parseChronicDuration, +} from '~/chronic_duration'; +import { __ } from '~/locale'; + +export default { + components: { + GlFormInput, + }, + model: { + prop: 'value', + event: 'change', + }, + props: { + value: { + type: Number, + required: false, + default: null, + }, + name: { + type: String, + required: false, + default: null, + }, + integerRequired: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + numberData: this.value, + humanReadableData: this.convertDuration(this.value), + isValueValid: this.value === null ? null : true, + }; + }, + computed: { + numberValue: { + get() { + return this.numberData; + }, + set(value) { + if (this.numberData !== value) { + this.numberData = value; + this.humanReadableData = this.convertDuration(value); + this.isValueValid = value === null ? null : true; + } + this.emitEvents(); + }, + }, + humanReadableValue: { + get() { + return this.humanReadableData; + }, + set(value) { + this.humanReadableData = value; + try { + if (value === '') { + this.numberData = null; + this.isValueValid = null; + } else { + this.numberData = parseChronicDuration(value, { + keepZero: true, + raiseExceptions: true, + }); + this.isValueValid = true; + } + } catch (e) { + if (e instanceof DurationParseError) { + this.isValueValid = false; + } else { + Sentry.captureException(e); + } + } + this.emitEvents(true); + }, + }, + isValidDecimal() { + return !this.integerRequired || this.numberData === null || Number.isInteger(this.numberData); + }, + feedback() { + if (this.isValueValid === false) { + return this.$options.i18n.INVALID_INPUT_FEEDBACK; + } + if (!this.isValidDecimal) { + return this.$options.i18n.INVALID_DECIMAL_FEEDBACK; + } + return ''; + }, + }, + i18n: { + INVALID_INPUT_FEEDBACK: __('Please enter a valid time interval'), + INVALID_DECIMAL_FEEDBACK: __('An integer value is required for seconds'), + }, + watch: { + value() { + this.numberValue = this.value; + }, + }, + mounted() { + this.emitEvents(); + }, + methods: { + convertDuration(value) { + return value === null ? '' : outputChronicDuration(value); + }, + emitEvents(emitChange = false) { + if (emitChange && this.isValueValid !== false && this.isValidDecimal) { + this.$emit('change', this.numberData); + } + const { feedback } = this; + this.$refs.text.$el.setCustomValidity(feedback); + this.$refs.hidden.setCustomValidity(feedback); + this.$emit('valid', { + valid: this.isValueValid && this.isValidDecimal, + feedback, + }); + }, + }, +}; +</script> +<template> + <div> + <gl-form-input ref="text" v-bind="$attrs" v-model="humanReadableValue" /> + <input ref="hidden" type="hidden" :name="name" :value="numberValue" /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index fe329b18f30..400be3ef688 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -66,6 +66,11 @@ export default { required: false, default: 'medium', }, + variant: { + type: String, + required: false, + default: 'default', + }, }, computed: { clipboardText() { @@ -92,6 +97,7 @@ export default { :size="size" icon="copy-to-clipboard" :aria-label="__('Copy this value')" + :variant="variant" v-on="$listeners" > <slot></slot> diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index 5f50a699034..ebbc1bfb037 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui'; import { isString, isEmpty } from 'lodash'; import { __, sprintf } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from './user_avatar/user_avatar_link.vue'; export default { diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue index 4c07cf44fed..f93415ced45 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue @@ -26,6 +26,11 @@ export default { type: String, required: true, }, + buttonClass: { + type: String, + required: false, + default: '', + }, buttonTestid: { type: String, required: false, @@ -39,7 +44,7 @@ export default { <div> <gl-button v-gl-modal="$options.modalId" - class="gl-button" + :class="buttonClass" variant="danger" :disabled="disabled" :data-testid="buttonTestid" diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue index 30c96daf7e3..5bbe44b20b3 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -47,7 +47,7 @@ export default { actionPrimary() { return { text: this.confirmButtonText, - attributes: [{ variant: 'danger', disabled: !this.isValid }], + attributes: [{ variant: 'danger', disabled: !this.isValid, class: 'qa-confirm-button' }], }; }, }, @@ -95,7 +95,7 @@ export default { <gl-form-input id="confirm_name_input" v-model="confirmationPhrase" - class="form-control" + class="form-control qa-confirm-input" data-testid="confirm-danger-input" type="text" /> diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue index 7c1d3772acd..72504e5bc50 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue @@ -2,10 +2,13 @@ import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import csrf from '~/lib/utils/csrf'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from './confirm_modal_eventhub'; +import DomElementListener from './dom_element_listener.vue'; export default { components: { GlModal, + DomElementListener, }, directives: { SafeHtml, @@ -30,18 +33,35 @@ export default { }; }, mounted() { - document.querySelectorAll(this.selector).forEach((button) => { - button.addEventListener('click', (e) => { - e.preventDefault(); - - this.path = button.dataset.path; - this.method = button.dataset.method; - this.modalAttributes = JSON.parse(button.dataset.modalAttributes); - this.openModal(); - }); - }); + eventHub.$on(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent); + }, + destroyed() { + eventHub.$off(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent); }, methods: { + onButtonPress(e) { + const element = e.currentTarget; + + if (!element.dataset.path) { + return; + } + + const modalAttributes = element.dataset.modalAttributes + ? JSON.parse(element.dataset.modalAttributes) + : {}; + + this.onOpenEvent({ + path: element.dataset.path, + method: element.dataset.method, + modalAttributes, + }); + }, + onOpenEvent({ path, method, modalAttributes }) { + this.path = path; + this.method = method; + this.modalAttributes = modalAttributes; + this.openModal(); + }, openModal() { this.$refs.modal.show(); }, @@ -61,21 +81,23 @@ export default { </script> <template> - <gl-modal - ref="modal" - :modal-id="modalId" - v-bind="modalAttributes" - @primary="submitModal" - @cancel="closeModal" - > - <form ref="form" :action="path" method="post"> - <!-- Rails workaround for <form method="delete" /> + <dom-element-listener :selector="selector" @click.prevent="onButtonPress"> + <gl-modal + ref="modal" + :modal-id="modalId" + v-bind="modalAttributes" + @primary="submitModal" + @cancel="closeModal" + > + <form ref="form" :action="path" method="post"> + <!-- Rails workaround for <form method="delete" /> https://github.com/rails/rails/blob/master/actionview/app/assets/javascripts/rails-ujs/features/method.coffee --> - <input type="hidden" name="_method" :value="method" /> - <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> - <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div> - <div v-else>{{ modalAttributes.message }}</div> - </form> - </gl-modal> + <input type="hidden" name="_method" :value="method" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div> + <div v-else>{{ modalAttributes.message }}</div> + </form> + </gl-modal> + </dom-element-listener> </template> diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js b/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js new file mode 100644 index 00000000000..f8d9d410ace --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js @@ -0,0 +1,5 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); + +export const EVENT_OPEN_CONFIRM_MODAL = Symbol('OPEN'); diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index 1a96cabf755..e546ca57c5e 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -3,7 +3,7 @@ import { GlIcon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitl import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range'; import { __, sprintf } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import DateTimePickerInput from './date_time_picker_input.vue'; import { defaultTimeRanges, diff --git a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue b/app/assets/javascripts/vue_shared/components/delete_label_modal.vue deleted file mode 100644 index 1ff0938d086..00000000000 --- a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import { GlModal, GlSprintf, GlButton } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; - -export default { - components: { - GlModal, - GlSprintf, - GlButton, - }, - props: { - selector: { - type: String, - required: true, - }, - }, - data() { - return { - labelName: '', - subjectName: '', - destroyPath: '', - modalId: uniqueId('modal-delete-label-'), - }; - }, - mounted() { - document.querySelectorAll(this.selector).forEach((button) => { - button.addEventListener('click', (e) => { - e.preventDefault(); - - const { labelName, subjectName, destroyPath } = button.dataset; - this.labelName = labelName; - this.subjectName = subjectName; - this.destroyPath = destroyPath; - this.openModal(); - }); - }); - }, - methods: { - openModal() { - this.$refs.modal.show(); - }, - closeModal() { - this.$refs.modal.hide(); - }, - }, -}; -</script> - -<template> - <gl-modal ref="modal" :modal-id="modalId"> - <template #modal-title> - <gl-sprintf :message="__('Delete label: %{labelName}')"> - <template #labelName> - {{ labelName }} - </template> - </gl-sprintf> - </template> - <gl-sprintf - :message=" - __( - `%{strongStart}${labelName}%{strongEnd} will be permanently deleted from ${subjectName}. This cannot be undone.`, - ) - " - > - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - <template #modal-footer> - <gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button> - <gl-button - category="primary" - variant="danger" - :href="destroyPath" - data-method="delete" - data-testid="delete-button" - >{{ __('Delete label') }}</gl-button - > - </template> - </gl-modal> -</template> diff --git a/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue new file mode 100644 index 00000000000..cb038a8c4e1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue @@ -0,0 +1,67 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; + +export default { + name: 'DesignNotePin', + components: { + GlIcon, + }, + props: { + position: { + type: Object, + required: false, + default: null, + }, + label: { + type: Number, + required: false, + default: null, + }, + isResolved: { + type: Boolean, + required: false, + default: false, + }, + isInactive: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + isNewNote() { + return this.label === null; + }, + pinLabel() { + return this.isNewNote + ? __('Comment form position') + : sprintf(__("Comment '%{label}' position"), { label: this.label }); + }, + }, +}; +</script> + +<template> + <button + :style="position" + :aria-label="pinLabel" + :class="{ + 'btn-transparent comment-indicator': isNewNote, + 'js-image-badge design-note-pin': !isNewNote, + resolved: isResolved, + inactive: isInactive, + 'gl-absolute': position, + }" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm" + type="button" + @mousedown="$emit('mousedown', $event)" + @mouseup="$emit('mouseup', $event)" + @click="$emit('click', $event)" + > + <gl-icon v-if="isNewNote" name="image-comment-dark" :size="24" /> + <template v-else> + {{ label }} + </template> + </button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue index 52371e42ba1..0621ec14c6c 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue @@ -24,6 +24,7 @@ export default { methods: { dismiss() { this.isDismissed = true; + this.$emit('alertDismissed'); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue new file mode 100644 index 00000000000..ca427ed4897 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue @@ -0,0 +1,28 @@ +<script> +export default { + props: { + selector: { + type: String, + required: true, + }, + }, + mounted() { + this.disposables = Array.from(document.querySelectorAll(this.selector)).flatMap((button) => { + return Object.entries(this.$listeners).map(([key, value]) => { + button.addEventListener(key, value); + return () => { + button.removeEventListener(key, value); + }; + }); + }); + }, + destroyed() { + this.disposables.forEach((x) => { + x(); + }); + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index e1e71639115..8686d317c8a 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -6,15 +6,10 @@ const fileExtensionIcons = { jade: 'pug', pug: 'pug', md: 'markdown', - 'md.rendered': 'markdown', markdown: 'markdown', - 'markdown.rendered': 'markdown', mdown: 'markdown', - 'mdown.rendered': 'markdown', mkd: 'markdown', - 'mkd.rendered': 'markdown', mkdn: 'markdown', - 'mkdn.rendered': 'markdown', rst: 'markdown', blink: 'blink', css: 'css', @@ -23,7 +18,6 @@ const fileExtensionIcons = { less: 'less', json: 'json', yaml: 'yaml', - 'YAML-tmLanguage': 'yaml', yml: 'yaml', xml: 'xml', plist: 'xml', @@ -85,10 +79,7 @@ const fileExtensionIcons = { props: 'settings', toml: 'settings', prefs: 'settings', - 'sln.dotsettings': 'settings', - 'sln.dotsettings.user': 'settings', ts: 'typescript', - 'd.ts': 'typescript-def', marko: 'markojs', pdf: 'pdf', xlsx: 'table', @@ -99,7 +90,6 @@ const fileExtensionIcons = { vscodeignore: 'vscode', vsixmanifest: 'vscode', vsix: 'vscode', - 'code-workplace': 'vscode', suo: 'visualstudio', sln: 'visualstudio', csproj: 'visualstudio', @@ -118,7 +108,6 @@ const fileExtensionIcons = { xz: 'zip', bzip2: 'zip', gzip: 'zip', - '7z': 'zip', rar: 'zip', tgz: 'zip', exe: 'exe', @@ -129,7 +118,6 @@ const fileExtensionIcons = { c: 'c', m: 'c', h: 'h', - 'c++': 'cpp', cc: 'cpp', cpp: 'cpp', mm: 'cpp', @@ -231,7 +219,6 @@ const fileExtensionIcons = { m2v: 'movie', vdi: 'virtual', vbox: 'virtual', - 'vbox-prev': 'virtual', ics: 'email', mp3: 'music', flac: 'music', @@ -277,44 +264,12 @@ const fileExtensionIcons = { ml: 'ocaml', mli: 'ocaml', cmx: 'ocaml', - 'js.map': 'javascript-map', - 'css.map': 'css-map', lock: 'lock', hbs: 'handlebars', mustache: 'handlebars', pl: 'perl', pm: 'perl', hx: 'haxe', - 'spec.ts': 'test-ts', - 'test.ts': 'test-ts', - 'ts.snap': 'test-ts', - 'spec.tsx': 'test-jsx', - 'test.tsx': 'test-jsx', - 'tsx.snap': 'test-jsx', - 'spec.jsx': 'test-jsx', - 'test.jsx': 'test-jsx', - 'jsx.snap': 'test-jsx', - 'spec.js': 'test-js', - 'test.js': 'test-js', - 'js.snap': 'test-js', - 'routing.ts': 'angular-routing', - 'routing.js': 'angular-routing', - 'module.ts': 'angular', - 'module.js': 'angular', - 'ng-template': 'angular', - 'component.ts': 'angular-component', - 'component.js': 'angular-component', - 'guard.ts': 'angular-guard', - 'guard.js': 'angular-guard', - 'service.ts': 'angular-service', - 'service.js': 'angular-service', - 'pipe.ts': 'angular-pipe', - 'pipe.js': 'angular-pipe', - 'filter.js': 'angular-pipe', - 'directive.ts': 'angular-directive', - 'directive.js': 'angular-directive', - 'resolver.ts': 'angular-resolver', - 'resolver.js': 'angular-resolver', pp: 'puppet', ex: 'elixir', exs: 'elixir', @@ -345,11 +300,8 @@ const fileExtensionIcons = { haml: 'haml', yang: 'yang', tf: 'terraform', - 'tf.json': 'terraform', tfvars: 'terraform', tfstate: 'terraform', - 'blade.php': 'laravel', - 'inky.php': 'laravel', applescript: 'applescript', cake: 'cake', feature: 'cucumber', @@ -376,16 +328,68 @@ const fileExtensionIcons = { kv: 'kivy', graphcool: 'graphcool', sbt: 'sbt', + cr: 'crystal', + cu: 'cuda', + cuh: 'cuda', + log: 'log', +}; + +const twoFileExtensionIcons = { + 'gradle.kts': 'gradle', + 'md.rendered': 'markdown', + 'markdown.rendered': 'markdown', + 'mdown.rendered': 'markdown', + 'mkd.rendered': 'markdown', + 'mkdn.rendered': 'markdown', + 'YAML-tmLanguage': 'yaml', + 'sln.dotsettings': 'settings', + 'sln.dotsettings.user': 'settings', + 'd.ts': 'typescript-def', + 'code-workplace': 'vscode', + '7z': 'zip', + 'c++': 'cpp', + 'vbox-prev': 'virtual', + 'js.map': 'javascript-map', + 'css.map': 'css-map', + 'spec.ts': 'test-ts', + 'test.ts': 'test-ts', + 'ts.snap': 'test-ts', + 'spec.tsx': 'test-jsx', + 'test.tsx': 'test-jsx', + 'tsx.snap': 'test-jsx', + 'spec.jsx': 'test-jsx', + 'test.jsx': 'test-jsx', + 'jsx.snap': 'test-jsx', + 'spec.js': 'test-js', + 'test.js': 'test-js', + 'js.snap': 'test-js', + 'routing.ts': 'angular-routing', + 'routing.js': 'angular-routing', + 'module.ts': 'angular', + 'module.js': 'angular', + 'ng-template': 'angular', + 'component.ts': 'angular-component', + 'component.js': 'angular-component', + 'guard.ts': 'angular-guard', + 'guard.js': 'angular-guard', + 'service.ts': 'angular-service', + 'service.js': 'angular-service', + 'pipe.ts': 'angular-pipe', + 'pipe.js': 'angular-pipe', + 'filter.js': 'angular-pipe', + 'directive.ts': 'angular-directive', + 'directive.js': 'angular-directive', + 'resolver.ts': 'angular-resolver', + 'resolver.js': 'angular-resolver', + 'tf.json': 'terraform', + 'blade.php': 'laravel', + 'inky.php': 'laravel', 'reducer.ts': 'ngrx-reducer', 'rootReducer.ts': 'ngrx-reducer', 'state.ts': 'ngrx-state', 'actions.ts': 'ngrx-actions', 'effects.ts': 'ngrx-effects', - cr: 'crystal', 'drone.yml': 'drone', - cu: 'cuda', - cuh: 'cuda', - log: 'log', }; const fileNameIcons = { @@ -598,6 +602,9 @@ const fileNameIcons = { export default function getIconForFile(name) { return ( - fileNameIcons[name] || fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || '' + fileNameIcons[name] || + twoFileExtensionIcons[name ? name.split('.').slice(-2).join('.') : ''] || + fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || + '' ); } diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 0b0a416b7ef..2227047a909 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -146,6 +146,7 @@ export default { ref="textOutput" :style="levelIndentation" class="file-row-name" + :title="file.name" data-qa-selector="file_name_content" :data-qa-file-name="file.name" data-testid="file-row-name-container" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index d9290e86bca..810d9f782b9 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -2,7 +2,6 @@ import { __ } from '~/locale'; export const DEBOUNCE_DELAY = 200; export const MAX_RECENT_TOKENS_SIZE = 3; -export const WEIGHT_TOKEN_SUGGESTIONS_SIZE = 21; export const FILTER_NONE = 'None'; export const FILTER_ANY = 'Any'; @@ -24,22 +23,11 @@ export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title: export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; -export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([ - { value: FILTER_CURRENT, text: __('Current') }, -]); - export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ { value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming') }, { value: FILTER_STARTED, text: __('Started'), title: __('Started') }, ]); -export const DEFAULT_MILESTONES_GRAPHQL = [ - { value: 'any', text: __('Any'), title: __('Any') }, - { value: 'none', text: __('None'), title: __('None') }, - { value: '#upcoming', text: __('Upcoming'), title: __('Upcoming') }, - { value: '#started', text: __('Started'), title: __('Started') }, -]; - export const SortDirection = { descending: 'descending', ascending: 'ascending', @@ -56,6 +44,3 @@ export const TOKEN_TITLE_TYPE = __('Type'); export const TOKEN_TITLE_RELEASE = __('Release'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); -export const TOKEN_TITLE_ITERATION = __('Iteration'); -export const TOKEN_TITLE_EPIC = __('Epic'); -export const TOKEN_TITLE_WEIGHT = __('Weight'); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql deleted file mode 100644 index 9e9bda8ad3e..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql +++ /dev/null @@ -1,15 +0,0 @@ -fragment EpicNode on Epic { - id - iid - group { - fullPath - } - title - state - reference - referencePath: reference(full: true) - webPath - webUrl - createdAt - closedAt -} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql deleted file mode 100644 index 4bb4b586fc9..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql +++ /dev/null @@ -1,16 +0,0 @@ -#import "./epic.fragment.graphql" - -query searchEpics($fullPath: ID!, $search: String, $state: EpicState) { - group(fullPath: $fullPath) { - epics( - search: $search - state: $state - includeAncestorGroups: true - includeDescendantGroups: false - ) { - nodes { - ...EpicNode - } - } - } -} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index b3b3d5c88c6..06478a89721 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -87,7 +87,6 @@ export default { :get-active-token-value="getActiveAuthor" :default-suggestions="defaultAuthors" :preloaded-suggestions="preloadedAuthors" - :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" @fetch-suggestions="fetchAuthors" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index cee7c40aa83..bbc1888bc0b 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -4,12 +4,17 @@ import { GlFilteredSearchSuggestion, GlDropdownDivider, GlDropdownSectionHeader, + GlDropdownText, GlLoadingIcon, } from '@gitlab/ui'; import { debounce } from 'lodash'; import { DEBOUNCE_DELAY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; -import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; +import { + getRecentlyUsedSuggestions, + setTokenValueToRecentlyUsed, + stripQuotes, +} from '../filtered_search_utils'; export default { components: { @@ -17,6 +22,7 @@ export default { GlFilteredSearchSuggestion, GlDropdownDivider, GlDropdownSectionHeader, + GlDropdownText, GlLoadingIcon, }, props: { @@ -57,11 +63,6 @@ export default { required: false, default: () => [], }, - recentSuggestionsStorageKey: { - type: String, - required: false, - default: '', - }, valueIdentifier: { type: String, required: false, @@ -76,14 +77,14 @@ export default { data() { return { searchKey: '', - recentSuggestions: this.recentSuggestionsStorageKey - ? getRecentlyUsedSuggestions(this.recentSuggestionsStorageKey) + recentSuggestions: this.config.recentSuggestionsStorageKey + ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey) : [], }; }, computed: { isRecentSuggestionsEnabled() { - return Boolean(this.recentSuggestionsStorageKey); + return Boolean(this.config.recentSuggestionsStorageKey); }, recentTokenIds() { return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); @@ -119,6 +120,9 @@ export default { showDefaultSuggestions() { return this.availableDefaultSuggestions.length > 0; }, + showNoMatchesText() { + return this.searchKey && !this.availableSuggestions.length; + }, showRecentSuggestions() { return ( this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey @@ -163,11 +167,20 @@ export default { this.searchKey = data; if (!this.suggestionsLoading && !this.activeTokenValue) { - const search = this.searchTerm ? this.searchTerm : data; + let search = this.searchTerm ? this.searchTerm : data; + + if (search.startsWith('"') && search.endsWith('"')) { + search = stripQuotes(search); + } else if (search.startsWith('"')) { + search = search.slice(1, search.length); + } + this.$emit('fetch-suggestions', search); } }, DEBOUNCE_DELAY), - handleTokenValueSelected(activeTokenValue) { + handleTokenValueSelected(selectedValue) { + const activeTokenValue = this.getActiveTokenValue(this.suggestions, selectedValue); + // Make sure that; // 1. Recently used values feature is enabled // 2. User has actually selected a value @@ -177,7 +190,7 @@ export default { activeTokenValue && !this.preloadedTokenIds.includes(activeTokenValue[this.valueIdentifier]) ) { - setTokenValueToRecentlyUsed(this.recentSuggestionsStorageKey, activeTokenValue); + setTokenValueToRecentlyUsed(this.config.recentSuggestionsStorageKey, activeTokenValue); } }, }, @@ -192,7 +205,7 @@ export default { v-bind="$attrs" v-on="$listeners" @input="handleInput" - @select="handleTokenValueSelected(activeTokenValue)" + @select="handleTokenValueSelected" > <template #view-token="viewTokenProps"> <slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> @@ -222,6 +235,9 @@ export default { :suggestions="preloadedSuggestions" ></slot> <gl-loading-icon v-if="suggestionsLoading" size="sm" /> + <gl-dropdown-text v-else-if="showNoMatchesText"> + {{ __('No matches found') }} + </gl-dropdown-text> <template v-else> <slot name="suggestions-list" :suggestions="availableSuggestions"></slot> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue deleted file mode 100644 index 9c2f5306654..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ /dev/null @@ -1,129 +0,0 @@ -<script> -import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __ } from '~/locale'; -import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; -import searchEpicsQuery from '../queries/search_epics.query.graphql'; - -import BaseToken from './base_token.vue'; - -export default { - prefix: '&', - separator: '::', - components: { - BaseToken, - GlFilteredSearchSuggestion, - }, - props: { - config: { - type: Object, - required: true, - }, - value: { - type: Object, - required: true, - }, - active: { - type: Boolean, - required: true, - }, - }, - data() { - return { - epics: this.config.initialEpics || [], - loading: false, - }; - }, - computed: { - idProperty() { - return this.config.idProperty || 'iid'; - }, - currentValue() { - const epicIid = Number(this.value.data); - if (epicIid) { - return epicIid; - } - return this.value.data; - }, - defaultEpics() { - return this.config.defaultEpics || DEFAULT_NONE_ANY; - }, - availableDefaultEpics() { - if (this.value.operator === OPERATOR_IS_NOT) { - return this.defaultEpics.filter( - (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value), - ); - } - return this.defaultEpics; - }, - }, - methods: { - fetchEpics(search = '') { - return this.$apollo - .query({ - query: searchEpicsQuery, - variables: { fullPath: this.config.fullPath, search }, - }) - .then(({ data }) => data.group?.epics.nodes); - }, - fetchEpicsBySearchTerm(search) { - this.loading = true; - this.fetchEpics(search) - .then((response) => { - this.epics = Array.isArray(response) ? response : response?.data; - }) - .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) - .finally(() => { - this.loading = false; - }); - }, - getActiveEpic(epics, data) { - if (data && epics.length) { - return epics.find((epic) => this.getValue(epic) === data); - } - return undefined; - }, - getValue(epic) { - return this.getEpicIdProperty(epic).toString(); - }, - displayValue(epic) { - return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${ - epic?.title - }`; - }, - getEpicIdProperty(epic) { - return getIdFromGraphQLId(epic[this.idProperty]); - }, - }, -}; -</script> - -<template> - <base-token - :config="config" - :value="value" - :active="active" - :suggestions-loading="loading" - :suggestions="epics" - :get-active-token-value="getActiveEpic" - :default-suggestions="availableDefaultEpics" - :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" - search-by="title" - @fetch-suggestions="fetchEpicsBySearchTerm" - v-on="$listeners" - > - <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> - {{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }} - </template> - <template #suggestions-list="{ suggestions }"> - <gl-filtered-search-suggestion - v-for="epic in suggestions" - :key="epic.id" - :value="getValue(epic)" - > - {{ epic.title }} - </gl-filtered-search-suggestion> - </template> - </base-token> -</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue deleted file mode 100644 index aff93ebc9c0..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue +++ /dev/null @@ -1,138 +0,0 @@ -<script> -import { GlDropdownDivider, GlDropdownSectionHeader, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __ } from '~/locale'; -import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { formatDate } from '~/lib/utils/datetime_utility'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { DEFAULT_ITERATIONS } from '../constants'; - -export default { - components: { - BaseToken, - GlDropdownDivider, - GlDropdownSectionHeader, - GlFilteredSearchSuggestion, - }, - mixins: [glFeatureFlagMixin()], - props: { - active: { - type: Boolean, - required: true, - }, - config: { - type: Object, - required: true, - }, - value: { - type: Object, - required: true, - }, - }, - data() { - return { - iterations: this.config.initialIterations || [], - loading: false, - }; - }, - computed: { - defaultIterations() { - return this.config.defaultIterations || DEFAULT_ITERATIONS; - }, - }, - methods: { - getActiveIteration(iterations, data) { - return iterations.find((iteration) => this.getValue(iteration) === data); - }, - groupIterationsByCadence(iterations) { - const cadences = []; - iterations.forEach((iteration) => { - if (!iteration.iterationCadence) { - return; - } - const { title } = iteration.iterationCadence; - const cadenceIteration = { - id: iteration.id, - title: iteration.title, - period: this.getIterationPeriod(iteration), - }; - const cadence = cadences.find((cad) => cad.title === title); - if (cadence) { - cadence.iterations.push(cadenceIteration); - } else { - cadences.push({ title, iterations: [cadenceIteration] }); - } - }); - return cadences; - }, - fetchIterations(searchTerm) { - this.loading = true; - this.config - .fetchIterations(searchTerm) - .then((response) => { - this.iterations = Array.isArray(response) ? response : response.data; - }) - .catch(() => { - createFlash({ message: __('There was a problem fetching iterations.') }); - }) - .finally(() => { - this.loading = false; - }); - }, - getValue(iteration) { - return String(getIdFromGraphQLId(iteration.id)); - }, - /** - * TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619 - * This method also exists as a utility function in ee/../iterations/utils.js - * Remove the duplication when iteration token is moved to EE. - */ - getIterationPeriod({ startDate, dueDate }) { - const start = formatDate(startDate, 'mmm d, yyyy', true); - const due = formatDate(dueDate, 'mmm d, yyyy', true); - return `${start} - ${due}`; - }, - }, -}; -</script> - -<template> - <base-token - :active="active" - :config="config" - :value="value" - :default-suggestions="defaultIterations" - :suggestions="iterations" - :suggestions-loading="loading" - :get-active-token-value="getActiveIteration" - @fetch-suggestions="fetchIterations" - v-on="$listeners" - > - <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> - {{ activeTokenValue ? activeTokenValue.title : inputValue }} - </template> - <template #suggestions-list="{ suggestions }"> - <template v-for="(cadence, index) in groupIterationsByCadence(suggestions)"> - <gl-dropdown-divider v-if="index !== 0" :key="index" /> - <gl-dropdown-section-header - :key="cadence.title" - class="gl-overflow-hidden" - :title="cadence.title" - > - {{ cadence.title }} - </gl-dropdown-section-header> - <gl-filtered-search-suggestion - v-for="iteration in cadence.iterations" - :key="iteration.id" - :value="getValue(iteration)" - > - {{ iteration.title }} - <div v-if="glFeatures.iterationCadences" class="gl-text-gray-400"> - {{ iteration.period }} - </div> - </gl-filtered-search-suggestion> - </template> - </template> - </base-token> -</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index c31f3a25fb1..3f7a8920f48 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -104,7 +104,6 @@ export default { :suggestions="labels" :get-active-token-value="getActiveLabel" :default-suggestions="defaultLabels" - :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" @fetch-suggestions="fetchLabels" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 523438f459c..0d3394788fa 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -2,7 +2,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; +import { sortMilestonesByDueDate } from '~/milestones/utils'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { DEFAULT_MILESTONES } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue deleted file mode 100644 index 280fb234576..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue +++ /dev/null @@ -1,66 +0,0 @@ -<script> -import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { DEFAULT_NONE_ANY, WEIGHT_TOKEN_SUGGESTIONS_SIZE } from '../constants'; - -const weights = Array.from(Array(WEIGHT_TOKEN_SUGGESTIONS_SIZE), (_, index) => index.toString()); - -export default { - components: { - BaseToken, - GlFilteredSearchSuggestion, - }, - props: { - active: { - type: Boolean, - required: true, - }, - config: { - type: Object, - required: true, - }, - value: { - type: Object, - required: true, - }, - }, - data() { - return { - weights, - }; - }, - computed: { - defaultWeights() { - return this.config.defaultWeights || DEFAULT_NONE_ANY; - }, - }, - methods: { - getActiveWeight(weightSuggestions, data) { - return weightSuggestions.find((weight) => weight === data); - }, - updateWeights(searchTerm) { - const weight = parseInt(searchTerm, 10); - this.weights = Number.isNaN(weight) ? weights : [String(weight)]; - }, - }, -}; -</script> - -<template> - <base-token - :active="active" - :config="config" - :value="value" - :default-suggestions="defaultWeights" - :suggestions="weights" - :get-active-token-value="getActiveWeight" - @fetch-suggestions="updateWeights" - v-on="$listeners" - > - <template #suggestions-list="{ suggestions }"> - <gl-filtered-search-suggestion v-for="weight of suggestions" :key="weight" :value="weight"> - {{ weight }} - </gl-filtered-search-suggestion> - </template> - </base-token> -</template> diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js new file mode 100644 index 00000000000..cdd7a074f34 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js @@ -0,0 +1,27 @@ +import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue'; + +export default { + component: InputCopyToggleVisibility, + title: 'vue_shared/components/form/input_copy_toggle_visibility', +}; + +const defaultProps = { + value: 'hR8x1fuJbzwu5uFKLf9e', + formInputGroupProps: { class: 'gl-form-input-xl' }, +}; + +const Template = (args, { argTypes }) => ({ + components: { InputCopyToggleVisibility }, + props: Object.keys(argTypes), + template: `<input-copy-toggle-visibility + :value="value" + :initial-visibility="initialVisibility" + :show-toggle-visibility-button="showToggleVisibilityButton" + :show-copy-button="showCopyButton" + :form-input-group-props="formInputGroupProps" + :copy-button-title="copyButtonTitle" + />`, +}); + +export const Default = Template.bind({}); +Default.args = defaultProps; diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue new file mode 100644 index 00000000000..06949b59823 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue @@ -0,0 +1,127 @@ +<script> +import { GlFormInputGroup, GlFormGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; + +import { __ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + name: 'InputCopyToggleVisibility', + i18n: { + toggleVisibilityLabelHide: __('Click to hide'), + toggleVisibilityLabelReveal: __('Click to reveal'), + }, + components: { + GlFormInputGroup, + GlFormGroup, + GlButton, + ClipboardButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + initialVisibility: { + type: Boolean, + required: false, + default: false, + }, + showToggleVisibilityButton: { + type: Boolean, + required: false, + default: true, + }, + showCopyButton: { + type: Boolean, + required: false, + default: true, + }, + copyButtonTitle: { + type: String, + required: false, + default: __('Copy'), + }, + formInputGroupProps: { + type: Object, + required: false, + default() { + return {}; + }, + }, + }, + data() { + return { + valueIsVisible: this.initialVisibility, + }; + }, + computed: { + toggleVisibilityLabel() { + return this.valueIsVisible + ? this.$options.i18n.toggleVisibilityLabelHide + : this.$options.i18n.toggleVisibilityLabelReveal; + }, + toggleVisibilityIcon() { + return this.valueIsVisible ? 'eye-slash' : 'eye'; + }, + computedValueIsVisible() { + return !this.showToggleVisibilityButton || this.valueIsVisible; + }, + displayedValue() { + return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20); + }, + }, + methods: { + handleToggleVisibilityButtonClick() { + this.valueIsVisible = !this.valueIsVisible; + + this.$emit('visibility-change', this.valueIsVisible); + }, + handleCopyButtonClick() { + this.$emit('copy'); + }, + handleFormInputCopy(event) { + if (this.computedValueIsVisible) { + return; + } + + event.clipboardData.setData('text/plain', this.value); + event.preventDefault(); + }, + }, +}; +</script> +<template> + <gl-form-group v-bind="$attrs"> + <gl-form-input-group + :value="displayedValue" + input-class="gl-font-monospace! gl-cursor-default!" + select-on-click + readonly + v-bind="formInputGroupProps" + @copy="handleFormInputCopy" + > + <template v-if="showToggleVisibilityButton || showCopyButton" #append> + <gl-button + v-if="showToggleVisibilityButton" + v-gl-tooltip.hover="toggleVisibilityLabel" + :aria-label="toggleVisibilityLabel" + :icon="toggleVisibilityIcon" + @click="handleToggleVisibilityButtonClick" + /> + <clipboard-button + v-if="showCopyButton" + :text="value" + :title="copyButtonTitle" + @click="handleCopyButtonClick" + /> + </template> + </gl-form-input-group> + <template v-for="slot in Object.keys($slots)" #[slot]> + <slot :name="slot"></slot> + </template> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 6ace0bd88f8..9bff469b670 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -5,6 +5,7 @@ import { GlSafeHtmlDirective, GlAvatarLink, GlAvatarLabeled, + GlTooltip, } from '@gitlab/ui'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '../../emoji'; @@ -26,6 +27,7 @@ export default { GlButton, GlAvatarLink, GlAvatarLabeled, + GlTooltip, }, directives: { GlTooltip: GlTooltipDirective, diff --git a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js deleted file mode 100644 index 28aa93d6680..00000000000 --- a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import IssuableHeaderWarnings from './issuable_header_warnings.vue'; - -export default function issuableHeaderWarnings(store) { - const el = document.getElementById('js-issuable-header-warnings'); - - if (!el) { - return false; - } - - const { hidden } = el.dataset; - - return new Vue({ - el, - store, - provide: { hidden: parseBoolean(hidden) }, - render(createElement) { - return createElement(IssuableHeaderWarnings); - }, - }); -} diff --git a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue b/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue deleted file mode 100644 index 82223ab9ef4..00000000000 --- a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue +++ /dev/null @@ -1,61 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; -import { __ } from '~/locale'; - -export default { - components: { - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - inject: ['hidden'], - computed: { - ...mapGetters(['getNoteableData']), - isLocked() { - return this.getNoteableData.discussion_locked; - }, - isConfidential() { - return this.getNoteableData.confidential; - }, - warningIconsMeta() { - return [ - { - iconName: 'lock', - visible: this.isLocked, - dataTestId: 'locked', - }, - { - iconName: 'eye-slash', - visible: this.isConfidential, - dataTestId: 'confidential', - }, - { - iconName: 'spam', - visible: this.hidden, - dataTestId: 'hidden', - tooltip: __('This issue is hidden because its author has been banned'), - }, - ]; - }, - }, -}; -</script> - -<template> - <div class="gl-display-inline-block"> - <template v-for="meta in warningIconsMeta"> - <div - v-if="meta.visible" - :key="meta.iconName" - v-gl-tooltip - :data-testid="meta.dataTestId" - :title="meta.tooltip || null" - class="issuable-warning-icon inline" - > - <gl-icon :name="meta.iconName" class="icon" /> - </div> - </template> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue deleted file mode 100644 index 5955f31fc70..00000000000 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ /dev/null @@ -1,107 +0,0 @@ -<script> -import { GlTooltipDirective } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; - -export default { - components: { - UserAvatarLink, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - assignees: { - type: Array, - required: true, - }, - iconSize: { - type: Number, - required: false, - default: 24, - }, - imgCssClasses: { - type: String, - required: false, - default: '', - }, - maxVisible: { - type: Number, - required: false, - default: 3, - }, - }, - data() { - return { - maxAssignees: 99, - }; - }, - computed: { - assigneesToShow() { - const numShownAssignees = this.assignees.length - this.numHiddenAssignees; - return this.assignees.slice(0, numShownAssignees); - }, - assigneesCounterTooltip() { - return sprintf(__('%{count} more assignees'), { count: this.numHiddenAssignees }); - }, - numHiddenAssignees() { - if (this.assignees.length > this.maxVisible) { - return this.assignees.length - this.maxVisible + 1; - } - return 0; - }, - assigneeCounterLabel() { - if (this.numHiddenAssignees > this.maxAssignees) { - return `${this.maxAssignees}+`; - } - - return `+${this.numHiddenAssignees}`; - }, - }, - methods: { - avatarUrlTitle(assignee) { - return sprintf(__('Assigned to %{assigneeName}'), { - assigneeName: assignee.name, - }); - }, - // This method is for backward compat - // since Graph query would return camelCase - // props while Rails would return snake_case - webUrl(assignee) { - return assignee.web_url || assignee.webUrl; - }, - avatarUrl(assignee) { - return assignee.avatar_url || assignee.avatarUrl; - }, - }, -}; -</script> -<template> - <div> - <user-avatar-link - v-for="assignee in assigneesToShow" - :key="assignee.id" - :link-href="webUrl(assignee)" - :img-alt="avatarUrlTitle(assignee)" - :img-css-classes="imgCssClasses" - :img-src="avatarUrl(assignee)" - :img-size="iconSize" - class="js-no-trigger author-link" - tooltip-placement="bottom" - data-qa-selector="assignee_link" - > - <span class="js-assignee-tooltip"> - <span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }} - <span v-if="assignee.username" class="text-white-50">@{{ assignee.username }}</span> - </span> - </user-avatar-link> - <span - v-if="numHiddenAssignees > 0" - v-gl-tooltip.bottom - :title="assigneesCounterTooltip" - class="avatar-counter" - data-qa-selector="avatar_counter_content" - >{{ assigneeCounterLabel }}</span - > - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue deleted file mode 100644 index 6a0c21602bd..00000000000 --- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue +++ /dev/null @@ -1,91 +0,0 @@ -<script> -import { GlTooltip, GlIcon } from '@gitlab/ui'; -import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility'; -import { __, sprintf } from '~/locale'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; - -export default { - components: { - GlIcon, - GlTooltip, - }, - mixins: [timeagoMixin], - props: { - milestone: { - type: Object, - required: true, - }, - }, - computed: { - milestoneDue() { - const dueDate = this.milestone.due_date || this.milestone.dueDate; - - return dueDate ? parsePikadayDate(dueDate) : null; - }, - milestoneStart() { - const startDate = this.milestone.start_date || this.milestone.startDate; - - return startDate ? parsePikadayDate(startDate) : null; - }, - isMilestoneStarted() { - if (!this.milestoneStart) { - return false; - } - return Date.now() > this.milestoneStart; - }, - isMilestonePastDue() { - if (!this.milestoneDue) { - return false; - } - return Date.now() > this.milestoneDue; - }, - milestoneDatesAbsolute() { - if (this.milestoneDue) { - return `(${dateInWords(this.milestoneDue)})`; - } else if (this.milestoneStart) { - return `(${dateInWords(this.milestoneStart)})`; - } - return ''; - }, - milestoneDatesHuman() { - if (this.milestoneStart || this.milestoneDue) { - if (this.milestoneDue) { - return timeFor( - this.milestoneDue, - sprintf(__('Expired %{expiredOn}'), { - expiredOn: this.timeFormatted(this.milestoneDue), - }), - ); - } - - return sprintf( - this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'), - { - startsIn: this.timeFormatted(this.milestoneStart), - }, - ); - } - return ''; - }, - }, -}; -</script> -<template> - <div ref="milestoneDetails" class="issue-milestone-details"> - <gl-icon :size="16" class="gl-mr-2" name="clock" /> - <span class="milestone-title d-inline-block">{{ milestone.title }}</span> - <gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone"> - <span class="bold">{{ __('Milestone') }}</span> <br /> - <span>{{ milestone.title }}</span> <br /> - <span - v-if="milestoneStart || milestoneDue" - :class="{ - 'text-danger-muted': isMilestonePastDue, - 'text-tertiary': !isMilestonePastDue, - }" - ><span>{{ milestoneDatesHuman }}</span - ><br /><span>{{ milestoneDatesAbsolute }}</span> - </span> - </gl-tooltip> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue deleted file mode 100644 index 8aeff9257a5..00000000000 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ /dev/null @@ -1,201 +0,0 @@ -<script> -import '~/commons/bootstrap'; -import { - GlIcon, - GlTooltip, - GlTooltipDirective, - GlButton, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; -import IssueDueDate from '~/boards/components/issue_due_date.vue'; -import { sprintf } from '~/locale'; -import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; -import CiIcon from '../ci_icon.vue'; -import IssueAssignees from './issue_assignees.vue'; -import IssueMilestone from './issue_milestone.vue'; - -export default { - name: 'IssueItem', - components: { - IssueMilestone, - IssueAssignees, - CiIcon, - GlIcon, - GlTooltip, - IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), - IssueDueDate, - GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - SafeHtml, - }, - mixins: [relatedIssuableMixin], - props: { - canReorder: { - type: Boolean, - required: false, - default: false, - }, - isLocked: { - type: Boolean, - required: false, - default: false, - }, - lockedMessage: { - type: String, - required: false, - default: '', - }, - }, - computed: { - stateTitle() { - return sprintf( - '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>', - { - state: this.stateText, - timeInWords: this.stateTimeInWords, - timestamp: this.stateTimestamp, - }, - ); - }, - iconClasses() { - return `${this.iconClass} ic-${this.iconName}`; - }, - }, -}; -</script> - -<template> - <div - :class="{ - 'issuable-info-container': !canReorder, - 'card-body': canReorder, - }" - class="item-body d-flex align-items-center py-2 px-3" - > - <div - class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 flex-xl-nowrap gl-min-h-7" - > - <!-- Title area: Status icon (XL) and title --> - <div class="item-title d-flex align-items-xl-center mb-xl-0 gl-min-w-0"> - <div ref="iconElementXL"> - <gl-icon - v-if="hasState" - ref="iconElementXL" - class="mr-2 d-block" - :class="iconClasses" - :name="iconName" - :title="stateTitle" - :aria-label="state" - /> - </div> - <gl-tooltip :target="() => $refs.iconElementXL"> - <span v-safe-html="stateTitle"></span> - </gl-tooltip> - <gl-icon - v-if="confidential" - v-gl-tooltip - name="eye-slash" - :title="__('Confidential')" - class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0" - :aria-label="__('Confidential')" - /> - <a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a> - </div> - - <!-- Info area: meta, path, and assignees --> - <div class="item-info-area d-flex flex-xl-grow-1 flex-shrink-0"> - <!-- Meta area: path and attributes --> - <!-- If there is no room beside the path, meta attributes are put ABOVE it (flex-wrap-reverse). --> - <!-- See design: https://gitlab-org.gitlab.io/gitlab-design/hosted/pedro/%2383-issue-mr-rows-cards-spec-previews/#artboard16 --> - <div - class="item-meta d-flex flex-wrap-reverse justify-content-start justify-content-md-between" - > - <!-- Path area: status icon (<XL), path, issue # --> - <div - class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2" - > - <gl-tooltip :target="() => this.$refs.iconElement"> - <span v-safe-html="stateTitle"></span> - </gl-tooltip> - <span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{ - itemPath - }}</span> - <span>{{ pathIdSeparator }}{{ itemId }}</span> - </div> - - <!-- Attributes area: CI, epic count, weight, milestone --> - <!-- They have a different order on large screen sizes --> - <div class="item-attributes-area d-flex align-items-center mt-2 mt-xl-0"> - <span v-if="hasPipeline" class="mr-ci-status order-md-last"> - <a :href="pipelineStatus.details_path"> - <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" /> - </a> - </span> - - <issue-milestone - v-if="hasMilestone" - :milestone="milestone" - class="d-flex align-items-center item-milestone order-md-first ml-md-0" - /> - - <!-- Flex order for slots is defined in the parent component: e.g. related_issues_block.vue --> - <span v-if="weight > 0" class="order-md-1"> - <issue-weight - :weight="weight" - class="item-weight gl-display-flex gl-align-items-center" - tag-name="span" - /> - </span> - - <span v-if="dueDate" class="order-md-1"> - <issue-due-date - :date="dueDate" - :closed="Boolean(closedAt)" - tooltip-placement="top" - css-class="item-due-date gl-display-flex gl-align-items-center" - /> - </span> - - <issue-assignees - v-if="hasAssignees" - :assignees="assignees" - class="item-assignees align-items-center align-self-end flex-shrink-0 order-md-2 d-none d-md-flex" - /> - </div> - </div> - - <!-- Assignees. On small layouts, these are put here, at the end of the card. --> - <issue-assignees - v-if="assignees.length !== 0" - :assignees="assignees" - class="item-assignees d-flex align-items-center align-self-end flex-shrink-0 d-md-none ml-2" - /> - </div> - </div> - - <span - v-if="isLocked" - ref="lockIcon" - v-gl-tooltip - class="gl-px-3 gl-display-inline-block gl-cursor-not-allowed" - :title="lockedMessage" - > - <gl-icon name="lock" /> - </span> - <gl-button - v-else-if="canRemove" - ref="removeButton" - v-gl-tooltip - icon="close" - category="tertiary" - :disabled="removeDisabled" - class="js-issue-item-remove-button gl-ml-3" - data-qa-selector="remove_related_issue_button" - :title="__('Remove')" - :aria-label="__('Remove')" - @click="onRemoveRequest" - /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/line_numbers.vue b/app/assets/javascripts/vue_shared/components/line_numbers.vue new file mode 100644 index 00000000000..7e17cca3dcc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/line_numbers.vue @@ -0,0 +1,57 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + lines: { + type: Number, + required: true, + }, + }, + data() { + return { + currentlyHighlightedLine: null, + }; + }, + mounted() { + this.scrollToLine(); + }, + methods: { + scrollToLine(hash = window.location.hash) { + const lineToHighlight = hash && this.$el.querySelector(hash); + + if (!lineToHighlight) { + return; + } + + if (this.currentlyHighlightedLine) { + this.currentlyHighlightedLine.classList.remove('hll'); + } + + lineToHighlight.classList.add('hll'); + this.currentlyHighlightedLine = lineToHighlight; + lineToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, + }, +}; +</script> +<template> + <div class="line-numbers"> + <gl-link + v-for="line in lines" + :id="`L${line}`" + :key="line" + class="diff-line-num" + :href="`#L${line}`" + :data-line-number="line" + @click="scrollToLine(`#L${line}`)" + > + <gl-icon :size="12" name="link" /> + {{ line }} + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index e36cfb3b275..2f6776f835e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -165,6 +165,6 @@ export default { <template> <div> <div class="flash-container js-suggestions-flash"></div> - <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md"></div> + <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 912aa8ce294..f1c293c87f4 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,18 +1,13 @@ <script> import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui'; -import { isExperimentVariant } from '~/experimentation/utils'; -import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; -import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants'; export default { - inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT, components: { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon, - InviteMembersTrigger, }, props: { markdownDocsPath: { @@ -34,9 +29,6 @@ export default { hasQuickActionsDocsPath() { return this.quickActionsDocsPath !== ''; }, - inviteCommentEnabled() { - return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link'); - }, }, }; </script> @@ -67,16 +59,6 @@ export default { </template> </div> <span v-if="canAttachFile" class="uploading-container"> - <invite-members-trigger - v-if="inviteCommentEnabled" - classes="gl-mr-3 gl-vertical-align-text-bottom" - :display-text="s__('InviteMember|Invite Member')" - icon="assignee" - variant="link" - :track-experiment="$options.inviteMembersInComment" - :trigger-source="$options.inviteMembersInComment" - data-track-action="comment_invite_click" - /> <span class="uploading-progress-container hide"> <gl-icon name="media" /> <span class="attaching-file-message"></span> diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue new file mode 100644 index 00000000000..7d2af7983d1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue @@ -0,0 +1,93 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export const i18n = { + DEFAULT_TEXT: __('Select a new namespace'), + GROUPS: __('Groups'), + USERS: __('Users'), +}; + +const filterByName = (data, searchTerm = '') => + data.filter((d) => d.humanName.toLowerCase().includes(searchTerm)); + +export default { + name: 'NamespaceSelect', + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, + }, + props: { + data: { + type: Object, + required: true, + }, + fullWidth: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + searchTerm: '', + selectedNamespace: null, + }; + }, + computed: { + hasUserNamespaces() { + return this.data.user?.length; + }, + hasGroupNamespaces() { + return this.data.group?.length; + }, + filteredGroupNamespaces() { + if (!this.hasGroupNamespaces) return []; + return filterByName(this.data.group, this.searchTerm); + }, + filteredUserNamespaces() { + if (!this.hasUserNamespaces) return []; + return filterByName(this.data.user, this.searchTerm); + }, + selectedNamespaceText() { + return this.selectedNamespace?.humanName || this.$options.i18n.DEFAULT_TEXT; + }, + }, + methods: { + handleSelect(item) { + this.selectedNamespace = item; + this.$emit('select', item); + }, + }, + i18n, +}; +</script> +<template> + <gl-dropdown :text="selectedNamespaceText" :block="fullWidth"> + <template #header> + <gl-search-box-by-type v-model.trim="searchTerm" /> + </template> + <div v-if="hasGroupNamespaces" class="qa-namespaces-list-groups"> + <gl-dropdown-section-header>{{ $options.i18n.GROUPS }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in filteredGroupNamespaces" + :key="item.id" + class="qa-namespaces-list-item" + @click="handleSelect(item)" + >{{ item.humanName }}</gl-dropdown-item + > + </div> + <div v-if="hasUserNamespaces" class="qa-namespaces-list-users"> + <gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in filteredUserNamespaces" + :key="item.id" + class="qa-namespaces-list-item" + @click="handleSelect(item)" + >{{ item.humanName }}</gl-dropdown-item + > + </div> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 9ea14ed506c..624dbcc6d8e 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -39,6 +39,11 @@ export default { required: false, default: null, }, + isOverviewTab: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapGetters(['getUserData']), @@ -46,9 +51,10 @@ export default { return renderMarkdown(this.note.body); }, avatarSize() { - if (this.line) { - return 16; + if (this.line && !this.isOverviewTab) { + return 24; } + return 40; }, }, diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 8877cfa39fb..1963d1aa7fe 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -141,6 +141,7 @@ export default { variant="link" :icon="descriptionVersionToggleIcon" data-testid="compare-btn" + class="gl-vertical-align-text-bottom" @click="toggleDescriptionVersion" >{{ __('Compare with previous version') }}</gl-button > @@ -149,6 +150,7 @@ export default { :icon="showLines ? 'chevron-up' : 'chevron-down'" variant="link" data-testid="outdated-lines-change-btn" + class="gl-vertical-align-text-bottom" @click="toggleDiff" > {{ __('Compare changes') }} diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js new file mode 100644 index 00000000000..e31446f4bb8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js @@ -0,0 +1,40 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import PaginationBar from './pagination_bar.vue'; + +export default { + component: PaginationBar, + title: 'vue_shared/components/pagination_bar/pagination_bar', +}; + +const Template = (args, { argTypes }) => ({ + components: { PaginationBar }, + props: Object.keys(argTypes), + template: `<pagination-bar v-bind="$props" v-on="{ 'set-page-size': setPageSize, 'set-page': setPage }" />`, +}); + +export const Default = Template.bind({}); + +Default.args = { + pageInfo: { + perPage: 20, + page: 2, + total: 83, + totalPages: 5, + }, + pageSizes: [20, 50, 100], +}; + +Default.argTypes = { + pageInfo: { + description: 'Page info object', + control: { type: 'object' }, + }, + pageSizes: { + description: 'Array of possible page sizes', + control: { type: 'array' }, + }, + + // events + setPageSize: { action: 'set-page-size' }, + setPage: { action: 'set-page' }, +}; diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue new file mode 100644 index 00000000000..b4d565991f5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue @@ -0,0 +1,103 @@ +<script> +import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; + +const DEFAULT_PAGE_SIZES = [20, 50, 100]; + +export default { + components: { + PaginationLinks, + GlDropdown, + GlDropdownItem, + GlIcon, + GlSprintf, + }, + props: { + pageInfo: { + required: true, + type: Object, + }, + pageSizes: { + required: false, + type: Array, + default: () => DEFAULT_PAGE_SIZES, + }, + }, + + computed: { + humanizedTotal() { + return this.pageInfo.total >= 1000 ? __('1000+') : this.pageInfo.total; + }, + + paginationInfo() { + const { page, perPage, totalPages, total } = this.pageInfo; + const itemsCount = page === totalPages ? total - (page - 1) * perPage : perPage; + const start = (page - 1) * perPage + 1; + const end = start + itemsCount - 1; + + return { start, end }; + }, + }, + + methods: { + setPage(page) { + // eslint-disable-next-line spaced-comment + /** + * Emitted when selected page is updated + * + * @event set-page + **/ + this.$emit('set-page', page); + }, + + setPageSize(pageSize) { + // eslint-disable-next-line spaced-comment + /** + * Emitted when page size is updated + * + * @event set-page-size + **/ + this.$emit('set-page-size', pageSize); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" /> + <gl-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size"> + <template #button-content> + <span class="gl-font-weight-bold"> + <gl-sprintf :message="__('%{count} items per page')"> + <template #count> + {{ pageInfo.perPage }} + </template> + </gl-sprintf> + </span> + <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> + </template> + <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="setPageSize(size)"> + <gl-sprintf :message="__('%{count} items per page')"> + <template #count> + {{ size }} + </template> + </gl-sprintf> + </gl-dropdown-item> + </gl-dropdown> + <div class="gl-ml-2" data-testid="information"> + <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')"> + <template #start> + {{ paginationInfo.start }} + </template> + <template #end> + {{ paginationInfo.end }} + </template> + <template #total> + {{ humanizedTotal }} + </template> + </gl-sprintf> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index 933a215112b..6bb321713d5 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -54,10 +54,10 @@ export default { class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1" :class="optionalClasses" > - <div class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5"> + <div class="gl-display-flex gl-align-items-center gl-py-3"> <div v-if="$slots['left-action']" - class="gl-w-7 gl-display-none gl-sm-display-flex gl-justify-content-start gl-pl-2" + class="gl-w-7 gl-display-flex gl-justify-content-start gl-pl-2" > <slot name="left-action"></slot> </div> @@ -105,7 +105,7 @@ export default { </div> <div v-if="$slots['right-action']" - class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1" + class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1" > <slot name="right-action"></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue b/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue index 93396219a54..4c2816b63b2 100644 --- a/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { name: 'MetadataItem', diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue deleted file mode 100644 index a1dca65a423..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue +++ /dev/null @@ -1,101 +0,0 @@ -<script> -import { dateInWords, timeFor } from '~/lib/utils/datetime_utility'; -import { __ } from '~/locale'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; - -export default { - name: 'SidebarCollapsedGroupedDatePicker', - components: { - collapsedCalendarIcon, - }, - mixins: [timeagoMixin], - props: { - collapsed: { - type: Boolean, - required: false, - default: true, - }, - minDate: { - type: Date, - required: false, - default: null, - }, - maxDate: { - type: Date, - required: false, - default: null, - }, - disableClickableIcons: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - hasMinAndMaxDates() { - return this.minDate && this.maxDate; - }, - hasNoMinAndMaxDates() { - return !this.minDate && !this.maxDate; - }, - showMinDateBlock() { - return this.minDate || this.hasNoMinAndMaxDates; - }, - showFromText() { - return !this.maxDate && this.minDate; - }, - iconClass() { - const disabledClass = this.disableClickableIcons ? 'disabled' : ''; - return `sidebar-collapsed-icon calendar-icon ${disabledClass}`; - }, - }, - methods: { - toggleSidebar() { - this.$emit('toggleCollapse'); - }, - dateText(dateType = 'min') { - const date = this[`${dateType}Date`]; - const dateWords = dateInWords(date, true); - const parsedDateWords = dateWords ? dateWords.replace(',', '') : dateWords; - - return date ? parsedDateWords : __('None'); - }, - tooltipText(dateType = 'min') { - const defaultText = dateType === 'min' ? __('Start date') : __('Due date'); - const date = this[`${dateType}Date`]; - const timeAgo = dateType === 'min' ? this.timeFormatted(date) : timeFor(date); - const dateText = date ? [this.dateText(dateType), `(${timeAgo})`].join(' ') : ''; - - if (date) { - return [defaultText, dateText].join('<br />'); - } - return __('Start and due date'); - }, - }, -}; -</script> - -<template> - <div class="block sidebar-grouped-item gl-cursor-pointer" role="button" @click="toggleSidebar"> - <collapsed-calendar-icon - v-if="showMinDateBlock" - :container-class="iconClass" - :tooltip-text="tooltipText('min')" - > - <span class="sidebar-collapsed-value"> - <span v-if="showFromText">{{ __('From') }}</span> <span>{{ dateText('min') }}</span> - </span> - </collapsed-calendar-icon> - <div v-if="hasMinAndMaxDates" class="text-center sidebar-collapsed-divider">-</div> - <collapsed-calendar-icon - v-if="maxDate" - :container-class="iconClass" - :tooltip-text="tooltipText('max')" - > - <span class="sidebar-collapsed-value"> - <span v-if="!minDate">{{ __('Until') }}</span> <span>{{ dateText('max') }}</span> - </span> - </collapsed-calendar-icon> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 4234bc72f3a..7e259cb8b96 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -179,6 +179,8 @@ export default { document.addEventListener('mousedown', this.handleDocumentMousedown); document.addEventListener('click', this.handleDocumentClick); + + this.updateLabelsSetState(); }, beforeDestroy() { document.removeEventListener('mousedown', this.handleDocumentMousedown); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index f7485de0342..13a6dd43207 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -172,6 +172,13 @@ export default { showDropdown() { this.$refs.dropdown.show(); }, + clearSearch() { + if (!this.allowMultiselect || this.isStandalone) { + return; + } + this.searchKey = ''; + this.setFocus(); + }, }, }; </script> @@ -188,12 +195,12 @@ export default { > <template #header> <dropdown-header - v-if="!isStandalone" ref="header" - v-model="searchKey" + :search-key="searchKey" :labels-create-title="labelsCreateTitle" :labels-list-title="labelsListTitle" :show-dropdown-contents-create-view="showDropdownContentsCreateView" + :is-standalone="isStandalone" @toggleDropdownContentsCreateView="toggleDropdownContent" @closeDropdown="$emit('closeDropdown')" @input="debouncedSearchKeyUpdate" @@ -210,6 +217,7 @@ export default { :attr-workspace-path="attrWorkspacePath" :label-create-type="labelCreateType" @hideCreateView="toggleDropdownContent" + @input="clearSearch" /> </template> <template #footer> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue index 10064b01648..7a0f20b0c83 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue @@ -6,9 +6,6 @@ export default { GlButton, GlSearchBoxByType, }, - model: { - prop: 'searchKey', - }, props: { labelsCreateTitle: { type: String, @@ -31,6 +28,11 @@ export default { type: String, required: true, }, + isStandalone: { + type: Boolean, + required: false, + default: false, + }, }, computed: { dropdownTitle() { @@ -47,7 +49,11 @@ export default { <template> <div data-testid="dropdown-header"> - <div class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"> + <div + v-if="!isStandalone" + class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + data-testid="dropdown-header-title" + > <gl-button v-if="showDropdownContentsCreateView" :aria-label="__('Go back')" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue index aed5bc303ee..57ee816c4c7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue @@ -1,10 +1,15 @@ <script> -import { GlLabel } from '@gitlab/ui'; +import { GlIcon, GlLabel, GlTooltipDirective } from '@gitlab/ui'; import { sortBy } from 'lodash'; import { isScopedLabel } from '~/lib/utils/common_utils'; +import { s__, sprintf } from '~/locale'; export default { + directives: { + GlTooltip: GlTooltipDirective, + }, components: { + GlIcon, GlLabel, }, inject: ['allowScopedLabels'], @@ -35,6 +40,23 @@ export default { sortedSelectedLabels() { return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1)); }, + labelsList() { + const labelsString = this.selectedLabels.length + ? this.selectedLabels + .slice(0, 5) + .map((label) => label.title) + .join(', ') + : s__('LabelSelect|Labels'); + + if (this.selectedLabels.length > 5) { + return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), { + labelsString, + remainingLabelCount: this.selectedLabels.length - 5, + }); + } + + return labelsString; + }, }, methods: { labelFilterUrl(label) { @@ -48,6 +70,9 @@ export default { removeLabel(labelId) { this.$emit('onLabelRemove', labelId); }, + handleCollapsedClick() { + this.$emit('onCollapsedValueClick'); + }, }, }; </script> @@ -57,16 +82,30 @@ export default { :class="{ 'has-labels': selectedLabels.length, }" - class="hide-collapsed value issuable-show-labels js-value" + class="value issuable-show-labels js-value" data-testid="value-wrapper" > - <span v-if="!selectedLabels.length" class="text-secondary" data-testid="empty-placeholder"> + <div + v-gl-tooltip.left.viewport + :title="labelsList" + class="sidebar-collapsed-icon" + @click="handleCollapsedClick" + > + <gl-icon name="labels" /> + <span class="gl-font-base gl-line-height-24">{{ selectedLabels.length }}</span> + </div> + <span + v-if="!selectedLabels.length" + class="text-secondary hide-collapsed" + data-testid="empty-placeholder" + > <slot></slot> </span> <template v-else> <gl-label v-for="label in sortedSelectedLabels" :key="label.id" + class="hide-collapsed" data-qa-selector="selected_label_content" :data-qa-label-name="label.title" :title="label.title" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue deleted file mode 100644 index 122250d1ce7..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue +++ /dev/null @@ -1,55 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; - -export default { - directives: { - GlTooltip: GlTooltipDirective, - }, - components: { - GlIcon, - }, - props: { - labels: { - type: Array, - required: true, - }, - }, - computed: { - labelsList() { - const labelsString = this.labels.length - ? this.labels - .slice(0, 5) - .map((label) => label.title) - .join(', ') - : s__('LabelSelect|Labels'); - - if (this.labels.length > 5) { - return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), { - labelsString, - remainingLabelCount: this.labels.length - 5, - }); - } - - return labelsString; - }, - }, - methods: { - handleClick() { - this.$emit('onValueClick'); - }, - }, -}; -</script> - -<template> - <div - v-gl-tooltip.left.viewport - :title="labelsList" - class="sidebar-collapsed-icon" - @click="handleClick" - > - <gl-icon name="labels" /> - <span>{{ labels.length }}</span> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql index c130cc426dc..c442c17eb88 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql @@ -2,6 +2,7 @@ query epicLabels($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { + id issuable: epic(iid: $iid) { id labels { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql index 45fcb50732e..cb054e2968f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql @@ -1,8 +1,8 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" mutation updateEpicLabels($input: UpdateEpicInput!) { - updateEpic(input: $input) { - epic { + updateIssuableLabels: updateEpic(input: $input) { + issuable: epic { id labels { nodes { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql index e471d279b24..2904857270e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql @@ -2,6 +2,7 @@ query issueLabels($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { + id issuable: issue(iid: $iid) { id labels { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql index dd80e89c8a7..e0cdfd91658 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql @@ -2,6 +2,7 @@ query mergeRequestLabels($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { + id issuable: mergeRequest(iid: $iid) { id labels { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 97a65c13933..3adda69b892 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -2,14 +2,13 @@ import { debounce } from 'lodash'; import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { issuableLabelsQueries } from '~/sidebar/constants'; import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants'; import DropdownContents from './dropdown_contents.vue'; import DropdownValue from './dropdown_value.vue'; -import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import { isDropdownVariantSidebar, isDropdownVariantStandalone, @@ -20,7 +19,6 @@ export default { components: { DropdownValue, DropdownContents, - DropdownValueCollapsed, SidebarEditableItem, }, inject: { @@ -225,15 +223,13 @@ export default { variables: { input: inputVariables }, }) .then(({ data }) => { - const { mutationName } = issuableLabelsQueries[this.issuableType]; - - if (data[mutationName]?.errors?.length) { + if (data.updateIssuableLabels?.errors?.length) { throw new Error(); } this.$emit('updateSelectedLabels', { - id: data[mutationName]?.[this.issuableType]?.id, - labels: data[mutationName]?.[this.issuableType]?.labels?.nodes, + id: data.updateIssuableLabels?.issuable?.id, + labels: data.updateIssuableLabels?.issuable?.labels?.nodes, }); }) .catch((error) => @@ -288,18 +284,14 @@ export default { <template> <div - class="labels-select-wrapper position-relative" + class="labels-select-wrapper gl-relative" :class="{ 'is-standalone': isDropdownVariantStandalone(variant), 'is-embedded': isDropdownVariantEmbedded(variant), }" + data-qa-selector="labels_block" > <template v-if="isDropdownVariantSidebar(variant)"> - <dropdown-value-collapsed - ref="dropdownButtonCollapsed" - :labels="issuableLabels" - @onValueClick="handleCollapsedValueClick" - /> <sidebar-editable-item ref="editable" :title="__('Labels')" @@ -315,6 +307,7 @@ export default { :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @onLabelRemove="handleLabelRemove" + @onCollapsedValueClick="handleCollapsedValueClick" > <slot></slot> </dropdown-value> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql index d99fc125012..bb6c7181e5c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql @@ -7,6 +7,7 @@ query alertAssignees( $iid: String! ) { workspace: project(fullPath: $fullPath) { + id issuable: alertManagementAlert(domain: $domain, iid: $iid) { iid assignees { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql index 93b9833bb7d..be270e440ed 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql @@ -4,6 +4,7 @@ query issueAssignees($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql index 48787305459..96a40e597ee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -4,6 +4,7 @@ query issueParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql index 53f7381760e..81e19e48d75 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql @@ -3,6 +3,7 @@ query getMrAssignees($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { + id issuable: mergeRequest(iid: $iid) { id assignees { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql index 6adbd4098f2..3496d5f4a2e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql @@ -3,6 +3,7 @@ query getMrParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { + id issuable: mergeRequest(iid: $iid) { id participants { diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue index fdf0c9baee3..8a0fef36079 100644 --- a/app/assets/javascripts/vue_shared/components/source_editor.vue +++ b/app/assets/javascripts/vue_shared/components/source_editor.vue @@ -96,6 +96,7 @@ export default { :id="`source-editor-${fileGlobalId}`" ref="editor" data-editor-loading + data-qa-selector="source_editor_container" @[$options.readyEvent]="$emit($options.readyEvent)" > <pre class="editor-loading-content">{{ value }}</pre> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer.vue new file mode 100644 index 00000000000..8f0d051543f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer.vue @@ -0,0 +1,88 @@ +<script> +import { GlSafeHtmlDirective } from '@gitlab/ui'; +import LineNumbers from '~/vue_shared/components/line_numbers.vue'; + +export default { + components: { + LineNumbers, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + content: { + type: String, + required: true, + }, + language: { + type: String, + required: false, + default: 'plaintext', + }, + autoDetect: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + languageDefinition: null, + hljs: null, + }; + }, + computed: { + lineNumbers() { + return this.content.split('\n').length; + }, + highlightedContent() { + let highlightedContent; + + if (this.hljs) { + if (this.autoDetect) { + highlightedContent = this.hljs.highlightAuto(this.content).value; + } else if (this.languageDefinition) { + highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value; + } + } + + return highlightedContent; + }, + }, + async mounted() { + this.hljs = await this.loadHighlightJS(); + + if (!this.autoDetect) { + this.languageDefinition = await this.loadLanguage(); + } + }, + methods: { + loadHighlightJS() { + // With auto-detect enabled we load all common languages else we load only the core (smallest footprint) + return this.autoDetect ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); + }, + async loadLanguage() { + let languageDefinition; + + try { + languageDefinition = await import(`highlight.js/lib/languages/${this.language}`); + this.hljs.registerLanguage(this.language, languageDefinition.default); + } catch (message) { + this.$emit('error', message); + } + + return languageDefinition; + }, + }, + userColorScheme: window.gon.user_color_scheme, +}; +</script> +<template> + <div class="file-content code" :class="$options.userColorScheme"> + <line-numbers :lines="lineNumbers" /> + <pre + class="code gl-pl-3!" + ><code v-safe-html="highlightedContent" class="gl-white-space-pre-wrap!"></code> + </pre> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js deleted file mode 100644 index 00aa5519ec6..00000000000 --- a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js +++ /dev/null @@ -1,38 +0,0 @@ -/* eslint-disable @gitlab/require-i18n-strings */ -import '@gitlab/ui/dist/utility_classes.css'; -import UsageGraph from './usage_graph.vue'; - -export default { - component: UsageGraph, - title: 'vue_shared/components/storage_counter/usage_graph', -}; - -const Template = (args, { argTypes }) => ({ - components: { UsageGraph }, - props: Object.keys(argTypes), - template: '<usage-graph v-bind="$props" />', -}); - -export const Default = Template.bind({}); -Default.argTypes = { - rootStorageStatistics: { - description: 'The statistics object with all its fields', - type: { name: 'object', required: true }, - defaultValue: { - buildArtifactsSize: 400000, - pipelineArtifactsSize: 38000, - lfsObjectsSize: 4800000, - packagesSize: 3800000, - repositorySize: 39000000, - snippetsSize: 2000112, - storageSize: 39930000, - uploadsSize: 7000, - wikiSize: 300000, - }, - }, - limit: { - description: - 'When a limit is set, users will see how much of their storage usage (limit) is used. In case the limit is 0 or the current usage exceeds the limit, it just renders the distribution', - defaultValue: 0, - }, -}; diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue deleted file mode 100644 index c33d065ff4b..00000000000 --- a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue +++ /dev/null @@ -1,148 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { s__ } from '~/locale'; - -export default { - components: { - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - rootStorageStatistics: { - required: true, - type: Object, - }, - limit: { - required: true, - type: Number, - }, - }, - computed: { - storageTypes() { - const { - buildArtifactsSize, - pipelineArtifactsSize, - lfsObjectsSize, - packagesSize, - repositorySize, - storageSize, - wikiSize, - snippetsSize, - uploadsSize, - } = this.rootStorageStatistics; - const artifactsSize = buildArtifactsSize + pipelineArtifactsSize; - - if (storageSize === 0) { - return null; - } - - return [ - { - name: s__('UsageQuota|Repositories'), - style: this.usageStyle(this.barRatio(repositorySize)), - class: 'gl-bg-data-viz-blue-500', - size: repositorySize, - }, - { - name: s__('UsageQuota|LFS Objects'), - style: this.usageStyle(this.barRatio(lfsObjectsSize)), - class: 'gl-bg-data-viz-orange-600', - size: lfsObjectsSize, - }, - { - name: s__('UsageQuota|Packages'), - style: this.usageStyle(this.barRatio(packagesSize)), - class: 'gl-bg-data-viz-aqua-500', - size: packagesSize, - }, - { - name: s__('UsageQuota|Artifacts'), - style: this.usageStyle(this.barRatio(artifactsSize)), - class: 'gl-bg-data-viz-green-600', - size: artifactsSize, - tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'), - }, - { - name: s__('UsageQuota|Wikis'), - style: this.usageStyle(this.barRatio(wikiSize)), - class: 'gl-bg-data-viz-magenta-500', - size: wikiSize, - }, - { - name: s__('UsageQuota|Snippets'), - style: this.usageStyle(this.barRatio(snippetsSize)), - class: 'gl-bg-data-viz-orange-800', - size: snippetsSize, - }, - { - name: s__('UsageQuota|Uploads'), - style: this.usageStyle(this.barRatio(uploadsSize)), - class: 'gl-bg-data-viz-aqua-700', - size: uploadsSize, - }, - ] - .filter((data) => data.size !== 0) - .sort((a, b) => b.size - a.size); - }, - }, - methods: { - formatSize(size) { - return numberToHumanSize(size); - }, - usageStyle(ratio) { - return { flex: ratio }; - }, - barRatio(size) { - let max = this.rootStorageStatistics.storageSize; - - if (this.limit !== 0 && max <= this.limit) { - max = this.limit; - } - - return size / max; - }, - }, -}; -</script> -<template> - <div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100"> - <div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex"> - <div - v-for="storageType in storageTypes" - :key="storageType.name" - class="storage-type-usage gl-h-full gl-display-inline-block" - :class="storageType.class" - :style="storageType.style" - data-testid="storage-type-usage" - ></div> - </div> - <div class="row py-0"> - <div - v-for="storageType in storageTypes" - :key="storageType.name" - class="col-md-auto gl-display-flex gl-align-items-center" - data-testid="storage-type-legend" - > - <div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div> - <span class="gl-mr-2 gl-font-weight-bold gl-font-sm"> - {{ storageType.name }} - </span> - <span class="gl-text-gray-500 gl-font-sm"> - {{ formatSize(storageType.size) }} - </span> - <span - v-if="storageType.tooltip" - v-gl-tooltip - :title="storageType.tooltip" - :aria-label="storageType.tooltip" - class="gl-ml-2" - > - <gl-icon name="question" :size="12" /> - </span> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue deleted file mode 100644 index c5fdb5fc242..00000000000 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ /dev/null @@ -1,69 +0,0 @@ -<script> -import { GlTooltipDirective as GlTooltip } from '@gitlab/ui'; -import { isFunction } from 'lodash'; -import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; - -export default { - directives: { - GlTooltip, - }, - props: { - title: { - type: String, - required: false, - default: '', - }, - placement: { - type: String, - required: false, - default: 'top', - }, - truncateTarget: { - type: [String, Function], - required: false, - default: '', - }, - }, - data() { - return { - showTooltip: false, - }; - }, - watch: { - title() { - // Wait on $nextTick in case of slot width changes - this.$nextTick(this.updateTooltip); - }, - }, - mounted() { - this.updateTooltip(); - }, - methods: { - selectTarget() { - if (isFunction(this.truncateTarget)) { - return this.truncateTarget(this.$el); - } else if (this.truncateTarget === 'child') { - return this.$el.childNodes[0]; - } - - return this.$el; - }, - updateTooltip() { - const target = this.selectTarget(); - this.showTooltip = hasHorizontalOverflow(target); - }, - }, -}; -</script> - -<template> - <span - v-if="showTooltip" - v-gl-tooltip="{ placement }" - :title="title" - class="js-show-tooltip gl-min-w-0" - > - <slot></slot> - </span> - <span v-else class="gl-min-w-0"> <slot></slot> </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js new file mode 100644 index 00000000000..f27901a30a9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js @@ -0,0 +1,88 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import TooltipOnTruncate from './tooltip_on_truncate.vue'; + +const defaultWidth = '250px'; + +export default { + component: TooltipOnTruncate, + title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue', +}; + +const createStory = ({ ...options }) => { + return (_, { argTypes }) => { + const comp = { + components: { TooltipOnTruncate }, + props: Object.keys(argTypes), + template: ` + <div class="gl-bg-blue-50" :style="{ width }"> + <tooltip-on-truncate :title="title" :placement="placement" class="gl-display-block gl-text-truncate"> + {{title}} + </tooltip-on-truncate> + </div> + `, + ...options, + }; + + return comp; + }; +}; + +export const Default = createStory(); +Default.args = { + width: defaultWidth, + title: 'Hover on this text to see the content in a tooltip.', +}; + +export const NoOverflow = createStory(); +NoOverflow.args = { + width: defaultWidth, + title: "Short text doesn't need a tooltip.", +}; + +export const Placement = createStory(); +Placement.args = { + width: defaultWidth, + title: 'Use `placement="right"` to display this tooltip at the right.', + placement: 'right', +}; + +const TIMEOUT_S = 3; + +export const LiveUpdates = createStory({ + props: ['width', 'placement'], + data() { + return { + title: `(loading in ${TIMEOUT_S}s)`, + }; + }, + mounted() { + setTimeout(() => { + this.title = 'Content updated! The content is now overflowing so we use a tooltip!'; + }, TIMEOUT_S * 1000); + }, +}); +LiveUpdates.args = { + width: defaultWidth, +}; +LiveUpdates.argTypes = { + title: { + control: false, + }, +}; + +export const TruncateTarget = createStory({ + template: ` + <div class="gl-bg-black" :style="{ width }"> + <tooltip-on-truncate class="gl-display-flex" :truncate-target="truncateTarget" :title="title"> + <div class="gl-m-5 gl-bg-blue-50 gl-text-truncate"> + {{ title }} + </div> + </tooltip-on-truncate> + </div> + `, +}); +TruncateTarget.args = { + width: defaultWidth, + truncateTarget: 'child', + title: 'Wrap in container and use `truncate-target="child"` prop.', +}; diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue new file mode 100644 index 00000000000..09414e679bb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue @@ -0,0 +1,85 @@ +<script> +import { GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui'; +import { isFunction, debounce } from 'lodash'; +import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; + +const UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS = 300; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + GlResizeObserver: GlResizeObserverDirective, + }, + props: { + title: { + type: String, + required: false, + default: '', + }, + placement: { + type: String, + required: false, + default: 'top', + }, + truncateTarget: { + type: [String, Function], + required: false, + default: '', + }, + }, + data() { + return { + tooltipDisabled: true, + }; + }, + computed: { + classes() { + if (this.tooltipDisabled) { + return ''; + } + return 'js-show-tooltip'; + }, + tooltip() { + return { + title: this.title, + placement: this.placement, + disabled: this.tooltipDisabled, + }; + }, + }, + watch: { + title() { + // Wait on $nextTick in case the slot width changes + this.$nextTick(this.updateTooltip); + }, + }, + created() { + this.updateTooltipDebounced = debounce(this.updateTooltip, UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS); + }, + mounted() { + this.updateTooltip(); + }, + methods: { + selectTarget() { + if (isFunction(this.truncateTarget)) { + return this.truncateTarget(this.$el); + } else if (this.truncateTarget === 'child') { + return this.$el.childNodes[0]; + } + return this.$el; + }, + updateTooltip() { + this.tooltipDisabled = !hasHorizontalOverflow(this.selectTarget()); + }, + onResize() { + this.updateTooltipDebounced(); + }, + }, +}; +</script> + +<template> + <span v-gl-tooltip="tooltip" v-gl-resize-observer="onResize" :class="classes" class="gl-min-w-0"> + <slot></slot> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue new file mode 100644 index 00000000000..f4cbaba9313 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue @@ -0,0 +1,44 @@ +<script> +import IssuableForm from './issuable_form.vue'; + +export default { + components: { + IssuableForm, + }, + props: { + descriptionPreviewPath: { + type: String, + required: true, + }, + descriptionHelpPath: { + type: String, + required: true, + }, + labelsFetchPath: { + type: String, + required: true, + }, + labelsManagePath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="issuable-create-container"> + <slot name="title"></slot> + <hr class="gl-mt-0" /> + <issuable-form + :description-preview-path="descriptionPreviewPath" + :description-help-path="descriptionHelpPath" + :labels-fetch-path="labelsFetchPath" + :labels-manage-path="labelsManagePath" + > + <template #actions="issuableMeta"> + <slot name="actions" v-bind="issuableMeta"></slot> + </template> + </issuable-form> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue new file mode 100644 index 00000000000..c216a05bdb0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue @@ -0,0 +1,128 @@ +<script> +import { GlForm, GlFormInput } from '@gitlab/ui'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; +import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; + +export default { + LabelSelectVariant: DropdownVariant, + components: { + GlForm, + GlFormInput, + MarkdownField, + LabelsSelect, + }, + props: { + descriptionPreviewPath: { + type: String, + required: true, + }, + descriptionHelpPath: { + type: String, + required: true, + }, + labelsFetchPath: { + type: String, + required: true, + }, + labelsManagePath: { + type: String, + required: true, + }, + }, + data() { + return { + issuableTitle: '', + issuableDescription: '', + selectedLabels: [], + }; + }, + methods: { + handleUpdateSelectedLabels(labels) { + if (labels.length) { + this.selectedLabels = labels; + } + }, + }, +}; +</script> + +<template> + <gl-form class="common-note-form gfm-form" @submit.stop.prevent> + <div data-testid="issuable-title" class="form-group row"> + <label for="issuable-title" class="col-form-label col-sm-2">{{ __('Title') }}</label> + <div class="col-sm-10"> + <gl-form-input + id="issuable-title" + v-model="issuableTitle" + :autofocus="true" + :placeholder="__('Title')" + /> + </div> + </div> + <div data-testid="issuable-description" class="form-group row"> + <label for="issuable-description" class="col-form-label col-sm-2">{{ + __('Description') + }}</label> + <div class="col-sm-10"> + <markdown-field + :markdown-preview-path="descriptionPreviewPath" + :markdown-docs-path="descriptionHelpPath" + :add-spacing-classes="false" + :show-suggest-popover="true" + :textarea-value="issuableDescription" + > + <template #textarea> + <textarea + id="issuable-description" + ref="textarea" + v-model="issuableDescription" + dir="auto" + class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files hereā¦')" + ></textarea> + </template> + </markdown-field> + </div> + </div> + <div class="row"> + <div class="col-lg-6"> + <div data-testid="issuable-labels" class="form-group row"> + <label for="issuable-labels" class="col-form-label col-md-2 col-lg-4">{{ + __('Labels') + }}</label> + <div class="col-md-8 col-sm-10"> + <div class="issuable-form-select-holder"> + <labels-select + :allow-label-edit="true" + :allow-label-create="true" + :allow-multiselect="true" + :allow-scoped-labels="true" + :labels-fetch-path="labelsFetchPath" + :labels-manage-path="labelsManagePath" + :selected-labels="selectedLabels" + :labels-list-title="__('Select label')" + :footer-create-label-title="__('Create project label')" + :footer-manage-label-title="__('Manage project labels')" + :variant="$options.LabelSelectVariant.Embedded" + @updateSelectedLabels="handleUpdateSelectedLabels" + /> + </div> + </div> + </div> + </div> + </div> + <div + data-testid="issuable-create-actions" + class="footer-block row-content-block gl-display-flex" + > + <slot + name="actions" + :issuable-title="issuableTitle" + :issuable-description="issuableDescription" + :selected-labels="selectedLabels" + ></slot> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue new file mode 100644 index 00000000000..5ca9e50d854 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue @@ -0,0 +1,35 @@ +<script> +export default { + props: { + expanded: { + type: Boolean, + required: true, + }, + }, + watch: { + expanded(value) { + const layoutPageEl = document.querySelector('.layout-page'); + + if (layoutPageEl) { + layoutPageEl.classList.toggle('right-sidebar-expanded', value); + layoutPageEl.classList.toggle('right-sidebar-collapsed', !value); + } + }, + }, +}; +</script> + +<template> + <aside + :class="{ 'right-sidebar-expanded': expanded, 'right-sidebar-collapsed': !expanded }" + class="issues-bulk-update right-sidebar" + aria-live="polite" + > + <div + class="gl-display-flex gl-justify-content-space-between gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-gray-100" + > + <slot name="bulk-edit-actions"></slot> + </div> + <slot name="sidebar-items"></slot> + </aside> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue new file mode 100644 index 00000000000..0bb0e0d9fb0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -0,0 +1,303 @@ +<script> +import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; + +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility'; +import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; +import { __, n__, sprintf } from '~/locale'; +import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + components: { + GlLink, + GlIcon, + GlLabel, + GlFormCheckbox, + GlSprintf, + IssuableAssignees, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + issuableSymbol: { + type: String, + required: true, + }, + issuable: { + type: Object, + required: true, + }, + enableLabelPermalinks: { + type: Boolean, + required: true, + }, + labelFilterParam: { + type: String, + required: false, + default: 'label_name', + }, + showCheckbox: { + type: Boolean, + required: true, + }, + checked: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + issuableId() { + return getIdFromGraphQLId(this.issuable.id); + }, + createdInPastDay() { + const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date()); + return createdSecondsAgo < SECONDS_IN_DAY; + }, + author() { + return this.issuable.author; + }, + webUrl() { + return this.issuable.gitlabWebUrl || this.issuable.webUrl; + }, + authorId() { + return getIdFromGraphQLId(this.author.id); + }, + isIssuableUrlExternal() { + return isExternal(this.webUrl); + }, + reference() { + return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`; + }, + labels() { + return this.issuable.labels?.nodes || this.issuable.labels || []; + }, + labelIdsString() { + return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id))); + }, + assignees() { + return this.issuable.assignees?.nodes || this.issuable.assignees || []; + }, + createdAt() { + return getTimeago().format(this.issuable.createdAt); + }, + updatedAt() { + return sprintf(__('updated %{timeAgo}'), { + timeAgo: getTimeago().format(this.issuable.updatedAt), + }); + }, + issuableTitleProps() { + if (this.isIssuableUrlExternal) { + return { + target: '_blank', + }; + } + return {}; + }, + taskStatus() { + const { completedCount, count } = this.issuable.taskCompletionStatus || {}; + if (!count) { + return undefined; + } + + return sprintf( + n__( + '%{completedCount} of %{count} task completed', + '%{completedCount} of %{count} tasks completed', + count, + ), + { completedCount, count }, + ); + }, + notesCount() { + return this.issuable.userDiscussionsCount ?? this.issuable.userNotesCount; + }, + showDiscussions() { + return typeof this.notesCount === 'number'; + }, + showIssuableMeta() { + return Boolean( + this.hasSlotContents('status') || this.showDiscussions || this.issuable.assignees, + ); + }, + issuableNotesLink() { + return setUrlFragment(this.webUrl, 'notes'); + }, + }, + methods: { + hasSlotContents(slotName) { + return Boolean(this.$slots[slotName]); + }, + scopedLabel(label) { + return isScopedLabel(label); + }, + labelTitle(label) { + return label.title || label.name; + }, + labelTarget(label) { + if (this.enableLabelPermalinks) { + const value = encodeURIComponent(this.labelTitle(label)); + return `?${this.labelFilterParam}[]=${value}`; + } + return '#'; + }, + /** + * This is needed as an independent method since + * when user changes current page, `$refs.authorLink` + * will be null until next page results are loaded & rendered. + */ + getAuthorPopoverTarget() { + if (this.$refs.authorLink) { + return this.$refs.authorLink.$el; + } + return ''; + }, + }, +}; +</script> + +<template> + <li + :id="`issuable_${issuableId}`" + class="issue gl-display-flex! gl-px-5!" + :class="{ closed: issuable.closedAt, today: createdInPastDay }" + :data-labels="labelIdsString" + :data-qa-issue-id="issuableId" + > + <gl-form-checkbox + v-if="showCheckbox" + class="issue-check gl-mr-0" + :checked="checked" + :data-id="issuableId" + @input="$emit('checked-input', $event)" + > + <span class="gl-sr-only">{{ issuable.title }}</span> + </gl-form-checkbox> + <div class="issuable-main-info"> + <div data-testid="issuable-title" class="issue-title title"> + <gl-icon + v-if="issuable.confidential" + v-gl-tooltip + name="eye-slash" + :title="__('Confidential')" + :aria-label="__('Confidential')" + /> + <gl-icon + v-if="issuable.hidden" + v-gl-tooltip + name="spam" + :title="__('This issue is hidden because its author has been banned')" + :aria-label="__('Hidden')" + /> + <gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps"> + {{ issuable.title }} + <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> + </gl-link> + <span + v-if="taskStatus" + class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3" + data-testid="task-status" + > + {{ taskStatus }} + </span> + </div> + <div class="issuable-info"> + <slot v-if="hasSlotContents('reference')" name="reference"></slot> + <span v-else data-testid="issuable-reference" class="issuable-reference"> + {{ reference }} + </span> + <span class="gl-display-none gl-sm-display-inline"> + <span aria-hidden="true">·</span> + <span class="issuable-authored gl-mr-3"> + <gl-sprintf :message="__('created %{timeAgo} by %{author}')"> + <template #timeAgo> + <span + v-gl-tooltip.bottom + :title="tooltipTitle(issuable.createdAt)" + data-testid="issuable-created-at" + > + {{ createdAt }} + </span> + </template> + <template #author> + <slot v-if="hasSlotContents('author')" name="author"></slot> + <gl-link + v-else + :data-user-id="authorId" + :data-username="author.username" + :data-name="author.name" + :data-avatar-url="author.avatarUrl" + :href="author.webUrl" + data-testid="issuable-author" + class="author-link js-user-link" + > + <span class="author">{{ author.name }}</span> + </gl-link> + </template> + </gl-sprintf> + </span> + <slot name="timeframe"></slot> + </span> + + <span v-if="labels.length" role="group" :aria-label="__('Labels')"> + <gl-label + v-for="(label, index) in labels" + :key="index" + :background-color="label.color" + :title="labelTitle(label)" + :description="label.description" + :scoped="scopedLabel(label)" + :target="labelTarget(label)" + :class="{ 'gl-ml-2': index }" + size="sm" + /> + </span> + </div> + </div> + <div class="issuable-meta"> + <ul v-if="showIssuableMeta" class="controls"> + <li v-if="hasSlotContents('status')" class="issuable-status"> + <slot name="status"></slot> + </li> + <li v-if="assignees.length"> + <issuable-assignees + :assignees="assignees" + :icon-size="16" + :max-visible="4" + img-css-classes="gl-mr-2!" + class="gl-align-items-center gl-display-flex gl-ml-3" + /> + </li> + <slot name="statistics"></slot> + <li + v-if="showDiscussions" + data-testid="issuable-discussions" + class="issuable-comments gl-display-none gl-sm-display-block" + > + <gl-link + v-gl-tooltip.top + :title="__('Comments')" + :href="issuableNotesLink" + :class="{ 'no-comments': !notesCount }" + class="gl-reset-color!" + > + <gl-icon name="comments" /> + {{ notesCount }} + </gl-link> + </li> + </ul> + <div + v-gl-tooltip.bottom + class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block" + :title="tooltipTitle(issuable.updatedAt)" + data-testid="issuable-updated-at" + > + {{ updatedAt }} + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue new file mode 100644 index 00000000000..2f8401b45f0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -0,0 +1,363 @@ +<script> +import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +import { DEFAULT_SKELETON_COUNT } from '../constants'; +import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue'; +import IssuableItem from './issuable_item.vue'; +import IssuableTabs from './issuable_tabs.vue'; + +const VueDraggable = () => import('vuedraggable'); + +export default { + vueDraggableAttributes: { + animation: 200, + ghostClass: 'gl-visibility-hidden', + tag: 'ul', + }, + components: { + GlAlert, + GlKeysetPagination, + GlSkeletonLoading, + IssuableTabs, + FilteredSearchBar, + IssuableItem, + IssuableBulkEditSidebar, + GlPagination, + VueDraggable, + }, + props: { + namespace: { + type: String, + required: true, + }, + recentSearchesStorageKey: { + type: String, + required: true, + }, + searchInputPlaceholder: { + type: String, + required: true, + }, + searchTokens: { + type: Array, + required: true, + }, + sortOptions: { + type: Array, + required: true, + }, + urlParams: { + type: Object, + required: false, + default: () => ({}), + }, + initialFilterValue: { + type: Array, + required: false, + default: () => [], + }, + initialSortBy: { + type: String, + required: false, + default: 'created_desc', + }, + issuables: { + type: Array, + required: true, + }, + tabs: { + type: Array, + required: true, + }, + tabCounts: { + type: Object, + required: false, + default: null, + }, + currentTab: { + type: String, + required: true, + }, + issuableSymbol: { + type: String, + required: false, + default: '#', + }, + issuablesLoading: { + type: Boolean, + required: false, + default: false, + }, + showPaginationControls: { + type: Boolean, + required: false, + default: false, + }, + showBulkEditSidebar: { + type: Boolean, + required: false, + default: false, + }, + defaultPageSize: { + type: Number, + required: false, + default: 20, + }, + totalItems: { + type: Number, + required: false, + default: 0, + }, + currentPage: { + type: Number, + required: false, + default: 1, + }, + previousPage: { + type: Number, + required: false, + default: 0, + }, + nextPage: { + type: Number, + required: false, + default: 2, + }, + enableLabelPermalinks: { + type: Boolean, + required: false, + default: true, + }, + labelFilterParam: { + type: String, + required: false, + default: undefined, + }, + isManualOrdering: { + type: Boolean, + required: false, + default: false, + }, + useKeysetPagination: { + type: Boolean, + required: false, + default: false, + }, + hasNextPage: { + type: Boolean, + required: false, + default: false, + }, + hasPreviousPage: { + type: Boolean, + required: false, + default: false, + }, + error: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + checkedIssuables: {}, + }; + }, + computed: { + skeletonItemCount() { + const { totalItems, defaultPageSize, currentPage } = this; + const totalPages = Math.ceil(totalItems / defaultPageSize); + + if (totalPages) { + return currentPage < totalPages + ? defaultPageSize + : totalItems % defaultPageSize || defaultPageSize; + } + return DEFAULT_SKELETON_COUNT; + }, + allIssuablesChecked() { + return this.bulkEditIssuables.length === this.issuables.length; + }, + /** + * Returns all the checked issuables from `checkedIssuables` map. + */ + bulkEditIssuables() { + return Object.keys(this.checkedIssuables).reduce((acc, issuableId) => { + if (this.checkedIssuables[issuableId].checked) { + acc.push(this.checkedIssuables[issuableId].issuable); + } + return acc; + }, []); + }, + issuablesWrapper() { + return this.isManualOrdering ? VueDraggable : 'ul'; + }, + }, + watch: { + issuables(list) { + this.checkedIssuables = list.reduce((acc, issuable) => { + const id = this.issuableId(issuable); + acc[id] = { + // By default, an issuable is not checked, + // But if `checkedIssuables` is already + // populated, use existing value. + checked: + typeof this.checkedIssuables[id] !== 'boolean' + ? false + : this.checkedIssuables[id].checked, + // We're caching issuable reference here + // for ease of populating in `bulkEditIssuables`. + issuable, + }; + return acc; + }, {}); + }, + urlParams: { + deep: true, + immediate: true, + handler(params) { + if (Object.keys(params).length) { + updateHistory({ + url: setUrlParams(params, window.location.href, true, false, true), + title: document.title, + replace: true, + }); + } + }, + }, + }, + methods: { + issuableId(issuable) { + return getIdFromGraphQLId(issuable.id) || issuable.iid || uniqueId(); + }, + issuableChecked(issuable) { + return this.checkedIssuables[this.issuableId(issuable)]?.checked; + }, + handleIssuableCheckedInput(issuable, value) { + this.checkedIssuables[this.issuableId(issuable)].checked = value; + this.$emit('update-legacy-bulk-edit'); + }, + handleAllIssuablesCheckedInput(value) { + Object.keys(this.checkedIssuables).forEach((issuableId) => { + this.checkedIssuables[issuableId].checked = value; + }); + this.$emit('update-legacy-bulk-edit'); + }, + handleVueDraggableUpdate({ newIndex, oldIndex }) { + this.$emit('reorder', { newIndex, oldIndex }); + }, + }, +}; +</script> + +<template> + <div class="issuable-list-container"> + <issuable-tabs + :tabs="tabs" + :tab-counts="tabCounts" + :current-tab="currentTab" + @click="$emit('click-tab', $event)" + > + <template #nav-actions> + <slot name="nav-actions"></slot> + </template> + </issuable-tabs> + <filtered-search-bar + :namespace="namespace" + :recent-searches-storage-key="recentSearchesStorageKey" + :search-input-placeholder="searchInputPlaceholder" + :tokens="searchTokens" + :sort-options="sortOptions" + :initial-filter-value="initialFilterValue" + :initial-sort-by="initialSortBy" + :show-checkbox="showBulkEditSidebar" + :checkbox-checked="allIssuablesChecked" + class="gl-flex-grow-1 gl-border-t-none row-content-block" + data-qa-selector="issuable_search_container" + @checked-input="handleAllIssuablesCheckedInput" + @onFilter="$emit('filter', $event)" + @onSort="$emit('sort', $event)" + /> + <gl-alert v-if="error" variant="danger" @dismiss="$emit('dismiss-alert')">{{ error }}</gl-alert> + <issuable-bulk-edit-sidebar :expanded="showBulkEditSidebar"> + <template #bulk-edit-actions> + <slot name="bulk-edit-actions" :checked-issuables="bulkEditIssuables"></slot> + </template> + <template #sidebar-items> + <slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot> + </template> + </issuable-bulk-edit-sidebar> + <ul v-if="issuablesLoading" class="content-list"> + <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!"> + <gl-skeleton-loading /> + </li> + </ul> + <template v-else> + <component + :is="issuablesWrapper" + v-if="issuables.length > 0" + class="content-list issuable-list issues-list" + :class="{ 'manual-ordering': isManualOrdering }" + v-bind="$options.vueDraggableAttributes" + @update="handleVueDraggableUpdate" + > + <issuable-item + v-for="issuable in issuables" + :key="issuableId(issuable)" + :class="{ 'gl-cursor-grab': isManualOrdering }" + data-qa-selector="issuable_container" + :data-qa-issuable-title="issuable.title" + :issuable-symbol="issuableSymbol" + :issuable="issuable" + :enable-label-permalinks="enableLabelPermalinks" + :label-filter-param="labelFilterParam" + :show-checkbox="showBulkEditSidebar" + :checked="issuableChecked(issuable)" + @checked-input="handleIssuableCheckedInput(issuable, $event)" + > + <template #reference> + <slot name="reference" :issuable="issuable"></slot> + </template> + <template #author> + <slot name="author" :author="issuable.author"></slot> + </template> + <template #timeframe> + <slot name="timeframe" :issuable="issuable"></slot> + </template> + <template #status> + <slot name="status" :issuable="issuable"></slot> + </template> + <template #statistics> + <slot name="statistics" :issuable="issuable"></slot> + </template> + </issuable-item> + </component> + <slot v-else name="empty-state"></slot> + </template> + + <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3"> + <gl-keyset-pagination + :has-next-page="hasNextPage" + :has-previous-page="hasPreviousPage" + @next="$emit('next-page')" + @prev="$emit('previous-page')" + /> + </div> + <gl-pagination + v-else-if="showPaginationControls" + :per-page="defaultPageSize" + :total-items="totalItems" + :value="currentPage" + :prev-page="previousPage" + :next-page="nextPage" + align="center" + class="gl-pagination gl-mt-3" + @input="$emit('page-change', $event)" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue new file mode 100644 index 00000000000..3ff87ba3c4f --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue @@ -0,0 +1,67 @@ +<script> +import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; + +export default { + components: { + GlTabs, + GlTab, + GlBadge, + }, + props: { + tabs: { + type: Array, + required: true, + }, + tabCounts: { + type: Object, + required: false, + default: null, + }, + currentTab: { + type: String, + required: true, + }, + }, + methods: { + isTabActive(tabName) { + return tabName === this.currentTab; + }, + isTabCountNumeric(tab) { + return Number.isInteger(this.tabCounts[tab.name]); + }, + }, +}; +</script> + +<template> + <div class="top-area"> + <gl-tabs + class="gl-display-flex gl-flex-grow-1 gl-p-0 gl-m-0 mobile-separator issuable-state-filters" + nav-class="gl-border-b-0" + > + <gl-tab + v-for="tab in tabs" + :key="tab.id" + :active="isTabActive(tab.name)" + @click="$emit('click', tab.name)" + > + <template #title> + <span :title="tab.titleTooltip" :data-qa-selector="`${tab.name}_issuables_tab`"> + {{ tab.title }} + </span> + <gl-badge + v-if="tabCounts && isTabCountNumeric(tab)" + variant="muted" + size="sm" + class="gl-tab-counter-badge" + > + {{ tabCounts[tab.name] }} + </gl-badge> + </template> + </gl-tab> + </gl-tabs> + <div class="nav-controls"> + <slot name="nav-actions"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js new file mode 100644 index 00000000000..773ad0f8e93 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js @@ -0,0 +1,51 @@ +import { __ } from '~/locale'; + +export const IssuableStates = { + Opened: 'opened', + Closed: 'closed', + All: 'all', +}; + +export const IssuableListTabs = [ + { + id: 'state-opened', + name: IssuableStates.Opened, + title: __('Open'), + titleTooltip: __('Filter by issues that are currently opened.'), + }, + { + id: 'state-closed', + name: IssuableStates.Closed, + title: __('Closed'), + titleTooltip: __('Filter by issues that are currently closed.'), + }, + { + id: 'state-all', + name: IssuableStates.All, + title: __('All'), + titleTooltip: __('Show all issues.'), + }, +]; + +export const AvailableSortOptions = [ + { + id: 1, + title: __('Created date'), + sortDirection: { + descending: 'created_desc', + ascending: 'created_asc', + }, + }, + { + id: 2, + title: __('Last updated'), + sortDirection: { + descending: 'updated_desc', + ascending: 'updated_asc', + }, + }, +]; + +export const DEFAULT_PAGE_SIZE = 20; + +export const DEFAULT_SKELETON_COUNT = 5; diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue new file mode 100644 index 00000000000..05dc1650379 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue @@ -0,0 +1,194 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +import TaskList from '~/task_list'; + +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import IssuableDescription from './issuable_description.vue'; +import IssuableEditForm from './issuable_edit_form.vue'; +import IssuableTitle from './issuable_title.vue'; + +export default { + components: { + GlLink, + TimeAgoTooltip, + IssuableTitle, + IssuableDescription, + IssuableEditForm, + }, + props: { + issuable: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: true, + }, + statusIcon: { + type: String, + required: true, + }, + enableEdit: { + type: Boolean, + required: true, + }, + enableAutocomplete: { + type: Boolean, + required: true, + }, + enableAutosave: { + type: Boolean, + required: true, + }, + enableZenMode: { + type: Boolean, + required: true, + }, + enableTaskList: { + type: Boolean, + required: false, + default: false, + }, + editFormVisible: { + type: Boolean, + required: true, + }, + showFieldTitle: { + type: Boolean, + required: true, + }, + descriptionPreviewPath: { + type: String, + required: true, + }, + descriptionHelpPath: { + type: String, + required: true, + }, + taskListUpdatePath: { + type: String, + required: false, + default: '', + }, + taskListLockVersion: { + type: Number, + required: false, + default: 0, + }, + }, + computed: { + isUpdated() { + return Boolean(this.issuable.updatedAt); + }, + updatedBy() { + return this.issuable.updatedBy; + }, + }, + watch: { + /** + * When user switches between view and edit modes, + * taskList instance becomes invalid so whenever + * view mode is rendered, we need to re-initialize + * taskList to ensure the behaviour functional. + */ + editFormVisible(value) { + if (!value) { + this.$nextTick(() => { + this.initTaskList(); + }); + } + }, + }, + mounted() { + if (this.enableEdit && this.enableTaskList) { + this.initTaskList(); + } + }, + methods: { + initTaskList() { + this.taskList = new TaskList({ + /** + * We have hard-coded dataType to `issue` + * as currently only `issue` types can handle + * task-lists, however, we can still use + * task lists in Issue, Test Cases and Incidents + * as all of those are derived from `issue`. + */ + dataType: 'issue', + fieldName: 'description', + lockVersion: this.taskListLockVersion, + selector: '.js-detail-page-description', + onSuccess: this.handleTaskListUpdateSuccess.bind(this), + onError: this.handleTaskListUpdateFailure.bind(this), + }); + }, + handleTaskListUpdateSuccess(updatedIssuable) { + this.$emit('task-list-update-success', updatedIssuable); + }, + handleTaskListUpdateFailure() { + this.$emit('task-list-update-failure'); + }, + handleKeydownTitle(e, issuableMeta) { + this.$emit('keydown-title', e, issuableMeta); + }, + handleKeydownDescription(e, issuableMeta) { + this.$emit('keydown-description', e, issuableMeta); + }, + }, +}; +</script> + +<template> + <div class="issue-details issuable-details"> + <div class="detail-page-description js-detail-page-description content-block"> + <issuable-edit-form + v-if="editFormVisible" + :issuable="issuable" + :enable-autocomplete="enableAutocomplete" + :enable-autosave="enableAutosave" + :enable-zen-mode="enableZenMode" + :show-field-title="showFieldTitle" + :description-preview-path="descriptionPreviewPath" + :description-help-path="descriptionHelpPath" + @keydown-title="handleKeydownTitle" + @keydown-description="handleKeydownDescription" + > + <template #edit-form-actions="issuableMeta"> + <slot name="edit-form-actions" v-bind="issuableMeta"></slot> + </template> + </issuable-edit-form> + <template v-else> + <issuable-title + :issuable="issuable" + :status-badge-class="statusBadgeClass" + :status-icon="statusIcon" + :enable-edit="enableEdit" + @edit-issuable="$emit('edit-issuable', $event)" + > + <template #status-badge> + <slot name="status-badge"></slot> + </template> + </issuable-title> + <issuable-description + v-if="issuable.descriptionHtml" + :issuable="issuable" + :enable-task-list="enableTaskList" + :can-edit="enableEdit" + :task-list-update-path="taskListUpdatePath" + /> + <small v-if="isUpdated" class="edited-text gl-font-sm!"> + {{ __('Edited') }} + <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" /> + <span v-if="updatedBy"> + {{ __('by') }} + <gl-link :href="updatedBy.webUrl" class="author-link gl-font-sm!"> + <span>{{ updatedBy.name }}</span> + </gl-link> + </span> + </small> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue new file mode 100644 index 00000000000..f57b5b2deb4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue @@ -0,0 +1,52 @@ +<script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import $ from 'jquery'; +import '~/behaviors/markdown/render_gfm'; + +export default { + directives: { + SafeHtml, + }, + props: { + issuable: { + type: Object, + required: true, + }, + enableTaskList: { + type: Boolean, + required: true, + }, + canEdit: { + type: Boolean, + required: true, + }, + taskListUpdatePath: { + type: String, + required: true, + }, + }, + mounted() { + this.renderGFM(); + }, + methods: { + renderGFM() { + $(this.$refs.gfmContainer).renderGFM(); + }, + }, +}; +</script> + +<template> + <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }"> + <div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div> + <textarea + v-if="issuable.description && enableTaskList" + ref="textarea" + :value="issuable.description" + :data-update-url="taskListUpdatePath" + class="gl-display-none js-task-list-field" + dir="auto" + > + </textarea> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue new file mode 100644 index 00000000000..5858af6cc51 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue @@ -0,0 +1,15 @@ +<script> +export default { + name: 'IssuableDiscussion', +}; +</script> + +<template> + <section class="issuable-discussion"> + <div> + <ul class="notes main-notes-list timeline"> + <slot name="discussion"></slot> + </ul> + </div> + </section> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue new file mode 100644 index 00000000000..33dca3e9332 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue @@ -0,0 +1,167 @@ +<script> +import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import $ from 'jquery'; + +import Autosave from '~/autosave'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import ZenMode from '~/zen_mode'; + +import eventHub from '../event_hub'; + +export default { + components: { + GlForm, + GlFormGroup, + GlFormInput, + MarkdownField, + }, + props: { + issuable: { + type: Object, + required: true, + }, + enableAutocomplete: { + type: Boolean, + required: true, + }, + enableAutosave: { + type: Boolean, + required: true, + }, + enableZenMode: { + type: Boolean, + required: true, + }, + showFieldTitle: { + type: Boolean, + required: true, + }, + descriptionPreviewPath: { + type: String, + required: true, + }, + descriptionHelpPath: { + type: String, + required: true, + }, + }, + data() { + return { + title: '', + description: '', + }; + }, + watch: { + issuable: { + handler(value) { + this.title = value?.title || ''; + this.description = value?.description || ''; + }, + deep: true, + immediate: true, + }, + }, + created() { + eventHub.$on('update.issuable', this.resetAutosave); + eventHub.$on('close.form', this.resetAutosave); + }, + mounted() { + if (this.enableAutosave) this.initAutosave(); + + // eslint-disable-next-line no-new + if (this.enableZenMode) new ZenMode(); + }, + beforeDestroy() { + eventHub.$off('update.issuable', this.resetAutosave); + eventHub.$off('close.form', this.resetAutosave); + }, + methods: { + initAutosave() { + const { titleInput, descriptionInput } = this.$refs; + + if (!titleInput || !descriptionInput) return; + + this.autosaveTitle = new Autosave($(titleInput.$el), [ + document.location.pathname, + document.location.search, + 'title', + ]); + + this.autosaveDescription = new Autosave($(descriptionInput.$el), [ + document.location.pathname, + document.location.search, + 'description', + ]); + }, + resetAutosave() { + this.autosaveTitle.reset(); + this.autosaveDescription.reset(); + }, + handleKeydown(e, inputType) { + this.$emit(`keydown-${inputType}`, e, { + issuableTitle: this.title, + issuableDescription: this.description, + }); + }, + }, +}; +</script> + +<template> + <gl-form> + <gl-form-group + data-testid="title" + :label="__('Title')" + :label-sr-only="!showFieldTitle" + label-for="issuable-title" + class="col-12 gl-px-0" + > + <gl-form-input + id="issuable-title" + ref="titleInput" + v-model.trim="title" + :placeholder="__('Title')" + :aria-label="__('Title')" + :autofocus="true" + class="qa-title-input" + @keydown="handleKeydown($event, 'title')" + /> + </gl-form-group> + <gl-form-group + data-testid="description" + :label="__('Description')" + :label-sr-only="!showFieldTitle" + label-for="issuable-description" + label-class="gl-pb-0!" + class="col-12 gl-px-0 common-note-form" + > + <markdown-field + :markdown-preview-path="descriptionPreviewPath" + :markdown-docs-path="descriptionHelpPath" + :enable-autocomplete="enableAutocomplete" + :textarea-value="description" + > + <template #textarea> + <textarea + id="issuable-description" + ref="descriptionInput" + v-model="description" + :data-supports-quick-actions="enableAutocomplete" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files hereā¦')" + class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" + dir="auto" + @keydown="handleKeydown($event, 'description')" + ></textarea> + </template> + </markdown-field> + </gl-form-group> + <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 gl-px-0 clearfix"> + <slot + name="edit-form-actions" + :issuable-title="title" + :issuable-description="description" + ></slot> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue new file mode 100644 index 00000000000..d7da533d055 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -0,0 +1,152 @@ +<script> +import { GlIcon, GlButton, GlTooltipDirective, GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; + +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { isExternal } from '~/lib/utils/url_utility'; +import { n__, sprintf } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + GlIcon, + GlButton, + GlAvatarLink, + GlAvatarLabeled, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + createdAt: { + type: String, + required: true, + }, + author: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: false, + default: '', + }, + statusIcon: { + type: String, + required: false, + default: '', + }, + blocked: { + type: Boolean, + required: false, + default: false, + }, + confidential: { + type: Boolean, + required: false, + default: false, + }, + taskCompletionStatus: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + authorId() { + return getIdFromGraphQLId(`${this.author.id}`); + }, + isAuthorExternal() { + return isExternal(this.author.webUrl); + }, + taskStatusString() { + const { count, completedCount } = this.taskCompletionStatus; + + return sprintf( + n__( + '%{completedCount} of %{count} task completed', + '%{completedCount} of %{count} tasks completed', + count, + ), + { completedCount, count }, + ); + }, + }, + mounted() { + this.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button'); + }, + methods: { + handleRightSidebarToggleClick() { + if (this.toggleSidebarButtonEl) { + this.toggleSidebarButtonEl.dispatchEvent(new Event('click')); + } + }, + }, +}; +</script> + +<template> + <div class="detail-page-header"> + <div class="detail-page-header-body"> + <div data-testid="status" class="issuable-status-box status-box" :class="statusBadgeClass"> + <gl-icon v-if="statusIcon" :name="statusIcon" class="d-block d-sm-none" /> + <span class="d-none d-sm-block"><slot name="status-badge"></slot></span> + </div> + <div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block"> + <div v-if="blocked || confidential" class="gl-display-inline-block"> + <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline"> + <gl-icon name="lock" :aria-label="__('Blocked')" /> + </div> + <div v-if="confidential" data-testid="confidential" class="issuable-warning-icon inline"> + <gl-icon name="eye-slash" :aria-label="__('Confidential')" /> + </div> + </div> + <span> + {{ __('Opened') }} + <time-ago-tooltip data-testid="startTimeItem" :time="createdAt" /> + {{ __('by') }} + </span> + <gl-avatar-link + data-testid="avatar" + :data-user-id="authorId" + :data-username="author.username" + :data-name="author.name" + :href="author.webUrl" + target="_blank" + class="js-user-link gl-vertical-align-middle gl-ml-2" + > + <gl-avatar-labeled + :size="24" + :src="author.avatarUrl" + :label="author.name" + class="d-none d-sm-inline-flex gl-mx-1" + > + <template #meta> + <gl-icon v-if="isAuthorExternal" name="external-link" /> + </template> + </gl-avatar-labeled> + <strong class="author d-sm-none d-inline">@{{ author.username }}</strong> + </gl-avatar-link> + <span + v-if="taskCompletionStatus" + data-testid="task-status" + class="gl-display-none gl-md-display-block gl-lg-display-inline-block" + >{{ taskStatusString }}</span + > + </div> + <gl-button + data-testid="sidebar-toggle" + icon="chevron-double-lg-left" + class="d-block d-sm-none gutter-toggle issuable-gutter-toggle" + :aria-label="__('Expand sidebar')" + @click="handleRightSidebarToggleClick" + /> + </div> + <div + data-testid="header-actions" + class="detail-page-header-actions gl-display-flex gl-md-display-block" + > + <slot name="header-actions"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue new file mode 100644 index 00000000000..8849af2a52e --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue @@ -0,0 +1,162 @@ +<script> +import IssuableSidebar from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue'; + +import IssuableBody from './issuable_body.vue'; +import IssuableDiscussion from './issuable_discussion.vue'; +import IssuableHeader from './issuable_header.vue'; + +export default { + components: { + IssuableSidebar, + IssuableHeader, + IssuableBody, + IssuableDiscussion, + }, + props: { + issuable: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: false, + default: '', + }, + statusIcon: { + type: String, + required: false, + default: '', + }, + enableEdit: { + type: Boolean, + required: false, + default: false, + }, + enableAutocomplete: { + type: Boolean, + required: false, + default: false, + }, + enableAutosave: { + type: Boolean, + required: false, + default: true, + }, + enableZenMode: { + type: Boolean, + required: false, + default: true, + }, + enableTaskList: { + type: Boolean, + required: false, + default: false, + }, + editFormVisible: { + type: Boolean, + required: false, + default: false, + }, + showFieldTitle: { + type: Boolean, + required: false, + default: false, + }, + descriptionPreviewPath: { + type: String, + required: false, + default: '', + }, + descriptionHelpPath: { + type: String, + required: false, + default: '', + }, + taskCompletionStatus: { + type: Object, + required: false, + default: null, + }, + taskListUpdatePath: { + type: String, + required: false, + default: '', + }, + taskListLockVersion: { + type: Number, + required: false, + default: 0, + }, + }, + methods: { + handleKeydownTitle(e, issuableMeta) { + this.$emit('keydown-title', e, issuableMeta); + }, + handleKeydownDescription(e, issuableMeta) { + this.$emit('keydown-description', e, issuableMeta); + }, + }, +}; +</script> + +<template> + <div class="issuable-show-container" data-qa-selector="issuable_show_container"> + <issuable-header + :status-badge-class="statusBadgeClass" + :status-icon="statusIcon" + :blocked="issuable.blocked" + :confidential="issuable.confidential" + :created-at="issuable.createdAt" + :author="issuable.author" + :task-completion-status="taskCompletionStatus" + > + <template #status-badge> + <slot name="status-badge"></slot> + </template> + <template #header-actions> + <slot name="header-actions"></slot> + </template> + </issuable-header> + + <issuable-body + :issuable="issuable" + :status-badge-class="statusBadgeClass" + :status-icon="statusIcon" + :enable-edit="enableEdit" + :enable-autocomplete="enableAutocomplete" + :enable-autosave="enableAutosave" + :enable-zen-mode="enableZenMode" + :enable-task-list="enableTaskList" + :edit-form-visible="editFormVisible" + :show-field-title="showFieldTitle" + :description-preview-path="descriptionPreviewPath" + :description-help-path="descriptionHelpPath" + :task-list-update-path="taskListUpdatePath" + :task-list-lock-version="taskListLockVersion" + @edit-issuable="$emit('edit-issuable', $event)" + @task-list-update-success="$emit('task-list-update-success', $event)" + @task-list-update-failure="$emit('task-list-update-failure')" + @keydown-title="handleKeydownTitle" + @keydown-description="handleKeydownDescription" + > + <template #status-badge> + <slot name="status-badge"></slot> + </template> + <template #edit-form-actions="actionsProps"> + <slot name="edit-form-actions" v-bind="actionsProps"></slot> + </template> + </issuable-body> + + <issuable-discussion> + <template #discussion> + <slot name="discussion"></slot> + </template> + </issuable-discussion> + + <issuable-sidebar> + <template #right-sidebar-items="{ sidebarExpanded, toggleSidebar }"> + <slot name="right-sidebar-items" v-bind="{ sidebarExpanded, toggleSidebar }"></slot> + </template> + </issuable-sidebar> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue new file mode 100644 index 00000000000..b96ce0c43f7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -0,0 +1,101 @@ +<script> +import { + GlIcon, + GlButton, + GlIntersectionObserver, + GlTooltipDirective, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + i18n: { + editTitleAndDescription: __('Edit title and description'), + }, + components: { + GlIcon, + GlButton, + GlIntersectionObserver, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + props: { + issuable: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: true, + }, + statusIcon: { + type: String, + required: true, + }, + enableEdit: { + type: Boolean, + required: true, + }, + }, + data() { + return { + stickyTitleVisible: false, + }; + }, + methods: { + handleTitleAppear() { + this.stickyTitleVisible = false; + }, + handleTitleDisappear() { + this.stickyTitleVisible = true; + }, + }, +}; +</script> + +<template> + <div> + <div class="title-container"> + <h2 v-safe-html="issuable.titleHtml || issuable.title" class="title qa-title" dir="auto"></h2> + <gl-button + v-if="enableEdit" + v-gl-tooltip.bottom + :title="$options.i18n.editTitleAndDescription" + :aria-label="$options.i18n.editTitleAndDescription" + icon="pencil" + class="btn-edit js-issuable-edit qa-edit-button" + @click="$emit('edit-issuable', $event)" + /> + </div> + <gl-intersection-observer @appear="handleTitleAppear" @disappear="handleTitleDisappear"> + <transition name="issuable-header-slide"> + <div + v-if="stickyTitleVisible" + class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" + data-testid="header" + > + <div + class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5" + > + <p + data-testid="status" + class="issuable-status-box status-box gl-my-0" + :class="statusBadgeClass" + > + <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" /> + <span class="gl-display-none d-sm-block"><slot name="status-badge"></slot></span> + </p> + <p + class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" + :title="issuable.title" + > + {{ issuable.title }} + </p> + </div> + </div> + </transition> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/show/constants.js b/app/assets/javascripts/vue_shared/issuable/show/constants.js new file mode 100644 index 00000000000..346f45c7d90 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/show/constants.js @@ -0,0 +1,5 @@ +export const IssuableType = { + Issue: 'issue', + Incident: 'incident', + TestCase: 'test_case', +}; diff --git a/app/assets/javascripts/vue_shared/issuable/show/event_hub.js b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue new file mode 100644 index 00000000000..99dcccd12ed --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue @@ -0,0 +1,86 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import Cookies from 'js-cookie'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { USER_COLLAPSED_GUTTER_COOKIE } from '../constants'; + +export default { + components: { + GlIcon, + }, + data() { + const userExpanded = !parseBoolean(Cookies.get(USER_COLLAPSED_GUTTER_COOKIE)); + + // We're deliberately keeping two different props for sidebar status; + // 1. userExpanded reflects value based on cookie `collapsed_gutter`. + // 2. isExpanded reflect actual sidebar state. + return { + userExpanded, + isExpanded: userExpanded ? bp.isDesktop() : userExpanded, + }; + }, + mounted() { + window.addEventListener('resize', this.handleWindowResize); + this.updatePageContainerClass(); + }, + beforeDestroy() { + window.removeEventListener('resize', this.handleWindowResize); + }, + methods: { + updatePageContainerClass() { + const layoutPageEl = document.querySelector('.layout-page'); + + if (layoutPageEl) { + layoutPageEl.classList.toggle('right-sidebar-expanded', this.isExpanded); + layoutPageEl.classList.toggle('right-sidebar-collapsed', !this.isExpanded); + } + }, + handleWindowResize() { + if (this.userExpanded) { + this.isExpanded = bp.isDesktop(); + this.updatePageContainerClass(); + } + }, + toggleSidebar() { + this.isExpanded = !this.isExpanded; + this.userExpanded = this.isExpanded; + + Cookies.set(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded); + this.updatePageContainerClass(); + }, + }, +}; +</script> + +<template> + <aside + :class="{ 'right-sidebar-expanded': isExpanded, 'right-sidebar-collapsed': !isExpanded }" + class="right-sidebar" + aria-live="polite" + > + <button + class="toggle-right-sidebar-button js-toggle-right-sidebar-button w-100 gl-text-decoration-none! gl-display-flex gl-outline-0!" + data-testid="toggle-right-sidebar-button" + :title="__('Toggle sidebar')" + @click="toggleSidebar" + > + <span v-if="isExpanded" class="collapse-text gl-flex-grow-1 gl-text-left">{{ + __('Collapse sidebar') + }}</span> + <gl-icon v-show="isExpanded" data-testid="icon-collapse" name="chevron-double-lg-right" /> + <gl-icon + v-show="!isExpanded" + data-testid="icon-expand" + name="chevron-double-lg-left" + class="gl-ml-2" + /> + </button> + <div data-testid="sidebar-items" class="issuable-sidebar"> + <slot + name="right-sidebar-items" + v-bind="{ sidebarExpanded: isExpanded, toggleSidebar }" + ></slot> + </div> + </aside> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js new file mode 100644 index 00000000000..4f4b6341a1c --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js @@ -0,0 +1 @@ +export const USER_COLLAPSED_GUTTER_COOKIE = 'collapsed_gutter'; diff --git a/app/assets/javascripts/vue_shared/mixins/issuable.js b/app/assets/javascripts/vue_shared/mixins/issuable.js deleted file mode 100644 index fab0919d96e..00000000000 --- a/app/assets/javascripts/vue_shared/mixins/issuable.js +++ /dev/null @@ -1,14 +0,0 @@ -export default { - props: { - issuableType: { - required: true, - type: String, - }, - }, - - computed: { - issuableDisplayName() { - return this.issuableType.replace(/_/g, ' '); - }, - }, -}; diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js deleted file mode 100644 index 4a6edae0c06..00000000000 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ /dev/null @@ -1,214 +0,0 @@ -import { isEmpty } from 'lodash'; -import { formatDate } from '~/lib/utils/datetime_utility'; -import { sprintf, __ } from '~/locale'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; - -const mixins = { - data() { - return { - removeDisabled: false, - }; - }, - props: { - idKey: { - type: Number, - required: true, - }, - displayReference: { - type: String, - required: true, - }, - pathIdSeparator: { - type: String, - required: true, - }, - eventNamespace: { - type: String, - required: false, - default: '', - }, - confidential: { - type: Boolean, - required: false, - default: false, - }, - title: { - type: String, - required: false, - default: '', - }, - path: { - type: String, - required: false, - default: '', - }, - state: { - type: String, - required: false, - default: '', - }, - createdAt: { - type: String, - required: false, - default: '', - }, - closedAt: { - type: String, - required: false, - default: '', - }, - mergedAt: { - type: String, - required: false, - default: '', - }, - milestone: { - type: Object, - required: false, - default: () => ({}), - }, - dueDate: { - type: String, - required: false, - default: '', - }, - assignees: { - type: Array, - required: false, - default: () => [], - }, - weight: { - type: Number, - required: false, - default: 0, - }, - canRemove: { - type: Boolean, - required: false, - default: false, - }, - isMergeRequest: { - type: Boolean, - required: false, - default: false, - }, - pipelineStatus: { - type: Object, - required: false, - default: () => ({}), - }, - }, - mixins: [timeagoMixin], - computed: { - hasState() { - return this.state && this.state.length > 0; - }, - hasPipeline() { - return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length; - }, - isOpen() { - return this.state === 'opened' || this.state === 'reopened'; - }, - isClosed() { - return this.state === 'closed'; - }, - isMerged() { - return this.state === 'merged'; - }, - hasTitle() { - return this.title.length > 0; - }, - hasAssignees() { - return this.assignees.length > 0; - }, - hasMilestone() { - return !isEmpty(this.milestone); - }, - iconName() { - if (this.isMergeRequest && this.isMerged) { - return 'merge'; - } - - return this.isOpen ? 'issue-open-m' : 'issue-close'; - }, - iconClass() { - if (this.isMergeRequest && this.isClosed) { - return 'merge-request-status closed issue-token-state-icon-closed'; - } - - return this.isOpen - ? 'issue-token-state-icon-open gl-text-green-500' - : 'issue-token-state-icon-closed gl-text-blue-500'; - }, - computedLinkElementType() { - return this.path.length > 0 ? 'a' : 'span'; - }, - computedPath() { - return this.path.length ? this.path : null; - }, - itemPath() { - return this.displayReference.split(this.pathIdSeparator)[0]; - }, - itemId() { - return this.displayReference.split(this.pathIdSeparator).pop(); - }, - createdAtInWords() { - return this.createdAt ? this.timeFormatted(this.createdAt) : ''; - }, - createdAtTimestamp() { - return this.createdAt ? formatDate(new Date(this.createdAt)) : ''; - }, - mergedAtTimestamp() { - return this.mergedAt ? formatDate(new Date(this.mergedAt)) : ''; - }, - mergedAtInWords() { - return this.mergedAt ? this.timeFormatted(this.mergedAt) : ''; - }, - closedAtInWords() { - return this.closedAt ? this.timeFormatted(this.closedAt) : ''; - }, - closedAtTimestamp() { - return this.closedAt ? formatDate(new Date(this.closedAt)) : ''; - }, - stateText() { - if (this.isMerged) { - return __('Merged'); - } - - return this.isOpen ? __('Created') : __('Closed'); - }, - stateTimeInWords() { - if (this.isMerged) { - return this.mergedAtInWords; - } - - return this.isOpen ? this.createdAtInWords : this.closedAtInWords; - }, - stateTimestamp() { - if (this.isMerged) { - return this.mergedAtTimestamp; - } - - return this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp; - }, - pipelineStatusTooltip() { - return this.hasPipeline - ? sprintf(__('Pipeline: %{status}'), { status: this.pipelineStatus.label }) - : ''; - }, - }, - methods: { - onRemoveRequest() { - let namespacePrefix = ''; - if (this.eventNamespace && this.eventNamespace.length > 0) { - namespacePrefix = `${this.eventNamespace}`; - } - - this.$emit(`${namespacePrefix}RemoveRequest`, this.idKey); - - this.removeDisabled = true; - }, - }, -}; - -export default mixins; diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue index 42272c222fc..d1630c9ac13 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -85,7 +85,7 @@ export default { ); }, i18n: { - buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'), + buttonLabel: s__('SecurityConfiguration|Configure with a merge request'), noSuccessPathError: s__( 'SecurityConfiguration|%{featureName} merge request creation mutation failed', ), diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql index ae77a2ce5e4..829b9d9f9d8 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql @@ -1,6 +1,8 @@ fragment JobArtifacts on Pipeline { + id jobs(securityReportTypes: $reportTypes) { nodes { + id name artifacts { nodes { diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql index 4ce13827da2..2e80db30e9a 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql @@ -4,11 +4,14 @@ query securityReportDownloadPaths( $reportTypes: [SecurityReportTypeEnum!] ) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id headPipeline { id jobs(securityReportTypes: $reportTypes) { nodes { + id name artifacts { nodes { diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql index e1f3c55a886..e4f0c392b91 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql @@ -2,8 +2,8 @@ query getPipelineCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) { project(fullPath: $projectPath) { + id pipeline(iid: $iid) { - id ...JobArtifacts } } |