diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
65 files changed, 1195 insertions, 1502 deletions
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> |