diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared')
52 files changed, 784 insertions, 155 deletions
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue index 322ea64eb7e..f2c27cf611e 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue @@ -61,7 +61,7 @@ export default { }, }); - return document.dispatchEvent(headerTodoEvent); + document.dispatchEvent(headerTodoEvent); }, addToDo() { this.isUpdating = true; @@ -75,9 +75,10 @@ export default { }) .then(({ data: { errors = [] } }) => { if (errors[0]) { - return this.throwError(errors[0]); + this.throwError(errors[0]); + return; } - return this.updateToDoCount(true); + this.updateToDoCount(true); }) .catch(() => { this.throwError(); @@ -98,9 +99,10 @@ export default { }) .then(({ data: { errors = [] } }) => { if (errors[0]) { - return this.throwError(errors[0]); + this.throwError(errors[0]); + return; } - return this.updateToDoCount(false); + this.updateToDoCount(false); }) .catch(() => { this.throwError(); diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue index 92817d5fa70..70cac061ca6 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue @@ -14,12 +14,12 @@ export default { </script> <template> - <div> + <div class="color-item"> <span - class="dropdown-label-box gl-flex-shrink-0 gl-top-1 gl-mr-0" + class="dropdown-label-box color-item-color" data-testid="color-item" :style="{ backgroundColor: color }" ></span> - <span class="hide-collapsed">{{ title }}</span> + <span class="color-item-text">{{ title }}</span> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue index 6b79883d76b..a88a4ca5cb8 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue @@ -1,4 +1,5 @@ <script> +import { isString } from 'lodash'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; @@ -52,13 +53,23 @@ export default { required: false, default: s__('ColorWidget|Assign epic color'), }, + defaultColor: { + type: Object, + required: false, + validator(value) { + return isString(value?.color) && isString(value?.title); + }, + default() { + return { + color: '', + title: '', + }; + }, + }, }, data() { return { - issuableColor: { - color: '', - title: '', - }, + issuableColor: this.defaultColor, colorUpdateInProgress: false, oldIid: null, sidebarExpandedOnClick: false, @@ -106,9 +117,9 @@ export default { methods: { handleDropdownClose(color) { if (this.iid !== '') { - this.updateSelectedColor(this.getUpdateVariables(color)); + this.updateSelectedColor(color); } else { - this.$emit('updateSelectedColor', color); + this.$emit('updateSelectedColor', { color }); } this.collapseEditableItem(); @@ -129,13 +140,15 @@ export default { color: color.color, }; }, - updateSelectedColor(inputVariables) { + updateSelectedColor(color) { this.colorUpdateInProgress = true; + const input = this.getUpdateVariables(color); + this.$apollo .mutate({ mutation: updateEpicColorMutation, - variables: { input: inputVariables }, + variables: { input }, }) .then(({ data }) => { if (data.updateIssuableColor?.errors?.length) { @@ -144,7 +157,7 @@ export default { this.$emit('updateSelectedColor', { id: data.updateIssuableColor?.issuable?.id, - color: data.updateIssuableColor?.issuable?.color, + color, }); }) .catch((error) => diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js b/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js index c70785abd1e..701ac71d755 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js @@ -1,4 +1,4 @@ -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; export const COLOR_WIDGET_COLOR = s__('ColorWidget|Color'); @@ -7,7 +7,7 @@ export const DROPDOWN_VARIANT = { Embedded: 'embedded', }; -export const DEFAULT_COLOR = { title: __('SuggestedColors|Blue'), color: '#1068bf' }; +export const DEFAULT_COLOR = { title: s__('SuggestedColors|Blue'), color: '#1068bf' }; export const ISSUABLE_COLORS = [ DEFAULT_COLOR, diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue index 4eb1d3d08ca..84da6e1437e 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue @@ -1,11 +1,13 @@ <script> import { GlDropdown } from '@gitlab/ui'; +import ColorItem from './color_item.vue'; import DropdownContentsColorView from './dropdown_contents_color_view.vue'; import DropdownHeader from './dropdown_header.vue'; import { isDropdownVariantSidebar } from './utils'; export default { components: { + ColorItem, DropdownContentsColorView, DropdownHeader, GlDropdown, @@ -42,12 +44,15 @@ export default { }, computed: { buttonText() { - if (!this.localSelectedColor?.title) { + if (!this.hasSelectedColor) { return this.dropdownButtonText; } return this.localSelectedColor.title; }, + hasSelectedColor() { + return this.localSelectedColor?.title; + }, }, watch: { localSelectedColor: { @@ -91,7 +96,15 @@ export default { </script> <template> - <gl-dropdown ref="dropdown" :text="buttonText" class="gl-w-full" @hide="handleDropdownHide"> + <gl-dropdown ref="dropdown" class="gl-w-full" @hide="handleDropdownHide"> + <template #button-text> + <color-item + v-if="hasSelectedColor" + :color="localSelectedColor.color" + :title="localSelectedColor.title" + /> + <span v-else data-testid="fallback-button-text">{{ buttonText }}</span> + </template> <template #header> <dropdown-header ref="header" diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue index 62f4cf59c14..91906388049 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue @@ -36,8 +36,8 @@ export default { </script> <template> - <gl-dropdown-form> - <div> + <gl-dropdown-form class="js-colors-list"> + <div data-testid="dropdown-content"> <gl-dropdown-item v-for="color in colors" :key="color.color" diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue index 4cba66eefd2..7ae803ebf4d 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue @@ -20,6 +20,11 @@ export default { required: true, }, }, + computed: { + hasColor() { + return this.selectedColor.color !== ''; + }, + }, }; </script> @@ -31,13 +36,18 @@ export default { class="sidebar-collapsed-icon" > <gl-icon name="appearance" /> + <color-item :color="selectedColor.color" :title="selectedColor.title" /> + </div> + + <span v-if="!hasColor" class="no-value hide-collapsed"> + <slot></slot> + </span> + <template v-else> <color-item + class="hide-collapsed" :color="selectedColor.color" :title="selectedColor.title" - class="gl-font-base gl-line-height-24" /> - </div> - - <color-item class="hide-collapsed" :color="selectedColor.color" :title="selectedColor.title" /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/deployment_instance.vue b/app/assets/javascripts/vue_shared/components/deployment_instance.vue index 4aae86fc82b..1b907078cf9 100644 --- a/app/assets/javascripts/vue_shared/components/deployment_instance.vue +++ b/app/assets/javascripts/vue_shared/components/deployment_instance.vue @@ -13,8 +13,6 @@ * Mockup is https://gitlab.com/gitlab-org/gitlab/issues/35570 */ import { GlLink, GlTooltipDirective } from '@gitlab/ui'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -23,7 +21,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], props: { /** * Represents the status of the pod. Each state is represented with a different @@ -54,17 +51,11 @@ export default { required: false, default: '', }, - - logsPath: { - type: String, - required: false, - default: '', - }, }, computed: { isLink() { - return this.logsPath !== '' && this.podName !== ''; + return this.podName !== ''; }, cssClass() { @@ -74,12 +65,6 @@ export default { link: this.isLink, }; }, - - computedLogPath() { - return this.isLink && this.glFeatures.monitorLogging - ? mergeUrlParams({ pod_name: this.podName }, this.logsPath) - : null; - }, }, }; </script> @@ -88,7 +73,6 @@ export default { v-gl-tooltip :class="cssClass" :title="tooltipText" - :href="computedLogPath" class="deployment-instance d-flex justify-content-center align-items-center" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue index ca427ed4897..b9608a26d91 100644 --- a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue +++ b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue @@ -22,7 +22,7 @@ export default { }); }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue index a512eb687b7..a246eadb790 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -37,6 +37,7 @@ export default { aria-expanded="false" > <gl-loading-icon v-show="isLoading" size="sm" :inline="true" /> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <slot v-if="$slots.default"></slot> <span v-else class="dropdown-toggle-text"> {{ toggleText }} </span> <gl-icon diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue index 5d0ed8b0821..1da84df022f 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue @@ -75,7 +75,7 @@ export default { }, }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql new file mode 100644 index 00000000000..38222e4e8c2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql @@ -0,0 +1,6 @@ +fragment ContactFragment on CustomerRelationsContact { + id + firstName + lastName + email +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql new file mode 100644 index 00000000000..a7de3c7f7af --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql @@ -0,0 +1,4 @@ +fragment OrganizationFragment on CustomerRelationsOrganization { + id + name +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql new file mode 100644 index 00000000000..647aaa0f7f8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql @@ -0,0 +1,28 @@ +#import "./crm_contact.fragment.graphql" + +query searchCrmContacts( + $isProject: Boolean = false + $fullPath: ID! + $searchString: String + $searchIds: [CustomerRelationsContactID!] +) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + contacts(search: $searchString, ids: $searchIds) { + nodes { + ...ContactFragment + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + group { + id + contacts(search: $searchString, ids: $searchIds) { + nodes { + ...ContactFragment + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql new file mode 100644 index 00000000000..c4f4663de45 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql @@ -0,0 +1,28 @@ +#import "./crm_organization.fragment.graphql" + +query searchCrmOrganizations( + $isProject: Boolean = false + $fullPath: ID! + $searchString: String + $searchIds: [CustomerRelationsOrganizationID!] +) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + organizations(search: $searchString, ids: $searchIds) { + nodes { + ...OrganizationFragment + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + group { + id + organizations(search: $searchString, ids: $searchIds) { + nodes { + ...OrganizationFragment + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue new file mode 100644 index 00000000000..adfe0559b62 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue @@ -0,0 +1,131 @@ +<script> +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; + +import { ITEM_TYPE } from '~/groups/constants'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; +import createFlash from '~/flash'; +import { isPositiveInteger } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql'; + +import { DEFAULT_NONE_ANY } from '../constants'; + +import BaseToken from './base_token.vue'; + +export default { + components: { + BaseToken, + GlFilteredSearchSuggestion, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + active: { + type: Boolean, + required: true, + }, + }, + data() { + return { + contacts: this.config.initialContacts || [], + loading: false, + }; + }, + computed: { + defaultContacts() { + return this.config.defaultContacts || DEFAULT_NONE_ANY; + }, + namespace() { + return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; + }, + }, + methods: { + getActiveContact(contacts, data) { + return contacts.find((contact) => { + return `${this.formatContactId(contact)}` === data; + }); + }, + getContactName(contact) { + return `${contact.firstName} ${contact.lastName}`; + }, + fetchContacts(searchTerm) { + let searchString = null; + let searchId = null; + if (isPositiveInteger(searchTerm)) { + searchId = this.formatContactGraphQLId(searchTerm); + } else { + searchString = searchTerm; + } + + this.loading = true; + + this.$apollo + .query({ + query: searchCrmContactsQuery, + variables: { + fullPath: this.config.fullPath, + searchString, + searchIds: searchId ? [searchId] : null, + isProject: this.config.isProject, + }, + }) + .then(({ data }) => { + this.contacts = this.config.isProject + ? data[this.namespace]?.group.contacts.nodes + : data[this.namespace]?.contacts.nodes; + }) + .catch(() => + createFlash({ + message: __('There was a problem fetching CRM contacts.'), + }), + ) + .finally(() => { + this.loading = false; + }); + }, + formatContactId(contact) { + return `${getIdFromGraphQLId(contact.id)}`; + }, + formatContactGraphQLId(id) { + return convertToGraphQLId('CustomerRelations::Contact', id); + }, + }, +}; +</script> + +<template> + <base-token + :config="config" + :value="value" + :active="active" + :suggestions-loading="loading" + :suggestions="contacts" + :get-active-token-value="getActiveContact" + :default-suggestions="defaultContacts" + v-bind="$attrs" + @fetch-suggestions="fetchContacts" + v-on="$listeners" + > + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? getContactName(activeTokenValue) : inputValue }} + </template> + <template #suggestions-list="{ suggestions }"> + <gl-filtered-search-suggestion + v-for="contact in suggestions" + :key="formatContactId(contact)" + :value="formatContactId(contact)" + > + <div> + <div>{{ getContactName(contact) }}</div> + <div class="gl-font-sm">{{ contact.email }}</div> + </div> + </gl-filtered-search-suggestion> + </template> + </base-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue new file mode 100644 index 00000000000..e6ab944449e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue @@ -0,0 +1,125 @@ +<script> +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; + +import { ITEM_TYPE } from '~/groups/constants'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; +import createFlash from '~/flash'; +import { isPositiveInteger } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql'; + +import { DEFAULT_NONE_ANY } from '../constants'; + +import BaseToken from './base_token.vue'; + +export default { + components: { + BaseToken, + GlFilteredSearchSuggestion, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + active: { + type: Boolean, + required: true, + }, + }, + data() { + return { + organizations: this.config.initialOrganizations || [], + loading: false, + }; + }, + computed: { + defaultOrganizations() { + return this.config.defaultOrganizations || DEFAULT_NONE_ANY; + }, + namespace() { + return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; + }, + }, + methods: { + getActiveOrganization(organizations, data) { + return organizations.find((organization) => { + return `${this.formatOrganizationId(organization)}` === data; + }); + }, + fetchOrganizations(searchTerm) { + let searchString = null; + let searchId = null; + if (isPositiveInteger(searchTerm)) { + searchId = this.formatOrganizationGraphQLId(searchTerm); + } else { + searchString = searchTerm; + } + + this.loading = true; + + this.$apollo + .query({ + query: searchCrmOrganizationsQuery, + variables: { + fullPath: this.config.fullPath, + searchString, + searchIds: searchId ? [searchId] : null, + isProject: this.config.isProject, + }, + }) + .then(({ data }) => { + this.organizations = this.config.isProject + ? data[this.namespace]?.group.organizations.nodes + : data[this.namespace]?.organizations.nodes; + }) + .catch(() => + createFlash({ + message: __('There was a problem fetching CRM organizations.'), + }), + ) + .finally(() => { + this.loading = false; + }); + }, + formatOrganizationId(organization) { + return `${getIdFromGraphQLId(organization.id)}`; + }, + formatOrganizationGraphQLId(id) { + return convertToGraphQLId('CustomerRelations::Organization', id); + }, + }, +}; +</script> + +<template> + <base-token + :config="config" + :value="value" + :active="active" + :suggestions-loading="loading" + :suggestions="organizations" + :get-active-token-value="getActiveOrganization" + :default-suggestions="defaultOrganizations" + v-bind="$attrs" + @fetch-suggestions="fetchOrganizations" + v-on="$listeners" + > + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? activeTokenValue.name : inputValue }} + </template> + <template #suggestions-list="{ suggestions }"> + <gl-filtered-search-suggestion + v-for="organization in suggestions" + :key="formatOrganizationId(organization)" + :value="formatOrganizationId(organization)" + > + {{ organization.name }} + </gl-filtered-search-suggestion> + </template> + </base-token> +</template> 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 index 15d858b99b9..482a2964b4c 100644 --- 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 @@ -139,6 +139,7 @@ export default { /> </template> </gl-form-input-group> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <template v-for="slot in Object.keys($slots)" #[slot]> <slot :name="slot"></slot> </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 f2abade8036..96f7427dda1 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -163,6 +163,7 @@ export default { </template> </section> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex"> <slot></slot> </section> diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index c3f184446a8..1b89bd324c6 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -38,6 +38,7 @@ export default { <template #default> <div v-safe-html="options.content"></div> </template> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <template v-for="slot in Object.keys($slots)" #[slot]> <slot :name="slot"></slot> </template> diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue index 4ece87310c7..96c779c5ce4 100644 --- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue +++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue @@ -100,7 +100,7 @@ export default { }, }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 1f309a19b14..32b3a0e22c2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -248,7 +248,7 @@ export default { labels: this.enableAutocomplete, snippets: this.enableAutocomplete, vulnerabilities: this.enableAutocomplete, - contacts: this.enableAutocomplete && this.glFeatures.contactsAutocomplete, + contacts: this.enableAutocomplete, }, true, ); diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 8a1b8363f19..7646a8718d6 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -139,8 +139,8 @@ export default { </script> <template> - <div class="md-suggestion-header border-bottom-0 mt-2"> - <div class="js-suggestion-diff-header font-weight-bold"> + <div class="md-suggestion-header border-bottom-0 gl-mt-3"> + <div class="js-suggestion-diff-header gl-font-weight-bold"> {{ __('Suggested change') }} <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn"> <gl-icon name="question-o" css-classes="link-highlight" /> @@ -151,13 +151,13 @@ export default { </gl-badge> <div v-else-if="isApplying" - class="d-flex align-items-center text-secondary" + class="gl-display-flex gl-align-items-center text-secondary" data-qa-selector="applying_badge" > - <gl-loading-icon size="sm" class="d-flex-center mr-2" /> + <gl-loading-icon size="sm" class="gl-align-items-center gl-justify-content-center gl-mr-3" /> <span>{{ applyingSuggestionsMessage }}</span> </div> - <div v-else-if="isLoggedIn" class="d-flex align-items-center"> + <div v-else-if="isLoggedIn" class="gl-display-flex gl-align-items-center"> <div v-if="isBatched"> <gl-button class="btn-inverted js-remove-from-batch-btn btn-grouped" 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 624dbcc6d8e..0cb4a5bc39f 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -16,17 +16,17 @@ * :note="{body: 'This is a note'}" * /> */ -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { renderMarkdown } from '~/notes/utils'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import userAvatarLink from '../user_avatar/user_avatar_link.vue'; export default { name: 'PlaceholderNote', directives: { SafeHtml }, components: { - userAvatarLink, + GlAvatarLink, + GlAvatar, TimelineEntryItem, }, props: { @@ -55,7 +55,10 @@ export default { return 24; } - return 40; + return { + default: 24, + md: 32, + }; }, }, }; @@ -64,11 +67,14 @@ export default { <template> <timeline-entry-item class="note note-wrapper being-posted fade-in-half"> <div class="timeline-icon"> - <user-avatar-link - :link-href="getUserData.path" - :img-src="getUserData.avatar_url" - :img-size="avatarSize" - /> + <gl-avatar-link class="gl-mr-3" :href="getUserData.path"> + <gl-avatar + :src="getUserData.avatar_url" + :entity-name="getUserData.username" + :alt="getUserData.name" + :size="avatarSize" + /> + </gl-avatar-link> </div> <div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content"> <div class="note-header"> diff --git a/app/assets/javascripts/vue_shared/components/page_size_selector.vue b/app/assets/javascripts/vue_shared/components/page_size_selector.vue new file mode 100644 index 00000000000..9783946b786 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/page_size_selector.vue @@ -0,0 +1,37 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export const PAGE_SIZES = [20, 50, 100]; + +export default { + components: { GlDropdown, GlDropdownItem }, + props: { + value: { + type: Number, + required: true, + }, + }, + methods: { + emitInput(pageSize) { + this.$emit('input', pageSize); + }, + getPageSizeText(pageSize) { + return sprintf(s__('SecurityReports|Show %{pageSize} items'), { pageSize }); + }, + }, + PAGE_SIZES, +}; +</script> + +<template> + <gl-dropdown :text="getPageSizeText(value)" right menu-class="gl-w-auto! gl-min-w-0"> + <gl-dropdown-item + v-for="pageSize in $options.PAGE_SIZES" + :key="pageSize" + @click="emitInput(pageSize)" + > + <span class="gl-white-space-nowrap">{{ getPageSizeText(pageSize) }}</span> + </gl-dropdown-item> + </gl-dropdown> +</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 a8b250f2041..5516c9943b8 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -38,6 +38,7 @@ export default { }, }, mounted() { + // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots this.detailsSlots = Object.keys(this.$slots).filter((k) => k.startsWith('details-')); }, methods: { @@ -55,7 +56,7 @@ export default { > <div class="gl-display-flex gl-align-items-center gl-py-3"> <div - v-if="$slots['left-action']" + v-if="$slots['left-action'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */" class="gl-w-7 gl-display-flex gl-justify-content-start gl-pl-2" > <slot name="left-action"></slot> @@ -65,7 +66,9 @@ export default { > <div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"> <div - v-if="$slots['left-primary']" + v-if=" + $slots['left-primary'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ + " class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0" > <slot name="left-primary"></slot> @@ -79,7 +82,11 @@ export default { /> </div> <div - v-if="$slots['left-secondary']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'left-secondary' + ] + " class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1" > <slot name="left-secondary"></slot> @@ -89,13 +96,21 @@ export default { class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0" > <div - v-if="$slots['right-primary']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'right-primary' + ] + " class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6" > <slot name="right-primary"></slot> </div> <div - v-if="$slots['right-secondary']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'right-secondary' + ] + " class="gl-display-flex gl-align-items-center gl-min-h-6" > <slot name="right-secondary"></slot> @@ -103,7 +118,9 @@ export default { </div> </div> <div - v-if="$slots['right-action']" + v-if=" + $slots['right-action'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ + " class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1" > <slot name="right-action"></slot> diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue index fc0976b0792..ad979387596 100644 --- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue +++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue @@ -47,6 +47,7 @@ export default { methods: { recalculateMetadataSlots() { const METADATA_PREFIX = 'metadata-'; + // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots const metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX)); if (!isEqual(metadataSlots, this.metadataSlots)) { @@ -76,7 +77,9 @@ export default { </h2> <div - v-if="$slots['sub-header']" + v-if=" + $slots['sub-header'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ + " class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3" > <slot name="sub-header"></slot> @@ -107,6 +110,7 @@ export default { </template> </div> </div> + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> <div v-if="$slots['right-actions']" class="gl-mt-3"> <slot name="right-actions"></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js new file mode 100644 index 00000000000..1c08433ee78 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js @@ -0,0 +1 @@ +// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js new file mode 100644 index 00000000000..1c08433ee78 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js @@ -0,0 +1 @@ +// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js new file mode 100644 index 00000000000..1c08433ee78 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js @@ -0,0 +1 @@ +// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports. 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 5471cda0cc5..0127df730b8 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 @@ -193,7 +193,7 @@ export default { <gl-dropdown ref="dropdown" :text="buttonText" - class="gl-w-full gl-mt-2" + class="gl-w-full" data-testid="labels-select-dropdown-contents" data-qa-selector="labels_dropdown_content" @hide="handleDropdownHide" diff --git a/app/assets/javascripts/vue_shared/components/slot_switch.vue b/app/assets/javascripts/vue_shared/components/slot_switch.vue index 67726f01744..641b09e0982 100644 --- a/app/assets/javascripts/vue_shared/components/slot_switch.vue +++ b/app/assets/javascripts/vue_shared/components/slot_switch.vue @@ -20,6 +20,7 @@ export default { computed: { allSlotNames() { + // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots return Object.keys(this.$slots); }, }, diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index 0d78530d878..3ac35abcf3a 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -45,6 +45,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { haskell: 'haskell', haxe: 'haxe', http: 'http', + html: 'xml', hylang: 'hy', ini: 'ini', isbl: 'isbl', @@ -90,7 +91,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { scala: 'scala', scheme: 'scheme', scss: 'scss', - shell: 'shell', + shell: 'sh', smalltalk: 'smalltalk', sml: 'sml', sqf: 'sqf', @@ -112,6 +113,12 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { yaml: 'yaml', }; +export const EVENT_ACTION = 'view_source'; + +export const EVENT_LABEL_VIEWER = 'source_viewer'; + +export const EVENT_LABEL_FALLBACK = 'legacy_fallback'; + export const LINES_PER_CHUNK = 70; export const BIDI_CHARS = [ @@ -138,3 +145,5 @@ export const BIDI_CHAR_TOOLTIP = __( export const HLJS_COMMENT_SELECTOR = 'hljs-comment'; export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; + +export const NPM_URL = 'https://npmjs.com/package'; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js index c9f7e5508be..5d24a3d110b 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js @@ -1,5 +1,6 @@ import { HLJS_ON_AFTER_HIGHLIGHT } from '../constants'; import wrapComments from './wrap_comments'; +import linkDependencies from './link_dependencies'; /** * Registers our plugins for Highlight.js @@ -8,6 +9,9 @@ import wrapComments from './wrap_comments'; * * @param {Object} hljs - the Highlight.js instance. */ -export const registerPlugins = (hljs) => { +export const registerPlugins = (hljs, fileType, rawContent) => { hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments }); + hljs.addPlugin({ + [HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent), + }); }; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js new file mode 100644 index 00000000000..5b7650c56ae --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js @@ -0,0 +1,25 @@ +import packageJsonLinker from './utils/package_json_linker'; + +const DEPENDENCY_LINKERS = { + package_json: packageJsonLinker, +}; + +/** + * Highlight.js plugin for generating links to dependencies when viewing dependency files. + * + * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst + * + * @param {Object} result - an object that represents the highlighted result from Highlight.js + * @param {String} fileType - a string containing the file type + * @param {String} rawContent - raw (non-highlighted) file content + */ +export default (result, fileType, rawContent) => { + if (DEPENDENCY_LINKERS[fileType]) { + try { + // eslint-disable-next-line no-param-reassign + result.value = DEPENDENCY_LINKERS[fileType](result, rawContent); + } catch (e) { + // Shallowed (do nothing), in this case the original unlinked dependencies will be rendered. + } + } +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js new file mode 100644 index 00000000000..56ad55ef553 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js @@ -0,0 +1,15 @@ +import { escape } from 'lodash'; +import { setAttributes } from '~/lib/utils/dom_utils'; + +export const createLink = (href, innerText) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + const rel = 'nofollow noreferrer noopener'; + const link = document.createElement('a'); + + setAttributes(link, { href: escape(href), rel }); + link.innerText = escape(innerText); + + return link.outerHTML; +}; + +export const generateHLJSOpenTag = (type) => `<span class="hljs-${escape(type)}">"`; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js new file mode 100644 index 00000000000..d013d077ba3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js @@ -0,0 +1,46 @@ +import { joinPaths } from '~/lib/utils/url_utility'; +import { NPM_URL } from '../../constants'; +import { createLink, generateHLJSOpenTag } from './dependency_linker_util'; + +const attrOpenTag = generateHLJSOpenTag('attr'); +const stringOpenTag = generateHLJSOpenTag('string'); +const closeTag = '"</span>'; +const DEPENDENCY_REGEX = new RegExp( + /* + * Detects dependencies inside of content that is highlighted by Highlight.js + * Example: <span class="hljs-attr">"@babel/core"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"^7.18.5"</span> + * Group 1: @babel/core + * Group 2: ^7.18.5 + */ + `${attrOpenTag}(.*)${closeTag}.*${stringOpenTag}(.*[0-9].*)(${closeTag})`, + 'gm', +); + +const handleReplace = (original, packageName, version, dependenciesToLink) => { + const href = joinPaths(NPM_URL, packageName); + const packageLink = createLink(href, packageName); + const versionLink = createLink(href, version); + const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`; + const dependencyToLink = dependenciesToLink[packageName]; + + if (dependencyToLink && dependencyToLink === version) { + return `${attrOpenTag}${packageLink}${closeAndOpenTag}${versionLink}${closeTag}`; + } + + return original; +}; + +export default (result, raw) => { + const { dependencies, devDependencies, peerDependencies, optionalDependencies } = JSON.parse(raw); + + const dependenciesToLink = { + ...dependencies, + ...devDependencies, + ...peerDependencies, + ...optionalDependencies, + }; + + return result.value.replace(DEPENDENCY_REGEX, (original, packageName, version) => + handleReplace(original, packageName, version, dependenciesToLink), + ); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index f819a9e5be2..1bdae40332f 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -3,7 +3,14 @@ import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui'; import LineHighlighter from '~/blob/line_highlighter'; import eventHub from '~/notes/event_hub'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; -import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants'; +import Tracking from '~/tracking'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, + LINES_PER_CHUNK, +} from './constants'; import Chunk from './components/chunk.vue'; import { registerPlugins } from './plugins/index'; @@ -23,6 +30,7 @@ export default { directives: { SafeHtml: GlSafeHtmlDirective, }, + mixins: [Tracking.mixin()], props: { blob: { type: Object, @@ -49,8 +57,22 @@ export default { lineNumbers() { return this.splitContent.length; }, + unsupportedLanguage() { + const supportedLanguages = Object.keys(languageLoader); + return ( + !supportedLanguages.includes(this.language) && + !supportedLanguages.includes(this.blob.language) + ); + }, }, async created() { + this.trackEvent(EVENT_LABEL_VIEWER); + + if (this.unsupportedLanguage) { + this.handleUnsupportedLanguage(); + return; + } + this.generateFirstChunk(); this.hljs = await this.loadHighlightJS(); @@ -70,6 +92,13 @@ export default { }); }, methods: { + trackEvent(label) { + this.track(EVENT_ACTION, { label, property: this.blob.language }); + }, + handleUnsupportedLanguage() { + this.trackEvent(EVENT_LABEL_FALLBACK); + this.$emit('error'); + }, generateFirstChunk() { const lines = this.splitContent.splice(0, LINES_PER_CHUNK); this.firstChunk = this.createChunk(lines); @@ -112,7 +141,7 @@ export default { let detectedLanguage = language; let highlightedContent; if (this.hljs) { - registerPlugins(this.hljs); + registerPlugins(this.hljs, this.blob.fileType, this.content); if (!detectedLanguage) { const hljsHighlightAuto = this.hljs.highlightAuto(content); highlightedContent = hljsHighlightAuto.value; diff --git a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue index 20a666509a4..779a2ab5461 100644 --- a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue +++ b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue @@ -1,7 +1,6 @@ <script> import { GlSkeletonLoader } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__ } from '~/locale'; export default { name: 'UsageBanner', @@ -15,13 +14,6 @@ export default { default: false, }, }, - i18n: { - dependencyProxy: s__('UsageQuota|Dependency proxy'), - storageUsed: s__('UsageQuota|Storage used'), - dependencyProxyMessage: s__( - 'UsageQuota|Local proxy used for frequently-accessed upstream Docker images. %{linkStart}More information%{linkEnd}', - ), - }, storageUsageQuotaHelpPage: helpPagePath('user/usage_quotas'), }; </script> @@ -33,13 +25,21 @@ export default { > <div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"> <div - v-if="$slots['left-primary-text']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'left-primary-text' + ] + " class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0" > <slot name="left-primary-text"></slot> </div> <div - v-if="$slots['left-secondary-text']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'left-secondary-text' + ] + " class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1 gl-w-70p gl-md-max-w-70p" > <slot name="left-secondary-text"></slot> @@ -49,13 +49,21 @@ export default { class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0" > <div - v-if="$slots['right-primary-text']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'right-primary-text' + ] + " class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6" > <slot name="right-primary-text"></slot> </div> <div - v-if="$slots['right-secondary-text']" + v-if=" + /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ + 'right-secondary-text' + ] + " class="gl-display-flex gl-align-items-center gl-min-h-6" > <slot v-if="!loading" name="right-secondary-text"></slot> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue index c58a5357883..707b0bbec67 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue @@ -96,7 +96,10 @@ export default { /> <gl-tooltip - v-if="tooltipText || $slots.default" + v-if=" + tooltipText || + $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ + " :target="() => $refs.userAvatar.$el" :placement="tooltipPlacement" boundary="window" diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue index 15ba8e3b39b..6e8c200d5c3 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue @@ -100,7 +100,10 @@ export default { class="avatar" /> <gl-tooltip - v-if="tooltipText || $slots.default" + v-if=" + tooltipText || + $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ + " :target="() => $refs.userAvatarImage" :placement="tooltipPlacement" boundary="window" diff --git a/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue index 121c3bd94ef..ab5ddbc8af8 100644 --- a/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue +++ b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue @@ -56,7 +56,13 @@ import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.que * - shouldShowCallout: boolean * - A combination of the above which should cover 95% of use cases: `true` * if the query has loaded without error, and the user is logged in, and - * the callout has not been dismissed yet; `false` otherwise. + * the callout has not been dismissed yet; `false` otherwise + * + * The component emits a `queryResult` event when the GraphQL query + * completes. The payload is a combination of the ApolloQueryResult object and + * this component's `slotProps` computed property. This is useful for things + * like cleaning up/unmounting the component if the callout shouldn't be + * displayed. */ export default { name: 'UserCalloutDismisser', @@ -86,6 +92,9 @@ export default { update(data) { return data?.currentUser; }, + result(data) { + this.$emit('queryResult', { ...data, ...this.slotProps }); + }, error(err) { this.queryError = err; }, diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 768cd005727..a0d8ca117a4 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -7,13 +7,13 @@ import { GlSafeHtmlDirective, GlSprintf, GlButton, + GlAvatarLabeled, } from '@gitlab/ui'; import { __ } from '~/locale'; -import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import { glEmojiTag } from '~/emoji'; import createFlash from '~/flash'; import { followUser, unfollowUser } from '~/rest_api'; -import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; +import { isUserBusy } from '~/set_status_modal/utils'; import { USER_POPOVER_DELAY } from './constants'; const MAX_SKELETON_LINES = 4; @@ -22,15 +22,17 @@ export default { name: 'UserPopover', maxSkeletonLines: MAX_SKELETON_LINES, USER_POPOVER_DELAY, + i18n: { + busy: __('Busy'), + }, components: { GlIcon, GlLink, GlPopover, GlSkeletonLoader, - UserAvatarImage, - UserNameWithStatus, GlSprintf, GlButton, + GlAvatarLabeled, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -95,6 +97,15 @@ export default { toggleFollowButtonVariant() { return this.user?.isFollowed ? 'default' : 'confirm'; }, + hasPronouns() { + return Boolean(this.user?.pronouns?.trim()); + }, + isBusy() { + return isUserBusy(this.availabilityStatus); + }, + username() { + return `@${this.user?.username}`; + }, }, methods: { async toggleFollow() { @@ -149,43 +160,46 @@ export default { :placement="placement" boundary="viewport" triggers="hover focus manual" + data-testid="user-popover" > - <div class="gl-py-3 gl-line-height-normal gl-display-flex" data-testid="user-popover"> - <div class="gl-mr-4 gl-flex-shrink-0"> - <user-avatar-image :img-src="user.avatarUrl" :size="64" css-classes="gl-m-0!" /> + <div class="gl-mb-3"> + <div v-if="userIsLoading" class="gl-w-20"> + <gl-skeleton-loader :width="160" :height="64"> + <rect x="70" y="19" rx="3" ry="3" width="88" height="9" /> + <rect x="70" y="36" rx="3" ry="3" width="64" height="8" /> + <circle cx="32" cy="32" r="32" /> + </gl-skeleton-loader> </div> - <div class="gl-w-full gl-word-break-word gl-display-flex gl-align-items-center"> - <template v-if="userIsLoading"> - <gl-skeleton-loader - :lines="$options.maxSkeletonLines" - preserve-aspect-ratio="none" - equal-width-lines - :height="52" - /> - </template> - <template v-else> - <div> - <h5 class="gl-m-0"> - <user-name-with-status - :name="user.name" - :availability="availabilityStatus" - :pronouns="user.pronouns" - /> - </h5> - <span class="gl-text-gray-500">@{{ user.username }}</span> - <div v-if="shouldRenderToggleFollowButton" class="gl-mt-3"> - <gl-button - :variant="toggleFollowButtonVariant" - :loading="toggleFollowLoading" - size="small" - data-testid="toggle-follow-button" - @click="toggleFollow" - >{{ toggleFollowButtonText }}</gl-button - > - </div> - </div> + <gl-avatar-labeled + v-else + :size="64" + :src="user.avatarUrl" + :label="user.name" + :sub-label="username" + > + <gl-button + v-if="shouldRenderToggleFollowButton" + class="gl-mt-3 gl-align-self-start" + :variant="toggleFollowButtonVariant" + :loading="toggleFollowLoading" + size="small" + data-testid="toggle-follow-button" + @click="toggleFollow" + >{{ toggleFollowButtonText }}</gl-button + > + + <template #meta> + <span + v-if="hasPronouns" + class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1" + data-testid="user-popover-pronouns" + >({{ user.pronouns }})</span + > + <span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1" + >({{ $options.i18n.busy }})</span + > </template> - </div> + </gl-avatar-labeled> </div> <div class="gl-mt-2 gl-w-full gl-word-break-word"> <template v-if="userIsLoading"> diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue index eff39e2fb89..4ef9bc07b1c 100644 --- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue +++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue @@ -15,7 +15,7 @@ export default { }, }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.(); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue index 89eecea5239..25799171905 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue @@ -81,7 +81,8 @@ export default { ref="textarea" v-model="issuableDescription" dir="auto" - class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area" + class="note-textarea rspec-issuable-form-description js-gfm-input js-autosize markdown-area" + data-qa-selector="issuable_form_description_field" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" ></textarea> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index a9f8caa3e1f..b616b390032 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -86,7 +86,18 @@ export default { createdAt() { return getTimeago().format(this.issuable.createdAt); }, - updatedAt() { + timestamp() { + if (this.issuable.state === 'closed' && this.issuable.closedAt) { + return this.issuable.closedAt; + } + return this.issuable.updatedAt; + }, + formattedTimestamp() { + if (this.issuable.state === 'closed' && this.issuable.closedAt) { + return sprintf(__('closed %{timeago}'), { + timeago: getTimeago().format(this.issuable.closedAt), + }); + } return sprintf(__('updated %{timeAgo}'), { timeAgo: getTimeago().format(this.issuable.updatedAt), }); @@ -134,6 +145,7 @@ export default { }, methods: { hasSlotContents(slotName) { + // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots return Boolean(this.$slots[slotName]); }, scopedLabel(label) { @@ -311,10 +323,10 @@ export default { <div v-gl-tooltip.bottom class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block" - :title="tooltipTitle(issuable.updatedAt)" - data-testid="issuable-updated-at" + :title="tooltipTitle(timestamp)" + data-testid="issuable-timestamp" > - {{ updatedAt }} + {{ formattedTimestamp }} </div> </div> </li> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index 8fbf0bb10a0..189bbb56432 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -1,11 +1,13 @@ <script> import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { DEFAULT_SKELETON_COUNT } from '../constants'; +import { DEFAULT_SKELETON_COUNT, PAGE_SIZE_STORAGE_KEY } from '../constants'; import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue'; import IssuableItem from './issuable_item.vue'; import IssuableTabs from './issuable_tabs.vue'; @@ -29,6 +31,8 @@ export default { IssuableBulkEditSidebar, GlPagination, VueDraggable, + PageSizeSelector, + LocalStorageSync, }, props: { namespace: { @@ -173,6 +177,11 @@ export default { required: false, default: false, }, + showPageSizeChangeControls: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -262,7 +271,11 @@ export default { handleVueDraggableUpdate({ newIndex, oldIndex }) { this.$emit('reorder', { newIndex, oldIndex }); }, + handlePageSizeChange(newPageSize) { + this.$emit('page-size-change', newPageSize); + }, }, + PAGE_SIZE_STORAGE_KEY, }; </script> @@ -353,24 +366,38 @@ export default { <slot v-else name="empty-state"></slot> </template> - <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3"> + <div class="gl-text-center gl-mt-6 gl-relative"> <gl-keyset-pagination + v-if="showPaginationControls && useKeysetPagination" :has-next-page="hasNextPage" :has-previous-page="hasPreviousPage" @next="$emit('next-page')" @prev="$emit('previous-page')" /> + <gl-pagination + v-else-if="showPaginationControls" + :per-page="defaultPageSize" + :total-items="totalItems" + :value="currentPage" + :prev-page="previousPage" + :next-page="nextPage" + align="center" + class="gl-pagination gl-mt-3" + @input="$emit('page-change', $event)" + /> + + <local-storage-sync + v-if="showPageSizeChangeControls" + :value="defaultPageSize" + :storage-key="$options.PAGE_SIZE_STORAGE_KEY" + @input="handlePageSizeChange" + > + <page-size-selector + :value="defaultPageSize" + class="gl-absolute gl-right-0" + @input="handlePageSizeChange" + /> + </local-storage-sync> </div> - <gl-pagination - v-else-if="showPaginationControls" - :per-page="defaultPageSize" - :total-items="totalItems" - :value="currentPage" - :prev-page="previousPage" - :next-page="nextPage" - align="center" - class="gl-pagination gl-mt-3" - @input="$emit('page-change', $event)" - /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js index be9afc0610d..507f333a34e 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/constants.js +++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js @@ -56,3 +56,5 @@ export const IssuableTypes = { export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_SKELETON_COUNT = 5; + +export const PAGE_SIZE_STORAGE_KEY = 'issuable_list_page_size'; diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue index f57b5b2deb4..d4e9120ff17 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue @@ -37,7 +37,11 @@ export default { </script> <template> - <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }"> + <div + class="description" + :class="{ 'js-task-list-container': canEdit && enableTaskList }" + data-qa-selector="description_content" + > <div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div> <textarea v-if="issuable.description && enableTaskList" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue index 33dca3e9332..2fc1f935501 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue @@ -123,7 +123,6 @@ export default { :placeholder="__('Title')" :aria-label="__('Title')" :autofocus="true" - class="qa-title-input" @keydown="handleKeydown($event, 'title')" /> </gl-form-group> @@ -149,7 +148,7 @@ export default { :data-supports-quick-actions="enableAutocomplete" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" - class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" + class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" @keydown="handleKeydown($event, 'description')" ></textarea> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index f035795a045..cdc5903b934 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -112,7 +112,7 @@ export default { <gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" /> <span class="gl-display-none gl-sm-display-block"><slot name="status-badge"></slot></span> </gl-badge> - <div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block"> + <div class="issuable-meta gl-display-flex! gl-align-items-center"> <div v-if="blocked || confidential" class="gl-display-inline-block"> <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline"> <gl-icon name="lock" :aria-label="__('Blocked')" /> @@ -139,13 +139,15 @@ export default { :size="24" :src="author.avatarUrl" :label="author.name" - class="d-none d-sm-inline-flex gl-mx-1" + :class="[{ 'gl-display-none': !isAuthorExternal }, 'gl-sm-display-inline-flex gl-mx-1']" > <template #meta> - <gl-icon v-if="isAuthorExternal" name="external-link" /> + <gl-icon v-if="isAuthorExternal" name="external-link" class="gl-ml-1" /> </template> </gl-avatar-labeled> - <strong class="author d-sm-none d-inline">@{{ author.username }}</strong> + <strong v-if="author.username" class="author gl-display-inline gl-sm-display-none!" + >@{{ author.username }}</strong + > </gl-avatar-link> <span v-if="taskCompletionStatus && hasTasks" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index 3d7c71ce974..35124bd15d2 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -64,8 +64,9 @@ export default { <div class="title-container"> <h1 v-safe-html="issuable.titleHtml || issuable.title" - class="title qa-title gl-font-size-h-display" + class="title gl-font-size-h-display" dir="auto" + data-qa-selector="title_content" data-testid="title" ></h1> <gl-button @@ -74,7 +75,7 @@ export default { :title="$options.i18n.editTitleAndDescription" :aria-label="$options.i18n.editTitleAndDescription" icon="pencil" - class="btn-edit js-issuable-edit qa-edit-button" + class="btn-edit js-issuable-edit" @click="$emit('edit-issuable', $event)" /> </div> |