diff options
Diffstat (limited to 'app/assets')
700 files changed, 10971 insertions, 6050 deletions
diff --git a/app/assets/images/auth_buttons/alicloud_64.png b/app/assets/images/auth_buttons/alicloud_64.png Binary files differnew file mode 100644 index 00000000000..bd67a199e13 --- /dev/null +++ b/app/assets/images/auth_buttons/alicloud_64.png diff --git a/app/assets/images/checkmark.png b/app/assets/images/checkmark.png Binary files differdeleted file mode 100644 index 6e47fda5cdc..00000000000 --- a/app/assets/images/checkmark.png +++ /dev/null diff --git a/app/assets/images/learn_gitlab/graduation_hat.svg b/app/assets/images/learn_gitlab/graduation_hat.svg deleted file mode 100644 index 998d8d9b935..00000000000 --- a/app/assets/images/learn_gitlab/graduation_hat.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="16" height="17" xmlns="http://www.w3.org/2000/svg"><path fill="#fffff" d="M1.53 7.639l-.476.88.476-.88zm0-1.758L1.054 5l.476.88zm2.257 2.982h1v-.596l-.523-.283-.477.879zm8.424 0l-.476-.88-.524.284v.596h1zm2.257-1.224l.477.88-.477-.88zm0-1.758l-.476.879.476-.88zM8.476 2.632l-.477.88.477-.88zm-.953 0l.476.88-.476-.88zM2.007 6.76l-.953-1.758c-1.396.756-1.396 2.76 0 3.516l.953-1.758zm2.257 1.224L2.007 6.76l-.953 1.758L3.31 9.742l.953-1.758zm.523 1.995V8.863h-2v1.116h2zM8 12.5c-1.949 0-3.212-1.289-3.212-2.52h-2c0 2.656 2.51 4.52 5.212 4.52v-2zm3.212-2.52c0 1.231-1.262 2.52-3.212 2.52v2c2.704 0 5.212-1.864 5.212-4.52h-2zm0-1.117v1.116h2V8.863h-2zm2.78-2.103l-2.256 1.223.953 1.759 2.257-1.224-.953-1.758zm0 0l.954 1.758c1.396-.757 1.396-2.76 0-3.516l-.953 1.758zM8 3.51l5.993 3.249.953-1.758-5.993-3.249L8 3.511zm0 0l.953-1.758a2 2 0 00-1.906 0L8 3.511zM2.007 6.76l5.992-3.25-.953-1.758-5.992 3.249.953 1.758z"/><path fill="#fffff" d="M7.228 7.541c-.187-.112-.277-.427-.201-.704.076-.276.288-.41.475-.297L11 8.644v5.316c0 .298-.163.54-.365.54-.2 0-.364-.242-.364-.54V9.37L7.228 7.54z"/></svg>
\ No newline at end of file diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql index cdc8a952ead..a5fc70b9ca6 100644 --- a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql +++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query accessTokensGetProjects( $search: String = "" diff --git a/app/assets/javascripts/admin/topics/components/remove_avatar.vue b/app/assets/javascripts/admin/topics/components/remove_avatar.vue index 5e94d6185e0..a54c30a8336 100644 --- a/app/assets/javascripts/admin/topics/components/remove_avatar.vue +++ b/app/assets/javascripts/admin/topics/components/remove_avatar.vue @@ -1,6 +1,6 @@ <script> import { uniqueId } from 'lodash'; -import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; import csrf from '~/lib/utils/csrf'; @@ -8,11 +8,12 @@ export default { components: { GlButton, GlModal, + GlSprintf, }, directives: { GlModal: GlModalDirective, }, - inject: ['path'], + inject: ['path', 'name'], data() { return { modalId: uniqueId('remove-topic-avatar-'), @@ -25,8 +26,8 @@ export default { }, i18n: { remove: __('Remove avatar'), - title: __('Confirm remove avatar'), - body: __('Avatar will be removed. Are you sure?'), + title: __('Remove topic avatar'), + body: __('Topic avatar for %{name} will be removed. This cannot be undone.'), }, modal: { actionPrimary: { @@ -57,7 +58,9 @@ export default { :modal-id="modalId" size="sm" @primary="deleteApplication" - >{{ $options.i18n.body }} + ><gl-sprintf :message="$options.i18n.body" + ><template #name>{{ name }}</template></gl-sprintf + > <form ref="deleteForm" method="post" :action="path"> <input type="hidden" name="_method" value="delete" /> <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js index 8fbcadf3369..09e9b20f220 100644 --- a/app/assets/javascripts/admin/topics/index.js +++ b/app/assets/javascripts/admin/topics/index.js @@ -8,12 +8,13 @@ export default () => { return false; } - const { path } = el.dataset; + const { path, name } = el.dataset; return new Vue({ el, provide: { path, + name, }, render(h) { return h(RemoveAvatar); diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue index e6dde5898e7..ae0c6731271 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete.vue @@ -1,9 +1,11 @@ <script> -import SharedDeleteAction from './shared/shared_delete_action.vue'; +import { GlDropdownItem } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub'; export default { components: { - SharedDeleteAction, + GlDropdownItem, }, props: { username: { @@ -20,17 +22,32 @@ export default { default: () => [], }, }, + methods: { + onClick() { + const { username, paths, userDeletionObstacles } = this; + eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, { + username, + blockPath: paths.block, + deletePath: paths.delete, + userDeletionObstacles, + i18n: { + title: s__('AdminUsers|Delete User %{username}?'), + primaryButtonLabel: s__('AdminUsers|Delete user'), + messageBody: s__(`AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests, + and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss, + consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd}, + it cannot be undone or recovered.`), + }, + }); + }, + }, }; </script> <template> - <shared-delete-action - modal-type="delete" - :username="username" - :paths="paths" - :delete-path="paths.delete" - :user-deletion-obstacles="userDeletionObstacles" - > - <slot></slot> - </shared-delete-action> + <gl-dropdown-item @click="onClick"> + <span class="gl-text-red-500"> + <slot></slot> + </span> + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue index bd920a91516..a39df1cbfb6 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue @@ -1,9 +1,11 @@ <script> -import SharedDeleteAction from './shared/shared_delete_action.vue'; +import { GlDropdownItem } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub'; export default { components: { - SharedDeleteAction, + GlDropdownItem, }, props: { username: { @@ -20,17 +22,32 @@ export default { default: () => [], }, }, + methods: { + onClick() { + const { username, paths, userDeletionObstacles } = this; + eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, { + username, + blockPath: paths.block, + deletePath: paths.deleteWithContributions, + userDeletionObstacles, + i18n: { + title: s__('AdminUsers|Delete User %{username} and contributions?'), + primaryButtonLabel: s__('AdminUsers|Delete user and contributions'), + messageBody: s__(`AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues, + merge requests, and groups linked to them. To avoid data loss, + consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd}, + it cannot be undone or recovered.`), + }, + }); + }, + }, }; </script> <template> - <shared-delete-action - modal-type="delete-with-contributions" - :username="username" - :paths="paths" - :delete-path="paths.deleteWithContributions" - :user-deletion-obstacles="userDeletionObstacles" - > - <slot></slot> - </shared-delete-action> + <gl-dropdown-item @click="onClick"> + <span class="gl-text-red-500"> + <slot></slot> + </span> + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue deleted file mode 100644 index c9f29b55dbf..00000000000 --- a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue +++ /dev/null @@ -1,52 +0,0 @@ -<script> -import { GlDropdownItem } from '@gitlab/ui'; - -export default { - components: { - GlDropdownItem, - }, - props: { - username: { - type: String, - required: true, - }, - paths: { - type: Object, - required: true, - }, - deletePath: { - type: String, - required: true, - }, - modalType: { - type: String, - required: true, - }, - userDeletionObstacles: { - type: Array, - required: true, - }, - }, - computed: { - modalAttributes() { - return { - 'data-block-user-url': this.paths.block, - 'data-delete-user-url': this.deletePath, - 'data-gl-modal-action': this.modalType, - 'data-username': this.username, - 'data-user-deletion-obstacles': JSON.stringify(this.userDeletionObstacles), - }; - }, - }, -}; -</script> - -<template> - <div class="js-delete-user-modal-button" v-bind="{ ...modalAttributes }"> - <gl-dropdown-item> - <span class="gl-text-red-500"> - <slot></slot> - </span> - </gl-dropdown-item> - </div> -</template> diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue index d7c08096376..31fe86775ee 100644 --- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue +++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue @@ -1,8 +1,8 @@ <script> import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { s__, sprintf } from '~/locale'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; +import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from './delete_user_modal_event_hub'; export default { components: { @@ -13,47 +13,23 @@ export default { UserDeletionObstaclesList, }, props: { - title: { - type: String, - required: true, - }, - content: { - type: String, - required: true, - }, - action: { - type: String, - required: true, - }, - secondaryAction: { - type: String, - required: true, - }, - deleteUserUrl: { - type: String, - required: true, - }, - blockUserUrl: { - type: String, - required: true, - }, - username: { - type: String, - required: true, - }, csrfToken: { type: String, required: true, }, - userDeletionObstacles: { - type: String, - required: false, - default: '[]', - }, }, data() { return { enteredUsername: '', + username: '', + blockPath: '', + deletePath: '', + userDeletionObstacles: [], + i18n: { + title: '', + primaryButtonLabel: '', + messageBody: '', + }, }; }, computed: { @@ -61,75 +37,80 @@ export default { return this.username.trim(); }, modalTitle() { - return sprintf(this.title, { username: this.trimmedUsername }, false); - }, - secondaryButtonLabel() { - return s__('AdminUsers|Block user'); + return sprintf(this.i18n.title, { username: this.trimmedUsername }, false); }, canSubmit() { - return this.enteredUsername === this.trimmedUsername; + return this.enteredUsername && this.enteredUsername === this.trimmedUsername; }, - obstacles() { - try { - return JSON.parse(this.userDeletionObstacles); - } catch (e) { - Sentry.captureException(e); - } - return []; + secondaryButtonLabel() { + return s__('AdminUsers|Block user'); }, }, + mounted() { + eventHub.$on(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent); + }, + destroyed() { + eventHub.$off(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent); + }, methods: { - show() { + onOpenEvent({ username, blockPath, deletePath, userDeletionObstacles, i18n }) { + this.username = username; + this.blockPath = blockPath; + this.deletePath = deletePath; + this.userDeletionObstacles = userDeletionObstacles; + this.i18n = i18n; + this.openModal(); + }, + openModal() { this.$refs.modal.show(); }, + onSubmit() { + this.$refs.form.submit(); + this.enteredUsername = ''; + }, onCancel() { this.enteredUsername = ''; this.$refs.modal.hide(); }, onSecondaryAction() { const { form } = this.$refs; - - form.action = this.blockUserUrl; + form.action = this.blockPath; this.$refs.method.value = 'put'; - form.submit(); }, - onSubmit() { - this.$refs.form.submit(); - this.enteredUsername = ''; - }, }, }; </script> - <template> <gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger"> <p> - <gl-sprintf :message="content"> + <gl-sprintf :message="i18n.messageBody"> <template #username> - <strong>{{ trimmedUsername }}</strong> + <strong data-testid="message-username">{{ trimmedUsername }}</strong> </template> - <template #strong="props"> - <strong>{{ props.content }}</strong> + <template #strong="{ content }"> + <strong>{{ content }}</strong> </template> </gl-sprintf> </p> <user-deletion-obstacles-list - v-if="obstacles.length" - :obstacles="obstacles" + v-if="userDeletionObstacles.length" + :obstacles="userDeletionObstacles" :user-name="trimmedUsername" /> <p> <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> <template #username> - <code class="gl-white-space-pre-wrap">{{ trimmedUsername }}</code> + <code data-testid="confirm-username" class="gl-white-space-pre-wrap">{{ + trimmedUsername + }}</code> </template> </gl-sprintf> </p> - <form ref="form" :action="deleteUserUrl" method="post" @submit.prevent> + <form ref="form" :action="deletePath" method="post" @submit.prevent> <input ref="method" type="hidden" name="_method" value="delete" /> <input :value="csrfToken" type="hidden" name="authenticity_token" /> <gl-form-input @@ -140,6 +121,7 @@ export default { autocomplete="off" /> </form> + <template #modal-footer> <gl-button @click="onCancel">{{ __('Cancel') }}</gl-button> <gl-button @@ -148,10 +130,10 @@ export default { variant="danger" @click="onSecondaryAction" > - {{ secondaryAction }} + {{ secondaryButtonLabel }} </gl-button> <gl-button :disabled="!canSubmit" category="primary" variant="danger" @click="onSubmit">{{ - action + i18n.primaryButtonLabel }}</gl-button> </template> </gl-modal> diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js b/app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js new file mode 100644 index 00000000000..001061dcc6b --- /dev/null +++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js @@ -0,0 +1,5 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); + +export const EVENT_OPEN_DELETE_USER_MODAL = Symbol('OPEN'); diff --git a/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue b/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue deleted file mode 100644 index 1dfea3f1e7b..00000000000 --- a/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue +++ /dev/null @@ -1,77 +0,0 @@ -<script> -import DeleteUserModal from './delete_user_modal.vue'; - -export default { - components: { DeleteUserModal }, - props: { - modalConfiguration: { - required: true, - type: Object, - }, - csrfToken: { - required: true, - type: String, - }, - selector: { - required: true, - type: String, - }, - }, - data() { - return { - currentModalData: null, - }; - }, - computed: { - activeModal() { - return Boolean(this.currentModalData); - }, - - modalProps() { - const { glModalAction: requestedAction } = this.currentModalData; - return { - ...this.modalConfiguration[requestedAction], - ...this.currentModalData, - csrfToken: this.csrfToken, - }; - }, - }, - - mounted() { - /* - * Here we're looking for every button that needs to launch a modal - * on click, and then attaching a click event handler to show the modal - * if it's correctly configured. - * - * TODO: Replace this with integrated modal components https://gitlab.com/gitlab-org/gitlab/-/issues/320922 - */ - document.querySelectorAll(this.selector).forEach((button) => { - button.addEventListener('click', (e) => { - if (!button.dataset.glModalAction) return; - - e.preventDefault(); - this.show(button.dataset); - }); - }); - }, - - methods: { - show(modalData) { - const { glModalAction: requestedAction } = modalData; - - if (!this.modalConfiguration[requestedAction]) { - throw new Error(`Modal action ${requestedAction} has no configuration in HTML`); - } - - this.currentModalData = modalData; - - return this.$nextTick().then(() => { - this.$refs.modal.show(); - }); - }, - }, -}; -</script> -<template> - <delete-user-modal v-if="activeModal" ref="modal" v-bind="modalProps" /> -</template> diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js index 4636c8705a5..9cd61d6b1db 100644 --- a/app/assets/javascripts/admin/users/constants.js +++ b/app/assets/javascripts/admin/users/constants.js @@ -20,9 +20,3 @@ export const I18N_USER_ACTIONS = { ban: s__('AdminUsers|Ban user'), unban: s__('AdminUsers|Unban user'), }; - -export const CONFIRM_DELETE_BUTTON_SELECTOR = '.js-delete-user-modal-button'; - -export const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts'; - -export const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal'; diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js index 0c485d2a239..2bd37d3fffe 100644 --- a/app/assets/javascripts/admin/users/index.js +++ b/app/assets/javascripts/admin/users/index.js @@ -4,13 +4,8 @@ import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import csrf from '~/lib/utils/csrf'; import AdminUsersApp from './components/app.vue'; -import ModalManager from './components/modals/user_modal_manager.vue'; +import DeleteUserModal from './components/modals/delete_user_modal.vue'; import UserActions from './components/user_actions.vue'; -import { - CONFIRM_DELETE_BUTTON_SELECTOR, - MODAL_TEXTS_CONTAINER_SELECTOR, - MODAL_MANAGER_SELECTOR, -} from './constants'; Vue.use(VueApollo); @@ -46,43 +41,13 @@ export const initAdminUserActions = (el = document.querySelector('#js-admin-user initApp(el, UserActions, 'user', { showButtonLabels: true }); export const initDeleteUserModals = () => { - const modalsMountElement = document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR); - - if (!modalsMountElement) { - return; - } - - const modalConfiguration = Array.from(modalsMountElement.children).reduce((accumulator, node) => { - const { modal, ...config } = node.dataset; - - return { - ...accumulator, - [modal]: { - title: node.dataset.title, - ...config, - content: node.innerHTML, - }, - }; - }, {}); - - // eslint-disable-next-line no-new - new Vue({ - el: MODAL_MANAGER_SELECTOR, + return new Vue({ functional: true, - methods: { - show(...args) { - this.$refs.manager.show(...args); - }, - }, - render(h) { - return h(ModalManager, { - ref: 'manager', + render: (createElement) => + createElement(DeleteUserModal, { props: { - selector: CONFIRM_DELETE_BUTTON_SELECTOR, - modalConfiguration, csrfToken: csrf.token, }, - }); - }, - }); + }), + }).$mount(); }; diff --git a/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue b/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue index 400326e41e1..b9501107e37 100644 --- a/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue +++ b/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue @@ -43,7 +43,7 @@ export default { {{ s__('ServicePing|Turn on service ping to review instance-level analytics.') }} </p> - <gl-button category="primary" variant="success" :href="primaryButtonPath"> + <gl-button category="primary" variant="confirm" :href="primaryButtonPath"> {{ s__('ServicePing|Turn on service ping') }} </gl-button> </template> diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue index b3ae671d611..b2b033de75d 100644 --- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -11,6 +11,7 @@ import { import { debounce } from 'lodash'; import { filterBySearchTerm } from '~/analytics/shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { n__, s__, __ } from '~/locale'; import getProjects from '../graphql/projects.query.graphql'; @@ -204,6 +205,7 @@ export default { return getIdFromGraphQLId(project.id); }, }, + AVATAR_SHAPE_OPTION_RECT, }; </script> <template> @@ -227,7 +229,7 @@ export default { :entity-id="getEntityId(selectedProjects[0])" :entity-name="selectedProjects[0].name" :size="16" - shape="rect" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" :alt="selectedProjects[0].name" class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2" /> @@ -255,7 +257,7 @@ export default { :entity-id="getEntityId(project)" :entity-name="project.name" :src="project.avatarUrl" - shape="rect" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> <div> <div data-testid="project-name">{{ project.name }}</div> @@ -279,7 +281,7 @@ export default { :entity-id="getEntityId(project)" :entity-name="project.name" :src="project.avatarUrl" - shape="rect" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> <div> <div data-testid="project-name">{{ project.name }}</div> diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js index dde429ab278..71b7ca29bad 100644 --- a/app/assets/javascripts/analytics/shared/utils.js +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -19,6 +19,7 @@ export const toYmd = (date) => dateFormat(date, dateFormats.isoDate); * @returns {Object} */ export const extractFilterQueryParameters = (url = '') => { + /* eslint-disable camelcase */ const { source_branch_name = null, target_branch_name = null, @@ -27,6 +28,7 @@ export const extractFilterQueryParameters = (url = '') => { assignee_username = [], label_name = [], } = urlQueryToFilter(url); + /* eslint-enable camelcase */ return { selectedSourceBranch: source_branch_name, diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql index 2a5546efb68..f9311626cc3 100644 --- a/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" #import "../fragments/count.fragment.graphql" query getCount($identifier: MeasurementIdentifier!, $first: Int, $after: String) { diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql index 7c02ac49a42..d7638458b03 100644 --- a/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" #import "../fragments/count.fragment.graphql" query getUsersCount($first: Int, $after: String) { diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 35fc64d43e5..64812e52849 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -42,6 +42,7 @@ const Api = { projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', projectRunnersPath: '/api/:version/projects/:id/runners', projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', + projectProtectedBranchesNamePath: '/api/:version/projects/:id/protected_branches/:name', projectSearchPath: '/api/:version/projects/:id/search', projectSharePath: '/api/:version/projects/:id/share', projectMilestonesPath: '/api/:version/projects/:id/milestones', @@ -93,6 +94,7 @@ const Api = { notificationSettingsPath: '/api/:version/notification_settings', deployKeysPath: '/api/:version/deploy_keys', secureFilesPath: '/api/:version/projects/:project_id/secure_files', + dependencyProxyPath: '/api/:version/groups/:id/dependency_proxy/cache', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -154,13 +156,7 @@ const Api = { }); }, - addGroupMembersByUserId(id, data) { - const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); - - return axios.post(url, data); - }, - - inviteGroupMembersByEmail(id, data) { + inviteGroupMembers(id, data) { const url = Api.buildUrl(this.groupInvitationsPath).replace(':id', encodeURIComponent(id)); return axios.post(url, data); @@ -234,7 +230,7 @@ const Api = { return axios .get(url, { - params: Object.assign(defaults, options), + params: { ...defaults, ...options }, }) .then(({ data, headers }) => { callback(data); @@ -256,13 +252,7 @@ const Api = { .then(({ data }) => data); }, - addProjectMembersByUserId(id, data) { - const url = Api.buildUrl(this.projectMembersPath).replace(':id', encodeURIComponent(id)); - - return axios.post(url, data); - }, - - inviteProjectMembersByEmail(id, data) { + inviteProjectMembers(id, data) { const url = Api.buildUrl(this.projectInvitationsPath).replace(':id', encodeURIComponent(id)); return axios.post(url, data); @@ -371,6 +361,14 @@ const Api = { .then(({ data }) => data); }, + projectProtectedBranch(id, branchName) { + const url = Api.buildUrl(Api.projectProtectedBranchesNamePath) + .replace(':id', encodeURIComponent(id)) + .replace(':name', branchName); + + return axios.get(url).then(({ data }) => data); + }, + projectSearch(id, options = {}) { const url = Api.buildUrl(Api.projectSearchPath).replace(':id', encodeURIComponent(id)); @@ -445,7 +443,7 @@ const Api = { }, // Return group projects list. Filtered by query - groupProjects(groupId, query, options, callback) { + groupProjects(groupId, query, options, callback = () => {}, useCustomErrorHandler = false) { const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); const defaults = { search: query, @@ -455,14 +453,21 @@ const Api = { .get(url, { params: { ...defaults, ...options }, }) - .then(({ data }) => (callback ? callback(data) : data)) - .catch(() => { + .then(({ data, headers }) => { + callback(data); + + return { data, headers }; + }) + .catch((error) => { + if (useCustomErrorHandler) { + throw error; + } + createFlash({ message: __('Something went wrong while fetching projects'), }); - if (callback) { - callback(); - } + + callback(); }); }, @@ -992,6 +997,12 @@ const Api = { return result; }, + + deleteDependencyProxyCacheList(groupId, options = {}) { + const url = Api.buildUrl(this.dependencyProxyPath).replace(':id', groupId); + + return axios.delete(url, { params: { ...options } }); + }, }; export default Api; diff --git a/app/assets/javascripts/api/alert_management_alerts_api.js b/app/assets/javascripts/api/alert_management_alerts_api.js new file mode 100644 index 00000000000..fa66ca5b3dd --- /dev/null +++ b/app/assets/javascripts/api/alert_management_alerts_api.js @@ -0,0 +1,62 @@ +import axios from '~/lib/utils/axios_utils'; +import { buildApiUrl } from '~/api/api_utils'; +import { ContentTypeMultipartFormData } from '~/lib/utils/headers'; + +const ALERT_METRIC_IMAGES_PATH = + '/api/:version/projects/:id/alert_management_alerts/:alert_iid/metric_images'; +const ALERT_SINGLE_METRIC_IMAGE_PATH = + '/api/:version/projects/:id/alert_management_alerts/:alert_iid/metric_images/:image_id'; + +export function fetchAlertMetricImages({ alertIid, id }) { + const metricImagesUrl = buildApiUrl(ALERT_METRIC_IMAGES_PATH) + .replace(':id', encodeURIComponent(id)) + .replace(':alert_iid', encodeURIComponent(alertIid)); + + return axios.get(metricImagesUrl); +} + +export function uploadAlertMetricImage({ alertIid, id, file, url = null, urlText = null }) { + const options = { headers: { ...ContentTypeMultipartFormData } }; + const metricImagesUrl = buildApiUrl(ALERT_METRIC_IMAGES_PATH) + .replace(':id', encodeURIComponent(id)) + .replace(':alert_iid', encodeURIComponent(alertIid)); + + // Construct multipart form data + const formData = new FormData(); + formData.append('file', file); + if (url) { + formData.append('url', url); + } + if (urlText) { + formData.append('url_text', urlText); + } + + return axios.post(metricImagesUrl, formData, options); +} + +export function updateAlertMetricImage({ alertIid, id, imageId, url = null, urlText = null }) { + const metricImagesUrl = buildApiUrl(ALERT_SINGLE_METRIC_IMAGE_PATH) + .replace(':id', encodeURIComponent(id)) + .replace(':alert_iid', encodeURIComponent(alertIid)) + .replace(':image_id', encodeURIComponent(imageId)); + + // Construct multipart form data + const formData = new FormData(); + if (url != null) { + formData.append('url', url); + } + if (urlText != null) { + formData.append('url_text', urlText); + } + + return axios.put(metricImagesUrl, formData); +} + +export function deleteAlertMetricImage({ alertIid, id, imageId }) { + const individualMetricImageUrl = buildApiUrl(ALERT_SINGLE_METRIC_IMAGE_PATH) + .replace(':id', encodeURIComponent(id)) + .replace(':alert_iid', encodeURIComponent(alertIid)) + .replace(':image_id', encodeURIComponent(imageId)); + + return axios.delete(individualMetricImageUrl); +} diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js index b018db9a02d..7666f558eb5 100644 --- a/app/assets/javascripts/api/projects_api.js +++ b/app/assets/javascripts/api/projects_api.js @@ -2,6 +2,8 @@ import { DEFAULT_PER_PAGE } from '~/api'; import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; +export * from './alert_management_alerts_api'; + const PROJECTS_PATH = '/api/:version/projects.json'; const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id'; diff --git a/app/assets/javascripts/attention_requests/components/navigation_popover.vue b/app/assets/javascripts/attention_requests/components/navigation_popover.vue index 1542bc9a7e9..804eda8f321 100644 --- a/app/assets/javascripts/attention_requests/components/navigation_popover.vue +++ b/app/assets/javascripts/attention_requests/components/navigation_popover.vue @@ -82,7 +82,9 @@ export default { return 'bottom'; }, }, - docsPage: helpPagePath('development/code_review.html'), + docsPage: helpPagePath('user/project/merge_requests/index.md', { + anchor: 'request-attention-to-a-merge-request', + }), }; </script> diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 8fd03d3132d..29204020058 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -1,10 +1,4 @@ -import { - initEmojiMap, - getEmojiInfo, - emojiFallbackImageSrc, - emojiImageTag, - FALLBACK_EMOJI_KEY, -} from '../emoji'; +import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji'; import isEmojiUnicodeSupported from '../emoji/support'; class GlEmoji extends HTMLElement { @@ -22,10 +16,6 @@ class GlEmoji extends HTMLElement { if (emojiInfo) { if (name !== emojiInfo.name) { - if (emojiInfo.name === FALLBACK_EMOJI_KEY && this.innerHTML) { - return; // When fallback emoji is used, but there is a <img> provided, use the <img> instead - } - ({ name } = emojiInfo); this.dataset.name = emojiInfo.name; } @@ -43,34 +33,29 @@ class GlEmoji extends HTMLElement { this.childNodes && Array.prototype.every.call(this.childNodes, (childNode) => childNode.nodeType === 3); - if ( - emojiUnicode && - isEmojiUnicode && - !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion) - ) { - const hasImageFallback = fallbackSrc && fallbackSrc.length > 0; - const hasCssSpriteFallback = fallbackSpriteClass && fallbackSpriteClass.length > 0; + const hasImageFallback = fallbackSrc?.length > 0; + const hasCssSpriteFallback = fallbackSpriteClass?.length > 0; - // CSS sprite fallback takes precedence over image fallback - if (hasCssSpriteFallback) { - if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) { - const emojiSpriteLinkTag = document.createElement('link'); - emojiSpriteLinkTag.setAttribute('rel', 'stylesheet'); - emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path); - document.head.appendChild(emojiSpriteLinkTag); - gon.emoji_sprites_css_added = true; - } - // IE 11 doesn't like adding multiple at once :( - this.classList.add('emoji-icon'); - this.classList.add(fallbackSpriteClass); - } else if (hasImageFallback) { - this.innerHTML = ''; - this.appendChild(emojiImageTag(name, fallbackSrc)); - } else { - const src = emojiFallbackImageSrc(name); - this.innerHTML = ''; - this.appendChild(emojiImageTag(name, src)); + if (emojiUnicode && isEmojiUnicode && isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)) { + // noop + } else if (hasCssSpriteFallback) { + if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) { + const emojiSpriteLinkTag = document.createElement('link'); + emojiSpriteLinkTag.setAttribute('rel', 'stylesheet'); + emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path); + document.head.appendChild(emojiSpriteLinkTag); + gon.emoji_sprites_css_added = true; } + // IE 11 doesn't like adding multiple at once :( + this.classList.add('emoji-icon'); + this.classList.add(fallbackSpriteClass); + } else if (hasImageFallback) { + this.innerHTML = ''; + this.appendChild(emojiImageTag(name, fallbackSrc)); + } else { + const src = emojiFallbackImageSrc(name); + this.innerHTML = ''; + this.appendChild(emojiImageTag(name, src)); } }); } diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 6236e3bdefc..063393c9cd1 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -26,7 +26,7 @@ $.fn.renderGFM = function renderGFM() { const mrPopoverElements = this.find('.gfm-merge_request').get(); if (mrPopoverElements.length) { - import(/* webpackChunkName: 'MrPopoverBundle' */ '../../mr_popover') + import(/* webpackChunkName: 'MrPopoverBundle' */ '~/mr_popover') .then(({ default: initMRPopovers }) => { initMRPopovers(mrPopoverElements); }) diff --git a/app/assets/javascripts/behaviors/markdown/render_kroki.js b/app/assets/javascripts/behaviors/markdown/render_kroki.js index 7843df0cd8e..abe71694d73 100644 --- a/app/assets/javascripts/behaviors/markdown/render_kroki.js +++ b/app/assets/javascripts/behaviors/markdown/render_kroki.js @@ -51,7 +51,7 @@ export function renderKroki(krokiImages) { return; } - const parent = krokiImage.closest('.js-markdown-code'); + const parent = krokiImage.parentElement; // A single Kroki image is processed multiple times for some reason, // so this condition ensures we only create one alert per Kroki image diff --git a/app/assets/javascripts/behaviors/secret_values.js b/app/assets/javascripts/behaviors/secret_values.js index a34d5dcaef8..b6ed14611cd 100644 --- a/app/assets/javascripts/behaviors/secret_values.js +++ b/app/assets/javascripts/behaviors/secret_values.js @@ -1,5 +1,5 @@ -import { parseBoolean } from '../lib/utils/common_utils'; -import { n__ } from '../locale'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { n__ } from '~/locale'; export default class SecretValues { constructor({ diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index e58c51104c5..6124befd3b6 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -4,7 +4,7 @@ import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { isElementVisible } from '~/lib/utils/dom_utils'; import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import Sidebar from '../../right_sidebar'; +import Sidebar from '~/right_sidebar'; import { CopyAsGFM } from '../markdown/copy_as_gfm'; import { keysFor, @@ -33,10 +33,37 @@ export default class ShortcutsIssuable extends Shortcuts { Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), ShortcutsIssuable.replyWithSelectedText); Mousetrap.bind(keysFor(ISSUABLE_EDIT_DESCRIPTION), ShortcutsIssuable.editIssue); Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), ShortcutsIssuable.copyBranchName); + + /** + * We're attaching a global focus event listener on document for + * every markdown input field. + */ + $(document).on( + 'focus', + '.js-vue-markdown-field .js-gfm-input', + ShortcutsIssuable.handleMarkdownFieldFocus, + ); + } + + /** + * This event handler preserves last focused markdown input field. + * @param {Object} event + */ + static handleMarkdownFieldFocus({ currentTarget }) { + ShortcutsIssuable.$lastFocusedReplyField = $(currentTarget); } static replyWithSelectedText() { - const $replyField = $('.js-main-target-form .js-vue-comment-form'); + let $replyField = $('.js-main-target-form .js-vue-comment-form'); + + // Ensure that markdown input is still present in the DOM + // otherwise fall back to main comment input field. + if ( + ShortcutsIssuable.$lastFocusedReplyField && + isElementVisible(ShortcutsIssuable.$lastFocusedReplyField?.get(0)) + ) { + $replyField = ShortcutsIssuable.$lastFocusedReplyField; + } if (!$replyField.length || $replyField.is(':hidden') /* Other tab selected in MR */) { return false; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js index 7d8e4dd490c..e0ef49b60d3 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js @@ -1,6 +1,6 @@ import Mousetrap from 'mousetrap'; import { visitUrl, constructWebIDEPath } from '~/lib/utils/url_utility'; -import findAndFollowLink from '../../lib/utils/navigation_utility'; +import findAndFollowLink from '~/lib/utils/navigation_utility'; import { keysFor, GO_TO_PROJECT_OVERVIEW, diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js index 59c1d2654bc..b2801f9118d 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js @@ -1,5 +1,5 @@ import Mousetrap from 'mousetrap'; -import findAndFollowLink from '../../lib/utils/navigation_utility'; +import findAndFollowLink from '~/lib/utils/navigation_utility'; import { keysFor, EDIT_WIKI_PAGE } from './keybindings'; import ShortcutsNavigation from './shortcuts_navigation'; diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js deleted file mode 100644 index 313bec7e01a..00000000000 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ /dev/null @@ -1,111 +0,0 @@ -import { template as _template } from 'lodash'; -import sqljs from 'sql.js'; -import axios from '~/lib/utils/axios_utils'; -import { successCodes } from '~/lib/utils/http_status'; - -const PREVIEW_TEMPLATE = _template(` - <div class="card"> - <div class="card-header"><%- name %></div> - <div class="card-body"> - <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/> - </div> - </div> -`); - -class BalsamiqViewer { - constructor(viewer) { - this.viewer = viewer; - } - - loadFile(endpoint) { - return axios - .get(endpoint, { - responseType: 'arraybuffer', - validateStatus(status) { - return status !== successCodes.OK; - }, - }) - .then(({ data }) => { - this.renderFile(data); - }) - .catch((e) => { - throw new Error(e); - }); - } - - renderFile(fileBuffer) { - const container = document.createElement('ul'); - - this.initDatabase(fileBuffer); - - const previews = this.getPreviews(); - previews.forEach((preview) => { - const renderedPreview = this.renderPreview(preview); - - container.appendChild(renderedPreview); - }); - - container.classList.add('list-inline'); - container.classList.add('previews'); - - this.viewer.appendChild(container); - } - - initDatabase(data) { - const previewBinary = new Uint8Array(data); - - this.database = new sqljs.Database(previewBinary); - } - - getPreviews() { - const thumbnails = this.database.exec('SELECT * FROM thumbnails'); - - return thumbnails[0].values.map(BalsamiqViewer.parsePreview); - } - - getResource(resourceID) { - const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`); - - return resources[0]; - } - - renderPreview(preview) { - const previewElement = document.createElement('li'); - - previewElement.classList.add('preview'); - previewElement.innerHTML = this.renderTemplate(preview); - - return previewElement; - } - - renderTemplate(preview) { - const resource = this.getResource(preview.resourceID); - const name = BalsamiqViewer.parseTitle(resource); - const { image } = preview; - - const template = PREVIEW_TEMPLATE({ - name, - image, - }); - - return template; - } - - static parsePreview(preview) { - return JSON.parse(preview[1]); - } - - /* - * resource = { - * columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'], - * values: [['id', 'branchId', 'attributes', 'data']], - * } - * - * 'attributes' being a JSON string containing the `name` property. - */ - static parseTitle(resource) { - return JSON.parse(resource.values[0][2]).name; - } -} - -export default BalsamiqViewer; diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js deleted file mode 100644 index af8e8a4cd3d..00000000000 --- a/app/assets/javascripts/blob/balsamiq_viewer.js +++ /dev/null @@ -1,22 +0,0 @@ -import createFlash from '~/flash'; -import { __ } from '~/locale'; -import BalsamiqViewer from './balsamiq/balsamiq_viewer'; - -function onError() { - const flash = createFlash({ - message: __('Balsamiq file could not be loaded.'), - }); - - return flash; -} - -export default function loadBalsamiqFile() { - const viewer = document.getElementById('js-balsamiq-viewer'); - - if (!(viewer instanceof Element)) return; - - const { endpoint } = viewer.dataset; - - const balsamiqViewer = new BalsamiqViewer(viewer); - balsamiqViewer.loadFile(endpoint).catch(onError); -} diff --git a/app/assets/javascripts/blob/line_highlighter.js b/app/assets/javascripts/blob/line_highlighter.js index a1f59aa1b54..a8932f8c73b 100644 --- a/app/assets/javascripts/blob/line_highlighter.js +++ b/app/assets/javascripts/blob/line_highlighter.js @@ -37,6 +37,7 @@ const LineHighlighter = function (options = {}) { options.fileHolderSelector = options.fileHolderSelector || '.file-holder'; options.scrollFileHolder = options.scrollFileHolder || false; options.hash = options.hash || window.location.hash; + options.scrollBehavior = options.scrollBehavior || 'smooth'; this.options = options; this._hash = options.hash; @@ -74,6 +75,7 @@ LineHighlighter.prototype.highlightHash = function (newHash) { // Scroll to the first highlighted line on initial load // Add an offset of -100 for some context offset: -100, + behavior: this.options.scrollBehavior, }); } } diff --git a/app/assets/javascripts/blob/pdf/pdf_viewer.vue b/app/assets/javascripts/blob/pdf/pdf_viewer.vue index a1a62abeb6f..e07e415d6cf 100644 --- a/app/assets/javascripts/blob/pdf/pdf_viewer.vue +++ b/app/assets/javascripts/blob/pdf/pdf_viewer.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import PdfLab from '../../pdf/index.vue'; +import PdfLab from '~/pdf/index.vue'; export default { components: { diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 1bda7d4e3f0..a6eed4ecae3 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -11,14 +11,12 @@ import { } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import { fixTitle } from '~/tooltips'; -import axios from '../../lib/utils/axios_utils'; -import { handleLocationHash } from '../../lib/utils/common_utils'; -import eventHub from '../../notes/event_hub'; +import axios from '~/lib/utils/axios_utils'; +import { handleLocationHash } from '~/lib/utils/common_utils'; +import eventHub from '~/notes/event_hub'; const loadRichBlobViewer = (type) => { switch (type) { - case 'balsamiq': - return import(/* webpackChunkName: 'balsamiq_viewer' */ '../balsamiq_viewer'); case 'notebook': return import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer'); case 'openapi': diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 96cc774a280..9fca9860282 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -111,7 +111,7 @@ export function fullLabelId(label) { export function formatIssueInput(issueInput, boardConfig) { const { labelIds = [], assigneeIds = [] } = issueInput; - const { labels, assigneeId, milestoneId } = boardConfig; + const { labels, assigneeId, milestoneId, weight } = boardConfig; return { ...issueInput, @@ -121,6 +121,7 @@ export function formatIssueInput(issueInput, boardConfig) { : issueInput?.milestoneId, labelIds: [...labelIds, ...(labels?.map((l) => fullLabelId(l)) || [])], assigneeIds: [...assigneeIds, ...(assigneeId ? [fullUserId(assigneeId)] : [])], + weight: weight > -1 ? weight : undefined, }; } diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 28f4a267077..858aabb0f05 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -2,11 +2,13 @@ import { mapActions, mapGetters } from 'vuex'; import BoardContent from '~/boards/components/board_content.vue'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; +import BoardTopBar from '~/boards/components/board_top_bar.vue'; export default { components: { BoardContent, BoardSettingsSidebar, + BoardTopBar, }, inject: ['disabled'], computed: { @@ -23,6 +25,7 @@ export default { <template> <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }"> + <board-top-bar /> <board-content :disabled="disabled" /> <board-settings-sidebar /> </div> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index aee61a5b2a5..814ff16efec 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -13,7 +13,7 @@ import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { ListType } from '../constants'; import BoardBlockedIcon from './board_blocked_icon.vue'; import IssueDueDate from './issue_due_date.vue'; @@ -240,7 +240,7 @@ export default { class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end" > <div - class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container" + class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden" > <gl-loading-icon v-if="item.isLoading" size="md" class="mt-3" /> <span diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 27ea2e7a608..1d6a71aca47 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -4,7 +4,7 @@ import { sortBy } from 'lodash'; import Draggable from 'vuedraggable'; import { mapState, mapGetters, mapActions } from 'vuex'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; -import defaultSortableConfig from '~/sortable/sortable_config'; +import { defaultSortableOptions } from '~/sortable/constants'; import { DraggableItemTypes } from '../constants'; import BoardColumn from './board_column.vue'; @@ -43,7 +43,7 @@ export default { }, draggableOptions() { const options = { - ...defaultSortableConfig, + ...defaultSortableOptions, disabled: this.disabled, draggable: '.is-draggable', fallbackOnBody: false, diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 95d4fd5bc0a..aeb2cee590d 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -4,7 +4,10 @@ import { mapActions } from 'vuex'; import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + FILTERED_SEARCH_TERM, + FILTER_ANY, +} from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { AssigneeFilterType } from '~/boards/constants'; @@ -42,6 +45,7 @@ export default { search, milestoneTitle, iterationId, + iterationCadenceId, types, weight, epicId, @@ -95,10 +99,20 @@ export default { }); } - if (iterationId) { + let iterationData = null; + + if (iterationId && iterationCadenceId) { + iterationData = `${iterationId}&${iterationCadenceId}`; + } else if (iterationCadenceId) { + iterationData = `${FILTER_ANY}&${iterationCadenceId}`; + } else if (iterationId) { + iterationData = iterationId; + } + + if (iterationData) { filteredSearchValue.push({ type: 'iteration', - value: { data: iterationId, operator: '=' }, + value: { data: iterationData, operator: '=' }, }); } @@ -228,9 +242,12 @@ export default { epicId, myReactionEmoji, iterationId, + iterationCadenceId, releaseTag, confidential, } = this.filterParams; + let iteration = iterationId; + let cadence = iterationCadenceId; let notParams = {}; if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { @@ -251,6 +268,10 @@ export default { ); } + if (iterationId?.includes('&')) { + [iteration, cadence] = iterationId.split('&'); + } + return mapValues( { ...notParams, @@ -259,7 +280,8 @@ export default { assignee_username: assigneeUsername, assignee_id: assigneeId, milestone_title: milestoneTitle, - iteration_id: iterationId, + iteration_id: iteration, + iteration_cadence_id: cadence, search, types, weight, diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 5fcf9514708..a874c9e070a 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -48,7 +48,7 @@ export default { fullPath: { default: '', }, - rootPath: { + boardBaseUrl: { default: '', }, }, @@ -209,7 +209,7 @@ export default { if (this.isDeleteForm) { try { await this.deleteBoard(); - visitUrl(this.rootPath); + visitUrl(this.boardBaseUrl); } catch { this.setError({ message: this.$options.i18n.deleteErrorMessage }); } finally { @@ -289,7 +289,7 @@ export default { <p v-if="isDeleteForm" data-testid="delete-confirmation-message"> {{ $options.i18n.deleteConfirmationMessage }} </p> - <form v-else class="js-board-config-modal" data-testid="board-form-wrapper" @submit.prevent> + <form v-else data-testid="board-form-wrapper" @submit.prevent> <div v-if="!readonly" class="gl-mb-5" data-testid="board-form"> <label class="gl-font-weight-bold gl-font-lg" for="board-new-name"> {{ $options.i18n.titleFieldLabel }} diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1024be61359..47f25f34d0c 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -2,9 +2,9 @@ import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import Draggable from 'vuedraggable'; import { mapActions, mapGetters, mapState } from 'vuex'; -import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options'; import { sprintf, __ } from '~/locale'; -import defaultSortableConfig from '~/sortable/sortable_config'; +import { defaultSortableOptions } from '~/sortable/constants'; +import { sortableStart, sortableEnd } from '~/sortable/utils'; import Tracking from '~/tracking'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import { toggleFormEventPrefix, DraggableItemTypes } from '../constants'; @@ -121,7 +121,7 @@ export default { }, treeRootOptions() { const options = { - ...defaultSortableConfig, + ...defaultSortableOptions, fallbackOnBody: false, group: 'board-list', tag: 'ul', @@ -287,7 +287,7 @@ export default { :data-board-type="list.listType" :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }" draggable=".board-card" - class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" + class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2" data-testid="tree-root-wrapper" @start="handleDragOnStart" @end="handleDragOnEnd" diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 46b28d20da9..9f70c84931f 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -18,7 +18,7 @@ import Tracking from '~/tracking'; import { formatDate } from '~/lib/utils/datetime_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; -import AccessorUtilities from '../../lib/utils/accessor'; +import AccessorUtilities from '~/lib/utils/accessor'; import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; import ItemCount from './item_count.vue'; @@ -57,6 +57,9 @@ export default { currentUserId: { default: null, }, + canCreateEpic: { + default: false, + }, }, props: { list: { @@ -129,7 +132,7 @@ export default { return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard; }, isNewEpicShown() { - return this.isEpicBoard && this.listType !== ListType.closed; + return this.isEpicBoard && this.canCreateEpic && this.listType !== ListType.closed; }, isSettingsShown() { return ( @@ -262,7 +265,7 @@ export default { 'gl-py-2': list.collapsed && isSwimlanesHeader, 'gl-flex-direction-column': list.collapsed, }" - class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle" + class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3" > <gl-button v-gl-tooltip.hover @@ -443,12 +446,11 @@ export default { ref="settingsBtn" v-gl-tooltip.hover :aria-label="$options.i18n.listSettings" - class="no-drag js-board-settings-button" + class="no-drag" :title="$options.i18n.listSettings" icon="settings" @click="openSidebarSettings" /> - <gl-tooltip :target="() => $refs.settingsBtn">{{ $options.i18n.listSettings }}</gl-tooltip> </gl-button-group> </h3> </header> diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue new file mode 100644 index 00000000000..f90ac1e9079 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -0,0 +1,54 @@ +<script> +import { mapGetters } from 'vuex'; +import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; +import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; +import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue'; +import ConfigToggle from './config_toggle.vue'; +import NewBoardButton from './new_board_button.vue'; +import ToggleFocus from './toggle_focus.vue'; + +export default { + components: { + BoardAddNewColumnTrigger, + BoardsSelector, + IssueBoardFilteredSearch, + ConfigToggle, + NewBoardButton, + ToggleFocus, + ToggleLabels: () => import('ee_component/boards/components/toggle_labels.vue'), + ToggleEpicsSwimlanes: () => import('ee_component/boards/components/toggle_epics_swimlanes.vue'), + EpicBoardFilteredSearch: () => + import('ee_component/boards/components/epic_filtered_search.vue'), + }, + inject: ['swimlanesFeatureAvailable', 'canAdminList', 'isSignedIn'], + computed: { + ...mapGetters(['isEpicBoard']), + }, +}; +</script> + +<template> + <div class="issues-filters"> + <div + class="issues-details-filters filtered-search-block gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row row-content-block second-block" + > + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0! mb-md-2 mb-sm-0 gl-w-full" + > + <boards-selector /> + <new-board-button /> + <epic-board-filtered-search v-if="isEpicBoard" /> + <issue-board-filtered-search v-else /> + </div> + <div + class="filter-dropdown-container gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-align-items-flex-start" + > + <toggle-labels /> + <toggle-epics-swimlanes v-if="swimlanesFeatureAvailable && isSignedIn" /> + <config-toggle /> + <board-add-new-column-trigger v-if="canAdminList" /> + <toggle-focus /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 91fdfd668fc..2951eda1112 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -40,37 +40,21 @@ export default { directives: { GlModalDirective, }, - inject: ['fullPath'], + inject: [ + 'boardBaseUrl', + 'fullPath', + 'canAdminBoard', + 'multipleIssueBoardsAvailable', + 'hasMissingBoards', + 'scopedIssueBoardFeatureEnabled', + 'weights', + ], props: { throttleDuration: { type: Number, default: 200, required: false, }, - boardBaseUrl: { - type: String, - required: true, - }, - hasMissingBoards: { - type: Boolean, - required: true, - }, - canAdminBoard: { - type: Boolean, - required: true, - }, - multipleIssueBoardsAvailable: { - type: Boolean, - required: true, - }, - scopedIssueBoardFeatureEnabled: { - type: Boolean, - required: true, - }, - weights: { - type: Array, - required: true, - }, }, data() { return { @@ -255,11 +239,12 @@ export default { </script> <template> - <div class="boards-switcher js-boards-selector gl-mr-3"> - <span class="boards-selector-wrapper js-boards-selector-wrapper"> + <div class="boards-switcher gl-mr-3" data-testid="boards-selector"> + <span class="boards-selector-wrapper"> <gl-dropdown + data-testid="boards-dropdown" data-qa-selector="boards_dropdown" - toggle-class="dropdown-menu-toggle js-dropdown-toggle" + toggle-class="dropdown-menu-toggle" menu-class="flex-column dropdown-extended-height" :loading="isBoardLoading" :text="board.name" @@ -292,8 +277,8 @@ export default { <gl-dropdown-item v-for="recentBoard in recentBoards" :key="`recent-${recentBoard.id}`" - class="js-dropdown-item" :href="`${boardBaseUrl}/${recentBoard.id}`" + data-testid="dropdown-item" > {{ recentBoard.name }} </gl-dropdown-item> @@ -308,8 +293,8 @@ export default { <gl-dropdown-item v-for="otherBoard in filteredBoards" :key="otherBoard.id" - class="js-dropdown-item" :href="`${boardBaseUrl}/${otherBoard.id}`" + data-testid="dropdown-item" > {{ otherBoard.name }} </gl-dropdown-item> @@ -347,7 +332,7 @@ export default { <gl-dropdown-item v-if="showDelete" v-gl-modal-directive="'board-config-modal'" - class="text-danger js-delete-board" + class="text-danger" @click.prevent="showPage('delete')" > {{ s__('IssueBoards|Delete board') }} diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index f39e4d90357..4746f598ab7 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -14,16 +14,7 @@ export default { GlModalDirective, }, mixins: [Tracking.mixin()], - props: { - canAdminList: { - type: Boolean, - required: true, - }, - hasScope: { - type: Boolean, - required: true, - }, - }, + inject: ['canAdminList', 'hasScope'], computed: { buttonText() { return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope'); diff --git a/app/assets/javascripts/boards/components/issuable_title.vue b/app/assets/javascripts/boards/components/issuable_title.vue deleted file mode 100644 index 40627a9fab8..00000000000 --- a/app/assets/javascripts/boards/components/issuable_title.vue +++ /dev/null @@ -1,21 +0,0 @@ -<script> -export default { - props: { - title: { - type: String, - required: true, - }, - refPath: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <div data-testid="issue-title"> - <p class="gl-font-weight-bold">{{ title }}</p> - <p class="gl-mb-0">{{ refPath }}</p> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index 6bfdbb674a2..bab6fe26978 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -41,17 +41,7 @@ export default { confidential: __('Confidential'), }, components: { BoardFilteredSearch }, - inject: ['isSignedIn', 'releasesFetchPath'], - props: { - fullPath: { - type: String, - required: true, - }, - boardType: { - type: String, - required: true, - }, - }, + inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'boardType'], computed: { isGroupBoard() { return this.boardType === BoardType.group; diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index 1ab7deebfaf..9312db06efe 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -43,7 +43,7 @@ export default { <gl-tooltip :target="() => $refs.issueTimeEstimate" placement="bottom" - class="js-issue-time-estimate" + data-testid="issue-time-estimate" > <span class="gl-font-weight-bold gl-display-block">{{ $options.i18n.timeEstimate }}</span> {{ title }} diff --git a/app/assets/javascripts/boards/components/item_count.vue b/app/assets/javascripts/boards/components/item_count.vue index 9b1ff254766..a11c23e5625 100644 --- a/app/assets/javascripts/boards/components/item_count.vue +++ b/app/assets/javascripts/boards/components/item_count.vue @@ -29,7 +29,7 @@ export default { <span :class="{ 'text-danger': issuesExceedMax }" data-testid="board-items-count"> {{ itemsSize }} </span> - <span v-if="isMaxLimitSet" class="js-max-issue-size"> + <span v-if="isMaxLimitSet" class="max-issue-size"> {{ maxIssueCount }} </span> </div> diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue index 49f5e7d20a9..71612e0742f 100644 --- a/app/assets/javascripts/boards/components/toggle_focus.vue +++ b/app/assets/javascripts/boards/components/toggle_focus.vue @@ -10,12 +10,6 @@ export default { directives: { GlTooltip, }, - props: { - issueBoardsContentSelector: { - type: String, - required: true, - }, - }, data() { return { isFullscreen: false, @@ -25,7 +19,7 @@ export default { toggleFocusMode() { hide(this.$refs.toggleFocusModeButton); - const issueBoardsContent = document.querySelector(this.issueBoardsContentSelector); + const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board'); issueBoardsContent.classList.toggle('is-focused'); this.isFullscreen = !this.isFullscreen; @@ -44,7 +38,6 @@ export default { v-gl-tooltip category="tertiary" :icon="isFullscreen ? 'minimize' : 'maximize'" - class="js-focus-mode-btn" data-qa-selector="focus_mode_button" :title="$options.i18n.toggleFocusMode" :aria-label="$options.i18n.toggleFocusMode" diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js deleted file mode 100644 index 1e54c2511b8..00000000000 --- a/app/assets/javascripts/boards/config_toggle.js +++ /dev/null @@ -1,25 +0,0 @@ -import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import ConfigToggle from './components/config_toggle.vue'; - -export default () => { - const el = document.querySelector('.js-board-config'); - - if (!el) { - return; - } - - // eslint-disable-next-line no-new - new Vue({ - el, - name: 'ConfigToggleRoot', - render(h) { - return h(ConfigToggle, { - props: { - canAdminList: parseBoolean(el.dataset.canAdminList), - hasScope: parseBoolean(el.dataset.hasScope), - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql index 0da14d0b872..e0a3cb0ee21 100644 --- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query boardsGetGroupProjects($fullPath: ID!, $search: String, $after: String) { group(fullPath: $fullPath) { diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index b31b56e6839..77c5994b5a1 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,22 +1,19 @@ import PortalVue from 'portal-vue'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; - -import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes'; -import toggleLabels from 'ee_else_ce/boards/toggle_labels'; -import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; import BoardApp from '~/boards/components/board_app.vue'; import '~/boards/filters/due_date_filters'; import { issuableTypes } from '~/boards/constants'; -import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards'; import store from '~/boards/stores'; -import toggleFocusMode from '~/boards/toggle_focus'; -import { NavigationType, isLoggedIn, parseBoolean } from '~/lib/utils/common_utils'; +import { + NavigationType, + isLoggedIn, + parseBoolean, + convertObjectPropsToCamelCase, +} from '~/lib/utils/common_utils'; +import { queryToObject } from '~/lib/utils/url_utility'; import { fullBoardId } from './boards_util'; -import boardConfigToggle from './config_toggle'; -import initNewBoard from './new_board'; import { gqlClient } from './graphql'; -import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; Vue.use(VueApollo); Vue.use(PortalVue); @@ -28,6 +25,12 @@ const apolloProvider = new VueApollo({ function mountBoardApp(el) { const { boardId, groupId, fullPath, rootPath } = el.dataset; + const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); + + const initialFilterParams = { + ...convertObjectPropsToCamelCase(rawFilterParams), + }; + store.dispatch('fetchBoard', { fullPath, fullBoardId: fullBoardId(boardId), @@ -54,26 +57,41 @@ function mountBoardApp(el) { boardId, groupId: Number(groupId), rootPath, + fullPath, + initialFilterParams, + boardBaseUrl: el.dataset.boardBaseUrl, + boardType: el.dataset.parent, currentUserId: gon.current_user_id || null, - canUpdate: parseBoolean(el.dataset.canUpdate), - canAdminList: parseBoolean(el.dataset.canAdminList), + boardWeight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null, labelsManagePath: el.dataset.labelsManagePath, labelsFilterBasePath: el.dataset.labelsFilterBasePath, + releasesFetchPath: el.dataset.releasesFetchPath, timeTrackingLimitToHours: parseBoolean(el.dataset.timeTrackingLimitToHours), + issuableType: issuableTypes.issue, + emailsDisabled: parseBoolean(el.dataset.emailsDisabled), + hasScope: parseBoolean(el.dataset.hasScope), + hasMissingBoards: parseBoolean(el.dataset.hasMissingBoards), + weights: el.dataset.weights ? JSON.parse(el.dataset.weights) : [], + // Permissions + canUpdate: parseBoolean(el.dataset.canUpdate), + canAdminList: parseBoolean(el.dataset.canAdminList), + canAdminBoard: parseBoolean(el.dataset.canAdminBoard), + allowLabelCreate: parseBoolean(el.dataset.canUpdate), + allowLabelEdit: parseBoolean(el.dataset.canUpdate), + isSignedIn: isLoggedIn(), + // Features multipleAssigneesFeatureAvailable: parseBoolean(el.dataset.multipleAssigneesFeatureAvailable), epicFeatureAvailable: parseBoolean(el.dataset.epicFeatureAvailable), iterationFeatureAvailable: parseBoolean(el.dataset.iterationFeatureAvailable), weightFeatureAvailable: parseBoolean(el.dataset.weightFeatureAvailable), - boardWeight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null, scopedLabelsAvailable: parseBoolean(el.dataset.scopedLabels), milestoneListsAvailable: parseBoolean(el.dataset.milestoneListsAvailable), assigneeListsAvailable: parseBoolean(el.dataset.assigneeListsAvailable), iterationListsAvailable: parseBoolean(el.dataset.iterationListsAvailable), - issuableType: issuableTypes.issue, - emailsDisabled: parseBoolean(el.dataset.emailsDisabled), - allowLabelCreate: parseBoolean(el.dataset.canUpdate), - allowLabelEdit: parseBoolean(el.dataset.canUpdate), allowScopedLabels: parseBoolean(el.dataset.scopedLabels), + swimlanesFeatureAvailable: gon.licensed_features?.swimlanes, + multipleIssueBoardsAvailable: parseBoolean(el.dataset.multipleBoardsAvailable), + scopedIssueBoardFeatureEnabled: parseBoolean(el.dataset.scopedIssueBoardFeatureEnabled), }, render: (createComponent) => createComponent(BoardApp), }); @@ -92,47 +110,5 @@ export default () => { } }); - const { releasesFetchPath, epicFeatureAvailable, iterationFeatureAvailable } = $boardApp.dataset; - initBoardsFilteredSearch( - apolloProvider, - isLoggedIn(), - releasesFetchPath, - parseBoolean(epicFeatureAvailable), - parseBoolean(iterationFeatureAvailable), - ); - mountBoardApp($boardApp); - - const createColumnTriggerEl = document.querySelector('.js-create-column-trigger'); - if (createColumnTriggerEl) { - // eslint-disable-next-line no-new - new Vue({ - el: createColumnTriggerEl, - name: 'BoardAddNewColumnTriggerRoot', - components: { - BoardAddNewColumnTrigger, - }, - store, - render(createElement) { - return createElement('board-add-new-column-trigger'); - }, - }); - } - - boardConfigToggle(); - initNewBoard(); - - toggleFocusMode(); - toggleLabels(); - - if (gon.licensed_features?.swimlanes) { - toggleEpicsSwimlanes(); - } - - mountMultipleBoardsSwitcher({ - fullPath: $boardApp.dataset.fullPath, - rootPath: $boardApp.dataset.boardsEndpoint, - allowScopedLabels: $boardApp.dataset.scopedLabels, - labelsManagePath: $boardApp.dataset.labelsManagePath, - }); }; diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js deleted file mode 100644 index bb659eb075a..00000000000 --- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue'; -import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue'; -import store from '~/boards/stores'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { queryToObject } from '~/lib/utils/url_utility'; - -export default ( - apolloProvider, - isSignedIn, - releasesFetchPath, - epicFeatureAvailable, - iterationFeatureAvailable, -) => { - const el = document.getElementById('js-issue-board-filtered-search'); - const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); - - const initialFilterParams = { - ...convertObjectPropsToCamelCase(rawFilterParams, {}), - }; - - if (!el) { - return null; - } - - return new Vue({ - el, - name: 'BoardFilteredSearchRoot', - provide: { - initialFilterParams, - isSignedIn, - releasesFetchPath, - epicFeatureAvailable, - iterationFeatureAvailable, - }, - store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094 - apolloProvider, - render: (createElement) => - createElement(IssueBoardFilteredSearch, { - props: { fullPath: store.state?.fullPath || '', boardType: store.state?.boardType || '' }, - }), - }); -}; diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js deleted file mode 100644 index 0bc9cfbd867..00000000000 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ /dev/null @@ -1,50 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; -import store from '~/boards/stores'; -import createDefaultClient from '~/lib/graphql'; -import { parseBoolean } from '~/lib/utils/common_utils'; - -Vue.use(VueApollo); - -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -export default (params = {}) => { - const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher'); - const { dataset } = boardsSwitcherElement; - return new Vue({ - el: boardsSwitcherElement, - name: 'BoardsSelectorRoot', - components: { - BoardsSelector, - }, - apolloProvider, - store, - provide: { - fullPath: params.fullPath, - rootPath: params.rootPath, - allowScopedLabels: params.allowScopedLabels, - labelsManagePath: params.labelsManagePath, - allowLabelCreate: parseBoolean(dataset.canAdminBoard), - }, - data() { - const boardsSelectorProps = { - ...dataset, - hasMissingBoards: parseBoolean(dataset.hasMissingBoards), - canAdminBoard: parseBoolean(dataset.canAdminBoard), - multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable), - scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled), - weights: JSON.parse(dataset.weights), - }; - - return { boardsSelectorProps }; - }, - render(createElement) { - return createElement(BoardsSelector, { - props: this.boardsSelectorProps, - }); - }, - }); -}; diff --git a/app/assets/javascripts/boards/new_board.js b/app/assets/javascripts/boards/new_board.js deleted file mode 100644 index 34f2fea79a9..00000000000 --- a/app/assets/javascripts/boards/new_board.js +++ /dev/null @@ -1,29 +0,0 @@ -import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { getExperimentVariant } from '~/experimentation/utils'; -import { CANDIDATE_VARIANT } from '~/experimentation/constants'; -import NewBoardButton from './components/new_board_button.vue'; - -export default () => { - if (getExperimentVariant('prominent_create_board_btn') !== CANDIDATE_VARIANT) { - return; - } - - const el = document.querySelector('.js-new-board'); - - if (!el) { - return; - } - - // eslint-disable-next-line no-new - new Vue({ - el, - provide: { - multipleIssueBoardsAvailable: parseBoolean(el.dataset.multipleIssueBoardsAvailable), - canAdminBoard: parseBoolean(el.dataset.canAdminBoard), - }, - render(h) { - return h(NewBoardButton); - }, - }); -}; diff --git a/app/assets/javascripts/boards/toggle_epics_swimlanes.js b/app/assets/javascripts/boards/toggle_epics_swimlanes.js deleted file mode 100644 index 2d1ec238274..00000000000 --- a/app/assets/javascripts/boards/toggle_epics_swimlanes.js +++ /dev/null @@ -1 +0,0 @@ -export default () => {}; diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js deleted file mode 100644 index 8f057e192dd..00000000000 --- a/app/assets/javascripts/boards/toggle_focus.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue'; -import ToggleFocus from './components/toggle_focus.vue'; - -export default () => { - const issueBoardsContentSelector = '.content-wrapper > .js-focus-mode-board'; - - return new Vue({ - el: '#js-toggle-focus-btn', - name: 'ToggleFocusRoot', - render(h) { - return h(ToggleFocus, { - props: { - issueBoardsContentSelector, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/boards/toggle_labels.js b/app/assets/javascripts/boards/toggle_labels.js deleted file mode 100644 index 2d1ec238274..00000000000 --- a/app/assets/javascripts/boards/toggle_labels.js +++ /dev/null @@ -1 +0,0 @@ -export default () => {}; diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js index 31cf9a18077..17fd3939441 100644 --- a/app/assets/javascripts/branches/divergence_graph.js +++ b/app/assets/javascripts/branches/divergence_graph.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import createFlash from '~/flash'; -import axios from '../lib/utils/axios_utils'; -import { __ } from '../locale'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; import DivergenceGraph from './components/divergence_graph.vue'; export function createGraphVueApp(el, data, maxCommits, defaultBranch) { diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index 4156717908d..5e5d799d627 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -1,10 +1,9 @@ <script> -import { GlTable, GlButton, GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { GlTable, GlButton, GlBadge, GlTooltipDirective, GlAvatarLink, GlAvatar } from '@gitlab/ui'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; export default { i18n: { @@ -21,7 +20,8 @@ export default { GlBadge, ClipboardButton, TooltipOnTruncate, - UserAvatarLink, + GlAvatarLink, + GlAvatar, TimeAgoTooltip, }, directives: { @@ -102,13 +102,14 @@ export default { </template> <template #cell(owner)="{ item }"> <span class="trigger-owner sr-only">{{ item.owner.name }}</span> - <user-avatar-link + <gl-avatar-link v-if="item.owner" - :link-href="item.owner.path" - :img-src="item.owner.avatarUrl" - :tooltip-text="item.owner.name" - :img-alt="item.owner.name" - /> + v-gl-tooltip + :href="item.owner.path" + :title="item.owner.name" + > + <gl-avatar :size="24" :src="item.owner.avatarUrl" /> + </gl-avatar-link> </template> <template #cell(lastUsed)="{ item }"> <time-ago-tooltip v-if="item.lastUsed" :time="item.lastUsed" /> diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js index 055e2f83e33..574a5e7fd99 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -1,8 +1,8 @@ import $ from 'jquery'; -import SecretValues from '../behaviors/secret_values'; -import CreateItemDropdown from '../create_item_dropdown'; -import { parseBoolean } from '../lib/utils/common_utils'; -import { s__ } from '../locale'; +import SecretValues from '~/behaviors/secret_values'; +import CreateItemDropdown from '~/create_item_dropdown'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; const ALL_ENVIRONMENTS_STRING = s__('CiVariable|All environments'); diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 2e198c59926..be2366108b3 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -405,7 +405,7 @@ export default { <gl-button ref="updateOrAddVariable" :disabled="!canSubmit" - variant="success" + variant="confirm" category="primary" data-qa-selector="ci_variable_save_button" @click="updateOrAddVariable" diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql index 3610662afc0..d7a8e447071 100644 --- a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql +++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" #import "../fragments/cluster_agent_token.fragment.graphql" query getClusterAgent( diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 8dcab55ac61..a8fef372637 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -4,10 +4,10 @@ import Vue from 'vue'; import createFlash from '~/flash'; import AccessorUtilities from '~/lib/utils/accessor'; import initProjectSelectDropdown from '~/project_select'; -import Poll from '../lib/utils/poll'; -import { s__ } from '../locale'; -import PersistentUserCallout from '../persistent_user_callout'; -import initSettingsPanels from '../settings_panels'; +import Poll from '~/lib/utils/poll'; +import { s__ } from '~/locale'; +import PersistentUserCallout from '~/persistent_user_callout'; +import initSettingsPanels from '~/settings_panels'; import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; diff --git a/app/assets/javascripts/clusters/forms/stores/state.js b/app/assets/javascripts/clusters/forms/stores/state.js index 74a00b97603..7d6ac1925d8 100644 --- a/app/assets/javascripts/clusters/forms/stores/state.js +++ b/app/assets/javascripts/clusters/forms/stores/state.js @@ -1,4 +1,4 @@ -import { parseBoolean } from '../../../lib/utils/common_utils'; +import { parseBoolean } from '~/lib/utils/common_utils'; export default (initialState = {}) => { return { diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index 7300bb3137a..072b9827f5a 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -1,4 +1,4 @@ -import axios from '../../lib/utils/axios_utils'; +import axios from '~/lib/utils/axios_utils'; export default class ClusterService { constructor(options = {}) { diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index db6e7bad6cc..6fb850f009a 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -1,4 +1,4 @@ -import { parseBoolean } from '../../lib/utils/common_utils'; +import { parseBoolean } from '~/lib/utils/common_utils'; export default class ClusterStore { constructor() { diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js index c78c93fe1ba..e7ad2f45c75 100644 --- a/app/assets/javascripts/clusters_list/clusters_util.js +++ b/app/assets/javascripts/clusters_list/clusters_util.js @@ -1,10 +1,11 @@ export function generateAgentRegistrationCommand(agentToken, kasAddress) { - return `docker run --pull=always --rm \\ - registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/cli:stable generate \\ - --agent-token=${agentToken} \\ - --kas-address=${kasAddress} \\ - --agent-version stable \\ - --namespace gitlab-kubernetes-agent | kubectl apply -f -`; + return `helm repo add gitlab https://charts.gitlab.io +helm repo update +helm upgrade --install gitlab-agent gitlab/gitlab-agent \\ + --namespace gitlab-agent \\ + --create-namespace \\ + --set config.token=${agentToken} \\ + --set config.kasAddress=${kasAddress}`; } export function getAgentConfigPath(clusterAgentName) { diff --git a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue index f54f7b11414..2f45ef8a862 100644 --- a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue +++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue @@ -1,55 +1,30 @@ <script> -import { GlButton, GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { INSTALL_AGENT_MODAL_ID, I18N_AGENTS_EMPTY_STATE } from '../constants'; +import { I18N_AGENTS_EMPTY_STATE } from '../constants'; export default { i18n: I18N_AGENTS_EMPTY_STATE, - modalId: INSTALL_AGENT_MODAL_ID, agentDocsUrl: helpPagePath('user/clusters/agent/index'), components: { - GlButton, GlEmptyState, GlLink, GlSprintf, }, - directives: { - GlModalDirective, - }, inject: ['emptyStateImage'], - props: { - isChildComponent: { - default: false, - required: false, - type: Boolean, - }, - }, }; </script> <template> - <gl-empty-state :svg-path="emptyStateImage" title="" class="agents-empty-state"> - <template #description> - <p class="gl-text-left"> - <gl-sprintf :message="$options.i18n.introText"> - <template #link="{ content }"> - <gl-link :href="$options.agentDocsUrl"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </template> - - <template #actions> - <gl-button - v-if="!isChildComponent" - v-gl-modal-directive="$options.modalId" - category="primary" - variant="confirm" - > - {{ $options.i18n.buttonText }} - </gl-button> + <gl-empty-state :svg-path="emptyStateImage" :svg-height="100"> + <template #title> + <gl-sprintf :message="$options.i18n.introText"> + <template #link="{ content }"> + <gl-link :href="$options.agentDocsUrl"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> </template> </gl-empty-state> </template> diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index 1144ce68e2c..2decdb5307b 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -37,7 +37,7 @@ export default { anchor: 'update-the-agent-version', }), configHelpLink: helpPagePath('user/clusters/agent/install/index', { - anchor: 'create-an-agent-without-configuration-file', + anchor: 'create-an-agent-configuration-file', }), inject: ['gitlabVersion'], props: { diff --git a/app/assets/javascripts/clusters_list/components/agent_token.vue b/app/assets/javascripts/clusters_list/components/agent_token.vue index eab3fc3ed63..751ad9795dd 100644 --- a/app/assets/javascripts/clusters_list/components/agent_token.vue +++ b/app/assets/javascripts/clusters_list/components/agent_token.vue @@ -8,11 +8,8 @@ import { I18N_AGENT_TOKEN } from '../constants'; export default { i18n: I18N_AGENT_TOKEN, - basicInstallPath: helpPagePath('user/clusters/agent/install/index', { - anchor: 'install-the-agent-into-the-cluster', - }), advancedInstallPath: helpPagePath('user/clusters/agent/install/index', { - anchor: 'advanced-installation', + anchor: 'advanced-installation-method', }), components: { GlAlert, @@ -43,27 +40,7 @@ export default { <template> <div> - <p> - <strong>{{ $options.i18n.tokenTitle }}</strong> - </p> - - <p> - <gl-sprintf :message="$options.i18n.tokenBody"> - <template #link="{ content }"> - <gl-link :href="$options.basicInstallPath" target="_blank"> {{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - - <p> - <gl-alert - :title="$options.i18n.tokenSingleUseWarningTitle" - variant="warning" - :dismissible="false" - > - {{ $options.i18n.tokenSingleUseWarningBody }} - </gl-alert> - </p> + <p class="gl-mb-3">{{ $options.i18n.tokenLabel }}</p> <p> <gl-form-input-group readonly :value="agentToken" :select-on-click="true"> @@ -78,6 +55,14 @@ export default { </p> <p> + {{ $options.i18n.tokenSubtitle }} + </p> + + <gl-alert :dismissible="false" variant="warning" class="gl-mb-5"> + {{ $options.i18n.tokenSingleUseWarningTitle }} + </gl-alert> + + <p> <strong>{{ $options.i18n.basicInstallTitle }}</strong> </p> diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index 70b9b8ac3c9..89b18ed6d06 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -20,7 +20,7 @@ export default { 'ClusterAgents|We would love to learn more about your experience with the GitLab Agent.', ), feedbackBannerButton: s__('ClusterAgents|Give feedback'), - error: s__('ClusterAgents|An error occurred while loading your Agents'), + error: s__('ClusterAgents|An error occurred while loading your agents'), }, AGENT_FEEDBACK_ISSUE, AGENT_FEEDBACK_KEY, @@ -208,7 +208,7 @@ export default { </div> </div> - <agent-empty-state v-else :is-child-component="isChildComponent" /> + <agent-empty-state v-else /> </section> <gl-alert v-else variant="danger" :dismissible="false"> diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue index 662cf2a7e36..bde76c46b4b 100644 --- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue +++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue @@ -3,6 +3,7 @@ import { GlDropdown, GlDropdownItem, GlDropdownDivider, + GlDropdownText, GlSearchBoxByType, GlSprintf, } from '@gitlab/ui'; @@ -15,6 +16,7 @@ export default { GlDropdown, GlDropdownItem, GlDropdownDivider, + GlDropdownText, GlSearchBoxByType, GlSprintf, }, @@ -73,13 +75,24 @@ export default { this.clearSearch(); this.focusSearch(); }, + onKeyEnter() { + if (!this.searchTerm?.length) { + return; + } + this.$refs.dropdown.hide(); + this.selectAgent(this.searchTerm); + }, }, }; </script> <template> - <gl-dropdown :text="dropdownText" :loading="isRegistering" @shown="handleShow"> + <gl-dropdown ref="dropdown" :text="dropdownText" :loading="isRegistering" @shown="handleShow"> <template #header> - <gl-search-box-by-type ref="searchInput" v-model.trim="searchTerm" /> + <gl-search-box-by-type + ref="searchInput" + v-model.trim="searchTerm" + @keydown.enter.stop.prevent="onKeyEnter" + /> </template> <gl-dropdown-item v-for="agent in filteredResults" @@ -90,9 +103,9 @@ export default { > {{ agent }} </gl-dropdown-item> - <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ + <gl-dropdown-text v-if="!filteredResults.length" ref="noMatchingResults">{{ $options.i18n.noResults - }}</gl-dropdown-item> + }}</gl-dropdown-text> <template v-if="shouldRenderCreateButton"> <gl-dropdown-divider /> <gl-dropdown-item data-testid="create-config-button" @click="selectAgent(searchTerm)"> diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue index ccb973f1eb8..8fd759bd3e9 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue @@ -1,13 +1,5 @@ <script> -import { - GlButton, - GlDropdown, - GlDropdownItem, - GlModalDirective, - GlTooltipDirective, - GlDropdownDivider, - GlDropdownSectionHeader, -} from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlModalDirective, GlTooltip } from '@gitlab/ui'; import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '../constants'; @@ -15,37 +7,40 @@ export default { i18n: CLUSTERS_ACTIONS, INSTALL_AGENT_MODAL_ID, components: { - GlButton, GlDropdown, GlDropdownItem, - GlDropdownDivider, - GlDropdownSectionHeader, + GlTooltip, }, directives: { GlModalDirective, - GlTooltip: GlTooltipDirective, }, inject: [ 'newClusterPath', 'addClusterPath', + 'newClusterDocsPath', 'canAddCluster', 'displayClusterAgents', 'certificateBasedClustersEnabled', ], computed: { - tooltip() { - const { connectWithAgent, connectExistingCluster, dropdownDisabledHint } = this.$options.i18n; + shouldTriggerModal() { + return this.canAddCluster && this.displayClusterAgents; + }, + defaultActionText() { + const { connectCluster, connectWithAgent, connectClusterDeprecated } = this.$options.i18n; - if (!this.canAddCluster) { - return dropdownDisabledHint; - } else if (this.displayClusterAgents) { - return connectWithAgent; + if (!this.displayClusterAgents) { + return connectClusterDeprecated; + } else if (!this.certificateBasedClustersEnabled) { + return connectCluster; } - - return connectExistingCluster; + return connectWithAgent; }, - shouldTriggerModal() { - return this.canAddCluster && this.displayClusterAgents; + defaultActionUrl() { + if (this.displayClusterAgents) { + return null; + } + return this.addClusterPath; }, }, }; @@ -53,46 +48,51 @@ export default { <template> <div class="nav-controls gl-ml-auto"> + <gl-tooltip + v-if="!canAddCluster" + :target="() => $refs.dropdown.$el" + :title="$options.i18n.dropdownDisabledHint" + /> + <gl-dropdown - v-if="certificateBasedClustersEnabled" ref="dropdown" v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID" - v-gl-tooltip="tooltip" + data-qa-selector="clusters_actions_button" category="primary" variant="confirm" - :text="$options.i18n.actionsButton" + :text="defaultActionText" :disabled="!canAddCluster" - :split="displayClusterAgents" + :split-href="defaultActionUrl" + split right > - <template v-if="displayClusterAgents"> - <gl-dropdown-section-header>{{ $options.i18n.agent }}</gl-dropdown-section-header> - <gl-dropdown-item - v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" - data-testid="connect-new-agent-link" - > - {{ $options.i18n.connectWithAgent }} + <gl-dropdown-item + v-if="displayClusterAgents" + :href="newClusterDocsPath" + data-testid="create-cluster-link" + @click.stop + > + {{ $options.i18n.createCluster }} + </gl-dropdown-item> + + <template v-if="displayClusterAgents && certificateBasedClustersEnabled"> + <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop> + {{ $options.i18n.createClusterCertificate }} + </gl-dropdown-item> + + <gl-dropdown-item :href="addClusterPath" data-testid="connect-cluster-link" @click.stop> + {{ $options.i18n.connectClusterCertificate }} </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-section-header>{{ $options.i18n.certificate }}</gl-dropdown-section-header> </template> - <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop> - {{ $options.i18n.createNewCluster }} - </gl-dropdown-item> - <gl-dropdown-item :href="addClusterPath" data-testid="connect-cluster-link" @click.stop> - {{ $options.i18n.connectExistingCluster }} + <gl-dropdown-item + v-if="certificateBasedClustersEnabled && !displayClusterAgents" + :href="newClusterPath" + data-testid="new-cluster-link" + @click.stop + > + {{ $options.i18n.createClusterDeprecated }} </gl-dropdown-item> </gl-dropdown> - <gl-button - v-else - v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" - v-gl-tooltip="tooltip" - :disabled="!canAddCluster" - category="primary" - variant="confirm" - > - {{ $options.i18n.connectWithAgent }} - </gl-button> </div> </template> diff --git a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue index 76bec05cfc7..f4134ab5072 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue @@ -1,6 +1,5 @@ <script> -import { GlEmptyState, GlButton, GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; -import { mapState } from 'vuex'; +import { GlEmptyState, GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { I18N_CLUSTERS_EMPTY_STATE } from '../constants'; @@ -8,35 +7,24 @@ export default { i18n: I18N_CLUSTERS_EMPTY_STATE, components: { GlEmptyState, - GlButton, GlLink, GlSprintf, GlAlert, }, - inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'addClusterPath'], - props: { - isChildComponent: { - default: false, - required: false, - type: Boolean, - }, - }, + inject: ['emptyStateHelpText', 'clustersEmptyStateImage'], clustersHelpUrl: helpPagePath('user/infrastructure/clusters/index', { anchor: 'certificate-based-kubernetes-integration-deprecated', }), blogPostUrl: 'https://about.gitlab.com/blog/2021/11/15/deprecating-the-cert-based-kubernetes-integration/', - computed: { - ...mapState(['canAddCluster']), - }, }; </script> <template> <div> - <gl-empty-state :svg-path="clustersEmptyStateImage" title=""> - <template #description> - <p class="gl-text-left"> + <gl-empty-state :svg-path="clustersEmptyStateImage" :svg-height="100"> + <template #title> + <p> <gl-sprintf :message="$options.i18n.introText"> <template #link="{ content }"> <gl-link :href="$options.clustersHelpUrl">{{ content }}</gl-link> @@ -48,28 +36,12 @@ export default { {{ emptyStateHelpText }} </p> </template> - - <template #actions> - <gl-button - v-if="!isChildComponent" - data-testid="integration-primary-button" - data-qa-selector="add_kubernetes_cluster_link" - category="primary" - variant="confirm" - :disabled="!canAddCluster" - :href="addClusterPath" - > - {{ $options.i18n.buttonText }} - </gl-button> - </template> </gl-empty-state> <gl-alert variant="warning" :dismissible="false"> <gl-sprintf :message="$options.i18n.alertText"> <template #link="{ content }"> - <gl-link :href="$options.blogPostUrl" target="_blank"> - {{ content }} - </gl-link> + <gl-link :href="$options.blogPostUrl" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </gl-alert> diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue index b730c0adfa2..73ca804e111 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue @@ -1,22 +1,7 @@ <script> -import { - GlCard, - GlSprintf, - GlPopover, - GlLink, - GlButton, - GlBadge, - GlLoadingIcon, - GlModalDirective, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlCard, GlSprintf, GlPopover, GlLink, GlBadge, GlLoadingIcon } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { - AGENT_CARD_INFO, - CERTIFICATE_BASED_CARD_INFO, - MAX_CLUSTERS_LIST, - INSTALL_AGENT_MODAL_ID, -} from '../constants'; +import { AGENT_CARD_INFO, CERTIFICATE_BASED_CARD_INFO, MAX_CLUSTERS_LIST } from '../constants'; import Clusters from './clusters.vue'; import Agents from './agents.vue'; @@ -26,23 +11,16 @@ export default { GlSprintf, GlPopover, GlLink, - GlButton, GlBadge, GlLoadingIcon, Clusters, Agents, }, - directives: { - GlModalDirective, - GlTooltip: GlTooltipDirective, - }, MAX_CLUSTERS_LIST, - INSTALL_AGENT_MODAL_ID, i18n: { agent: AGENT_CARD_INFO, certificate: CERTIFICATE_BASED_CARD_INFO, }, - inject: ['addClusterPath', 'canAddCluster'], props: { defaultBranchName: { default: '.noBranch', @@ -93,14 +71,6 @@ export default { return cardTitle; }, - installAgentTooltip() { - return this.canAddCluster ? '' : this.$options.i18n.agent.installAgentDisabledHint; - }, - connectExistingClusterTooltip() { - return this.canAddCluster - ? '' - : this.$options.i18n.certificate.connectExistingClusterDisabledHint; - }, }, methods: { cardFooterNumber(number) { @@ -177,21 +147,6 @@ export default { ><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf ></gl-link > - <div - v-gl-tooltip="installAgentTooltip" - class="gl-display-inline-block" - tabindex="-1" - data-testid="install-agent-button-tooltip" - > - <gl-button - v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" - class="gl-ml-4" - category="secondary" - variant="confirm" - :disabled="!canAddCluster" - >{{ $options.i18n.agent.actionText }}</gl-button - > - </div> </template> </gl-card> @@ -214,7 +169,7 @@ export default { <gl-badge variant="warning">{{ $options.i18n.certificate.badgeText }}</gl-badge> </template> - <clusters :limit="$options.MAX_CLUSTERS_LIST" :is-child-component="true" /> + <clusters :limit="$options.MAX_CLUSTERS_LIST" /> <template #footer> <gl-link @@ -226,22 +181,6 @@ export default { ><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf ></gl-link > - <div - v-gl-tooltip="connectExistingClusterTooltip" - class="gl-display-inline-block" - tabindex="-1" - data-testid="connect-existing-cluster-button-tooltip" - > - <gl-button - category="secondary" - data-qa-selector="connect_existing_cluster_button" - variant="confirm" - class="gl-ml-4" - :href="addClusterPath" - :disabled="!canAddCluster" - >{{ $options.i18n.certificate.actionText }}</gl-button - > - </div> </template> </gl-card> </div> diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue index ae0affe4c8b..3b39c3aac45 100644 --- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue +++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue @@ -31,7 +31,7 @@ export default { EVENT_LABEL_MODAL, enableKasPath: helpPagePath('administration/clusters/kas'), registerAgentPath: helpPagePath('user/clusters/agent/install/index', { - anchor: 'register-an-agent-with-gitlab', + anchor: 'register-the-agent-with-gitlab', }), components: { AvailableAgentsDropdown, diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index c914ee518b2..4a168e811aa 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -90,26 +90,20 @@ export const I18N_AGENT_TABLE = { export const I18N_AGENT_TOKEN = { copyToken: s__('ClusterAgents|Copy token'), copyCommand: s__('ClusterAgents|Copy command'), - tokenTitle: s__('ClusterAgents|Registration token'), - - tokenBody: s__( - `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`, - ), + tokenLabel: s__('ClusterAgents|Agent access token:'), tokenSingleUseWarningTitle: s__( 'ClusterAgents|You cannot see this token again after you close this window.', ), - tokenSingleUseWarningBody: s__( - `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`, - ), + tokenSubtitle: s__('ClusterAgents|The agent uses the token to connect with GitLab.'), - basicInstallTitle: s__('ClusterAgents|Recommended installation method'), - basicInstallBody: __( - `Open a CLI and connect to the cluster you want to install the agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`, + basicInstallTitle: s__('ClusterAgents|Install using Helm (recommended)'), + basicInstallBody: s__( + 'ClusterAgents|From a terminal, connect to your cluster and run this command. The token is included in the command.', ), advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'), advancedInstallBody: s__( - 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.', + 'ClusterAgents|%{linkStart}View the documentation%{linkEnd} for advanced installation. Ensure you have your access token available.', ), }; @@ -118,20 +112,15 @@ export const I18N_AGENT_MODAL = { close: __('Close'), cancel: __('Cancel'), - modalTitle: s__('ClusterAgents|Connect a cluster through an agent'), + modalTitle: s__('ClusterAgents|Connect a Kubernetes cluster'), modalBody: s__( 'ClusterAgents|Add an agent configuration file to %{linkStart}this repository%{linkEnd} and select it, or create a new one to register with GitLab:', ), enableKasText: s__( "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.", ), - altText: s__('ClusterAgents|GitLab Agent for Kubernetes'), + altText: s__('ClusterAgents|GitLab agent for Kubernetes'), learnMoreLink: s__('ClusterAgents|How do I register an agent?'), - copyToken: s__('ClusterAgents|Copy token'), - tokenTitle: s__('ClusterAgents|Registration token'), - tokenBody: s__( - `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`, - ), registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'), unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), }; @@ -180,16 +169,14 @@ export const AGENT_STATUSES = { export const I18N_AGENTS_EMPTY_STATE = { introText: s__( - 'ClusterIntegration|Use the %{linkStart}GitLab Agent%{linkEnd} to safely connect your Kubernetes clusters to GitLab. You can deploy your applications, run your pipelines, use Review Apps, and much more.', + 'ClusterIntegration|Use the %{linkStart}GitLab agent%{linkEnd} to safely connect your Kubernetes clusters to GitLab. You can deploy your applications, run your pipelines, use Review Apps, and much more.', ), - buttonText: s__('ClusterAgents|Connect with the GitLab Agent'), }; export const I18N_CLUSTERS_EMPTY_STATE = { introText: s__( 'ClusterIntegration|Connect your cluster to GitLab through %{linkStart}cluster certificates%{linkEnd}.', ), - buttonText: s__('ClusterIntegration|Connect with a certificate'), alertText: s__( 'ClusterIntegration|The certificate-based method to connect clusters to GitLab was %{linkStart}deprecated%{linkEnd} in GitLab 14.5.', ), @@ -201,19 +188,15 @@ export const AGENT_CARD_INFO = { emptyTitle: s__('ClusterAgents|No agents'), tooltip: { label: s__('ClusterAgents|Recommended'), - title: s__('ClusterAgents|GitLab Agent'), + title: s__('ClusterAgents|GitLab agent'), text: sprintf( s__( - 'ClusterAgents|The GitLab Agent provides an increased level of security when connecting Kubernetes clusters to GitLab. %{linkStart}Learn more about the GitLab Agent.%{linkEnd}', + 'ClusterAgents|The GitLab agent provides an increased level of security when connecting Kubernetes clusters to GitLab. %{linkStart}Learn more about the GitLab agent.%{linkEnd}', ), ), link: helpPagePath('user/clusters/agent/index'), }, - actionText: s__('ClusterAgents|Install a new agent'), footerText: sprintf(s__('ClusterAgents|View all %{number} agents')), - installAgentDisabledHint: s__( - 'ClusterAgents|Requires a Maintainer or greater role to install new agents', - ), }; export const CERTIFICATE_BASED_CARD_INFO = { @@ -222,12 +205,8 @@ export const CERTIFICATE_BASED_CARD_INFO = { s__('ClusterAgents|%{number} of %{total} clusters connected through cluster certificates'), ), emptyTitle: s__('ClusterAgents|No clusters connected through cluster certificates'), - actionText: s__('ClusterAgents|Connect existing cluster'), footerText: sprintf(s__('ClusterAgents|View all %{number} clusters')), badgeText: s__('ClusterAgents|Deprecated'), - connectExistingClusterDisabledHint: s__( - 'ClusterAgents|Requires a maintainer or greater role to connect existing clusters', - ), }; export const MAX_CLUSTERS_LIST = 6; @@ -252,12 +231,13 @@ export const CERTIFICATE_TAB = { export const CLUSTERS_TABS = [ALL_TAB, AGENT_TAB, CERTIFICATE_TAB]; export const CLUSTERS_ACTIONS = { - actionsButton: s__('ClusterAgents|Actions'), - createNewCluster: s__('ClusterAgents|Create a new cluster'), - connectWithAgent: s__('ClusterAgents|Connect with an agent'), - connectExistingCluster: s__('ClusterAgents|Connect with a certificate'), - agent: s__('ClusterAgents|Agent'), - certificate: s__('ClusterAgents|Certificate'), + connectCluster: s__('ClusterAgents|Connect a cluster'), + connectWithAgent: s__('ClusterAgents|Connect a cluster (agent)'), + connectClusterDeprecated: s__('ClusterAgents|Connect a cluster (deprecated)'), + createClusterDeprecated: s__('ClusterAgents|Create a cluster (deprecated)'), + createCluster: s__('ClusterAgents|Create a cluster'), + createClusterCertificate: s__('ClusterAgents|Create a cluster (certificate - deprecated)'), + connectClusterCertificate: s__('ClusterAgents|Connect a cluster (certificate - deprecated)'), dropdownDisabledHint: s__( 'ClusterAgents|Requires a Maintainer or greater role to perform these actions', ), diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql index 7743ffba5de..76920a0aef4 100644 --- a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql +++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" #import "../fragments/cluster_agent.fragment.graphql" query getAgents( diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js index 27eebc9d891..f6dfb96ffd9 100644 --- a/app/assets/javascripts/clusters_list/index.js +++ b/app/assets/javascripts/clusters_list/index.js @@ -25,6 +25,7 @@ export default () => { kasAddress, newClusterPath, addClusterPath, + newClusterDocsPath, emptyStateHelpText, clustersEmptyStateImage, canAddCluster, @@ -43,6 +44,7 @@ export default () => { kasAddress, newClusterPath, addClusterPath, + newClusterDocsPath, emptyStateHelpText, clustersEmptyStateImage, canAddCluster: parseBoolean(canAddCluster), diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue index 5c77f087d63..81edbb4182e 100644 --- a/app/assets/javascripts/code_navigation/components/app.vue +++ b/app/assets/javascripts/code_navigation/components/app.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapState } from 'vuex'; -import eventHub from '../../notes/event_hub'; +import eventHub from '~/notes/event_hub'; import Popover from './popover.vue'; export default { @@ -23,6 +23,11 @@ export default { required: false, default: null, }, + wrapTextNodes: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapState([ @@ -37,6 +42,7 @@ export default { const initialData = { blobs: [{ path: this.blobPath, codeNavigationPath: this.codeNavigationPath }], definitionPathPrefix: this.pathPrefix, + wrapTextNodes: this.wrapTextNodes, }; this.setInitialData(initialData); } diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js index 0b6b8437db5..562b78a891a 100644 --- a/app/assets/javascripts/code_navigation/store/actions.js +++ b/app/assets/javascripts/code_navigation/store/actions.js @@ -22,7 +22,7 @@ export default { ...d, definitionLineNumber: parseInt(d.definition_path?.split('#L').pop() || 0, 10), }; - addInteractionClass(path, d); + addInteractionClass({ path, d, wrapTextNodes: state.wrapTextNodes }); } return acc; }, {}); @@ -34,7 +34,9 @@ export default { }, showBlobInteractionZones({ state }, path) { if (state.data && state.data[path]) { - Object.values(state.data[path]).forEach((d) => addInteractionClass(path, d)); + Object.values(state.data[path]).forEach((d) => + addInteractionClass({ path, d, wrapTextNodes: state.wrapTextNodes }), + ); } }, showDefinition({ commit, state }, { target: el }) { diff --git a/app/assets/javascripts/code_navigation/store/mutations.js b/app/assets/javascripts/code_navigation/store/mutations.js index 07b190c7476..98beffe231c 100644 --- a/app/assets/javascripts/code_navigation/store/mutations.js +++ b/app/assets/javascripts/code_navigation/store/mutations.js @@ -1,9 +1,10 @@ import * as types from './mutation_types'; export default { - [types.SET_INITIAL_DATA](state, { blobs, definitionPathPrefix }) { + [types.SET_INITIAL_DATA](state, { blobs, definitionPathPrefix, wrapTextNodes }) { state.blobs = blobs; state.definitionPathPrefix = definitionPathPrefix; + state.wrapTextNodes = wrapTextNodes; }, [types.REQUEST_DATA](state) { state.loading = true; diff --git a/app/assets/javascripts/code_navigation/store/state.js b/app/assets/javascripts/code_navigation/store/state.js index 569d2f7b319..17505b8392c 100644 --- a/app/assets/javascripts/code_navigation/store/state.js +++ b/app/assets/javascripts/code_navigation/store/state.js @@ -2,6 +2,7 @@ export default () => ({ blobs: [], loading: false, data: null, + wrapTextNodes: false, currentDefinition: null, currentDefinitionPosition: null, currentBlobPath: null, diff --git a/app/assets/javascripts/code_navigation/utils/dom_utils.js b/app/assets/javascripts/code_navigation/utils/dom_utils.js new file mode 100644 index 00000000000..1a65c1a64a2 --- /dev/null +++ b/app/assets/javascripts/code_navigation/utils/dom_utils.js @@ -0,0 +1,31 @@ +const TEXT_NODE = 3; + +const isTextNode = ({ nodeType }) => nodeType === TEXT_NODE; + +const isBlank = (str) => !str || /^\s*$/.test(str); + +const isMatch = (s1, s2) => !isBlank(s1) && s1.trim() === s2.trim(); + +const createSpan = (content) => { + const span = document.createElement('span'); + span.innerText = content; + return span; +}; + +const wrapSpacesWithSpans = (text) => text.replace(/ /g, createSpan(' ').outerHTML); + +const wrapTextWithSpan = (el, text) => { + if (isTextNode(el) && isMatch(el.textContent, text)) { + const newEl = createSpan(text.trim()); + el.replaceWith(newEl); + } +}; + +const wrapNodes = (text) => { + const wrapper = createSpan(); + wrapper.innerHTML = wrapSpacesWithSpans(text); + wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text)); + return wrapper.childNodes; +}; + +export { wrapNodes, isTextNode }; diff --git a/app/assets/javascripts/code_navigation/utils/index.js b/app/assets/javascripts/code_navigation/utils/index.js index 6c078891ed4..0d72153d8fe 100644 --- a/app/assets/javascripts/code_navigation/utils/index.js +++ b/app/assets/javascripts/code_navigation/utils/index.js @@ -1,9 +1,11 @@ +import { wrapNodes, isTextNode } from './dom_utils'; + export const cachedData = new Map(); export const getCurrentHoverElement = () => cachedData.get('current'); export const setCurrentHoverElement = (el) => cachedData.set('current', el); -export const addInteractionClass = (path, d) => { +export const addInteractionClass = ({ path, d, wrapTextNodes }) => { const lineNumber = d.start_line + 1; const lines = document .querySelector(`[data-path="${path}"]`) @@ -12,13 +14,24 @@ export const addInteractionClass = (path, d) => { lines.forEach((line) => { let charCount = 0; + + if (wrapTextNodes) { + line.childNodes.forEach((elm) => { + if (isTextNode(elm)) { + // Highlight.js does not wrap all text nodes by default + // We need all text nodes to be wrapped in order to append code nav attributes + elm.replaceWith(...wrapNodes(elm.textContent)); + } + }); + } + const el = [...line.childNodes].find(({ textContent }) => { if (charCount === d.start_char) return true; charCount += textContent.length; return false; }); - if (el) { + if (el && !isTextNode(el)) { el.setAttribute('data-char-index', d.start_char); el.setAttribute('data-line-index', d.start_line); el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation'); diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index 59066162960..32d9159ee34 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -1,8 +1,8 @@ <script> import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import createFlash from '~/flash'; -import Api from '../../api'; -import { __ } from '../../locale'; +import Api from '~/api'; +import { __ } from '~/locale'; import state from '../state'; import Dropdown from './dropdown.vue'; @@ -87,7 +87,7 @@ export default { }, showWarning() { if (this.warningText) { - this.warningText.classList.remove('hidden'); + this.warningText.classList.remove('gl-display-none'); } if (this.createBtn) { @@ -120,7 +120,7 @@ export default { :selected-project="selectedProject" @click="selectProject" /> - <p class="text-muted mt-1 mb-0"> + <p class="gl-text-gray-600 gl-mt-1 gl-mb-0"> <template v-if="projects.length"> {{ $options.i18n.privateForkSelected }} </template> @@ -134,7 +134,7 @@ export default { </template> <gl-link :href="helpPagePath" - class="w-auto p-0 d-inline-block text-primary bg-transparent" + class="gl-w-auto gl-p-0 gl-display-inline-block gl-bg-transparent" target="_blank" > <span class="sr-only">{{ $options.i18n.readMore }}</span> diff --git a/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue new file mode 100644 index 00000000000..87f22a27856 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue @@ -0,0 +1,146 @@ +<script> +import { + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import codeBlockLanguageLoader from '../services/code_block_language_loader'; +import CodeBlockHighlight from '../extensions/code_block_highlight'; +import Diagram from '../extensions/diagram'; +import Frontmatter from '../extensions/frontmatter'; +import EditorStateObserver from './editor_state_observer.vue'; + +const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; + +export default { + components: { + BubbleMenu, + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + EditorStateObserver, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor'], + data() { + return { + selectedLanguage: {}, + filterTerm: '', + filteredLanguages: [], + }; + }, + watch: { + filterTerm: { + handler(val) { + this.filteredLanguages = codeBlockLanguageLoader.filterLanguages(val); + }, + immediate: true, + }, + }, + methods: { + shouldShow: ({ editor }) => { + return CODE_BLOCK_NODE_TYPES.some((type) => editor.isActive(type)); + }, + + getSelectedLanguage() { + const { language } = this.tiptapEditor.getAttributes(this.getCodeBlockType()); + + this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language); + }, + + async setSelectedLanguage(language) { + this.selectedLanguage = language; + + await codeBlockLanguageLoader.loadLanguages([language.syntax]); + + this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax }); + }, + + tippyOnBeforeUpdate(tippy, props) { + if (props.getReferenceClientRect) { + // eslint-disable-next-line no-param-reassign + props.getReferenceClientRect = () => { + const { view } = this.tiptapEditor; + const { from } = this.tiptapEditor.state.selection; + + for (let { node } = view.domAtPos(from); node; node = node.parentElement) { + if (node.nodeName?.toLowerCase() === 'pre') { + return node.getBoundingClientRect(); + } + } + + return new DOMRect(-1000, -1000, 0, 0); + }; + } + }, + + deleteCodeBlock() { + this.tiptapEditor.chain().focus().deleteNode(this.getCodeBlockType()).run(); + }, + + getCodeBlockType() { + return ( + CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)) || + CodeBlockHighlight.name + ); + }, + }, +}; +</script> +<template> + <bubble-menu + data-testid="code-block-bubble-menu" + class="gl-shadow gl-rounded-base" + :editor="tiptapEditor" + plugin-key="bubbleMenuCodeBlock" + :should-show="shouldShow" + :tippy-options="{ onBeforeUpdate: tippyOnBeforeUpdate }" + > + <editor-state-observer @transaction="getSelectedLanguage"> + <gl-button-group> + <gl-dropdown contenteditable="false" boundary="viewport" :text="selectedLanguage.label"> + <template #header> + <gl-search-box-by-type + v-model="filterTerm" + :clear-button-title="__('Clear')" + :placeholder="__('Search')" + /> + </template> + + <template #highlighted-items> + <gl-dropdown-item :key="selectedLanguage.syntax" is-check-item :is-checked="true"> + {{ selectedLanguage.label }} + </gl-dropdown-item> + </template> + + <gl-dropdown-item + v-for="language in filteredLanguages" + v-show="selectedLanguage.syntax !== language.syntax" + :key="language.syntax" + @click="setSelectedLanguage(language)" + > + {{ language.label }} + </gl-dropdown-item> + </gl-dropdown> + <gl-button + v-gl-tooltip + variant="default" + category="primary" + size="medium" + :aria-label="__('Delete code block')" + :title="__('Delete code block')" + icon="remove" + @click="deleteCodeBlock" + /> + </gl-button-group> + </editor-state-observer> + </bubble-menu> +</template> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index a942c9f1149..5b3f4f4ddf2 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -5,6 +5,7 @@ import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorProvider from './content_editor_provider.vue'; import EditorStateObserver from './editor_state_observer.vue'; import FormattingBubbleMenu from './formatting_bubble_menu.vue'; +import CodeBlockBubbleMenu from './code_block_bubble_menu.vue'; import TopToolbar from './top_toolbar.vue'; import LoadingIndicator from './loading_indicator.vue'; @@ -16,6 +17,7 @@ export default { TiptapEditorContent, TopToolbar, FormattingBubbleMenu, + CodeBlockBubbleMenu, EditorStateObserver, }, props: { @@ -89,6 +91,7 @@ export default { <top-toolbar ref="toolbar" class="gl-mb-4" /> <div class="gl-relative"> <formatting-bubble-menu /> + <code-block-bubble-menu /> <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> <loading-indicator /> </div> diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue index 14a553ff30b..103079534bc 100644 --- a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue @@ -3,6 +3,10 @@ import { GlButtonGroup } from '@gitlab/ui'; import { BubbleMenu } from '@tiptap/vue-2'; import { BUBBLE_MENU_TRACKING_ACTION } from '../constants'; import trackUIControl from '../services/track_ui_control'; +import Code from '../extensions/code'; +import CodeBlockHighlight from '../extensions/code_block_highlight'; +import Diagram from '../extensions/diagram'; +import Frontmatter from '../extensions/frontmatter'; import ToolbarButton from './toolbar_button.vue'; export default { @@ -16,6 +20,14 @@ export default { trackToolbarControlExecution({ contentType, value }) { trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: contentType, value }); }, + + shouldShow: ({ editor, from, to }) => { + if (from === to) return false; + + const exclude = [Code.name, CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; + + return !exclude.some((type) => editor.isActive(type)); + }, }, }; </script> @@ -24,6 +36,7 @@ export default { data-testid="formatting-bubble-menu" class="gl-shadow gl-rounded-base" :editor="tiptapEditor" + :should-show="shouldShow" > <gl-button-group> <toolbar-button diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue index 5b9383d6e11..620324adb06 100644 --- a/app/assets/javascripts/content_editor/components/loading_indicator.vue +++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue @@ -30,6 +30,7 @@ export default { > <div v-if="isLoading" + data-testid="content-editor-loading-indicator" class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0" > <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div> diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue deleted file mode 100644 index 5b81e5fddcc..00000000000 --- a/app/assets/javascripts/content_editor/components/wrappers/image.vue +++ /dev/null @@ -1,32 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { NodeViewWrapper } from '@tiptap/vue-2'; - -export default { - name: 'ImageWrapper', - components: { - NodeViewWrapper, - GlLoadingIcon, - }, - props: { - node: { - type: Object, - required: true, - }, - }, -}; -</script> -<template> - <node-view-wrapper class="gl-display-inline-block"> - <span class="gl-relative"> - <img - data-testid="image" - class="gl-max-w-full gl-h-auto" - :title="node.attrs.title" - :class="{ 'gl-opacity-5': node.attrs.uploading }" - :src="node.attrs.src" - /> - <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" /> - </span> - </node-view-wrapper> -</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/media.vue b/app/assets/javascripts/content_editor/components/wrappers/media.vue new file mode 100644 index 00000000000..37119bdd066 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/media.vue @@ -0,0 +1,51 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { NodeViewWrapper } from '@tiptap/vue-2'; + +const tagNameMap = { + image: 'img', + video: 'video', + audio: 'audio', +}; + +export default { + name: 'MediaWrapper', + components: { + NodeViewWrapper, + GlLoadingIcon, + }, + props: { + node: { + type: Object, + required: true, + }, + }, + computed: { + tagName() { + return tagNameMap[this.node.type.name] || 'img'; + }, + }, +}; +</script> +<template> + <node-view-wrapper class="gl-display-inline-block"> + <span class="gl-relative" :class="{ [`media-container ${tagName}-container`]: true }"> + <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" /> + <component + :is="tagName" + data-testid="media" + :class="{ + 'gl-max-w-full gl-h-auto': tagName !== 'audio', + 'gl-opacity-5': node.attrs.uploading, + }" + :title="node.attrs.title || node.attrs.alt" + :alt="node.attrs.alt" + :src="node.attrs.src" + controls="true" + /> + <a v-if="tagName !== 'img'" :href="node.attrs.canonicalSrc || node.attrs.src" @click.prevent> + {{ node.attrs.title || node.attrs.alt }} + </a> + </span> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 204ac07d401..61f379fc0a2 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -1,10 +1,21 @@ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; -import { lowlight } from 'lowlight/lib/all'; +import { textblockTypeInputRule } from '@tiptap/core'; +import codeBlockLanguageLoader from '../services/code_block_language_loader'; const extractLanguage = (element) => element.getAttribute('lang'); +export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/; +export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; export default CodeBlockLowlight.extend({ isolating: true, + exitOnArrowDown: false, + + addOptions() { + return { + ...this.parent?.(), + languageLoader: codeBlockLanguageLoader, + }; + }, addAttributes() { return { @@ -18,16 +29,40 @@ export default CodeBlockLowlight.extend({ }, }; }, + addInputRules() { + const { languageLoader } = this.options; + const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {}; + + return [ + textblockTypeInputRule({ + find: backtickInputRegex, + type: this.type, + getAttributes, + }), + textblockTypeInputRule({ + find: tildeInputRegex, + type: this.type, + getAttributes, + }), + ]; + }, + parseHTML() { + return [ + ...(this.parent?.() || []), + { + tag: 'div.markdown-code-block', + skip: true, + }, + ]; + }, renderHTML({ HTMLAttributes }) { return [ 'pre', { ...HTMLAttributes, - class: `content-editor-code-block ${HTMLAttributes.class}`, + class: `content-editor-code-block ${gon.user_color_scheme} ${HTMLAttributes.class}`, }, ['code', {}, 0], ]; }, -}).configure({ - lowlight, }); diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js new file mode 100644 index 00000000000..d192b815092 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/diagram.js @@ -0,0 +1,56 @@ +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import CodeBlockHighlight from './code_block_highlight'; + +export default CodeBlockHighlight.extend({ + name: 'diagram', + + isolating: true, + + addAttributes() { + return { + language: { + default: null, + parseHTML: (element) => { + return element.dataset.diagram; + }, + }, + }; + }, + + parseHTML() { + return [ + { + priority: PARSE_HTML_PRIORITY_HIGHEST, + tag: '[data-diagram]', + getContent(element, schema) { + const source = atob(element.dataset.diagramSrc.replace('data:text/plain;base64,', '')); + const node = schema.node('paragraph', {}, [schema.text(source)]); + return node.content; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes: { language, ...HTMLAttributes } }) { + return [ + 'div', + [ + 'pre', + { + language, + class: `content-editor-code-block code highlight`, + ...HTMLAttributes, + }, + ['code', {}, 0], + ], + ]; + }, + + addCommands() { + return {}; + }, + + addInputRules() { + return []; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 519f7f168ce..311db8151cb 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,6 +1,6 @@ import { Image } from '@tiptap/extension-image'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import ImageWrapper from '../components/wrappers/image.vue'; +import MediaWrapper from '../components/wrappers/media.vue'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; const resolveImageEl = (element) => @@ -78,6 +78,6 @@ export default Image.extend({ ]; }, addNodeView() { - return VueNodeViewRenderer(ImageWrapper); + return VueNodeViewRenderer(MediaWrapper); }, }); diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js index 0062bc563db..2c5269377c5 100644 --- a/app/assets/javascripts/content_editor/extensions/playable.js +++ b/app/assets/javascripts/content_editor/extensions/playable.js @@ -1,6 +1,8 @@ /* eslint-disable @gitlab/require-i18n-strings */ import { Node } from '@tiptap/core'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import MediaWrapper from '../components/wrappers/media.vue'; const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType); @@ -11,6 +13,9 @@ export default Node.create({ addAttributes() { return { + uploading: { + default: false, + }, src: { default: null, parseHTML: (element) => { @@ -60,7 +65,11 @@ export default Node.create({ ...this.extraElementAttrs, }, ], - ['a', { href: node.attrs.src }, node.attrs.alt], + ['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''], ]; }, + + addNodeView() { + return VueNodeViewRenderer(MediaWrapper); + }, }); diff --git a/app/assets/javascripts/content_editor/services/code_block_language_loader.js b/app/assets/javascripts/content_editor/services/code_block_language_loader.js new file mode 100644 index 00000000000..081400cfd9a --- /dev/null +++ b/app/assets/javascripts/content_editor/services/code_block_language_loader.js @@ -0,0 +1,283 @@ +import { lowlight } from 'lowlight/lib/core'; +import { __, sprintf } from '~/locale'; + +/* eslint-disable @gitlab/require-i18n-strings */ +// List of languages referenced from https://github.com/wooorm/lowlight#data +const CODE_BLOCK_LANGUAGES = [ + { syntax: '1c', label: '1C:Enterprise' }, + { syntax: 'abnf', label: 'Augmented Backus-Naur Form' }, + { syntax: 'accesslog', label: 'Apache Access Log' }, + { syntax: 'actionscript', variants: 'as', label: 'ActionScript' }, + { syntax: 'ada', label: 'Ada' }, + { syntax: 'angelscript', variants: 'asc', label: 'AngelScript' }, + { syntax: 'apache', variants: 'apacheconf', label: 'Apache config' }, + { syntax: 'applescript', variants: 'osascript', label: 'AppleScript' }, + { syntax: 'arcade', label: 'ArcGIS Arcade' }, + { syntax: 'arduino', variants: 'ino', label: 'Arduino' }, + { syntax: 'armasm', variants: 'arm', label: 'ARM Assembly' }, + { syntax: 'asciidoc', variants: 'adoc', label: 'AsciiDoc' }, + { syntax: 'aspectj', label: 'AspectJ' }, + { syntax: 'autohotkey', variants: 'ahk', label: 'AutoHotkey' }, + { syntax: 'autoit', label: 'AutoIt' }, + { syntax: 'avrasm', label: 'AVR Assembly' }, + { syntax: 'awk', label: 'Awk' }, + { syntax: 'axapta', variants: 'x++', label: 'X++' }, + { syntax: 'bash', variants: 'sh', label: 'Bash' }, + { syntax: 'basic', label: 'BASIC' }, + { syntax: 'bnf', label: 'Backus-Naur Form' }, + { syntax: 'brainfuck', variants: 'bf', label: 'Brainfuck' }, + { syntax: 'c', variants: 'h', label: 'C' }, + { syntax: 'cal', label: 'C/AL' }, + { syntax: 'capnproto', variants: 'capnp', label: "Cap'n Proto" }, + { syntax: 'ceylon', label: 'Ceylon' }, + { syntax: 'clean', variants: 'icl, dcl', label: 'Clean' }, + { syntax: 'clojure', variants: 'clj, edn', label: 'Clojure' }, + { syntax: 'clojure-repl', label: 'Clojure REPL' }, + { syntax: 'cmake', variants: 'cmake.in', label: 'CMake' }, + { syntax: 'coffeescript', variants: 'coffee, cson, iced', label: 'CoffeeScript' }, + { syntax: 'coq', label: 'Coq' }, + { syntax: 'cos', variants: 'cls', label: 'Caché Object Script' }, + { syntax: 'cpp', variants: 'cc, c++, h++, hpp, hh, hxx, cxx', label: 'C++' }, + { syntax: 'crmsh', variants: 'crm, pcmk', label: 'crmsh' }, + { syntax: 'crystal', variants: 'cr', label: 'Crystal' }, + { syntax: 'csharp', variants: 'cs, c#', label: 'C#' }, + { syntax: 'csp', label: 'CSP' }, + { syntax: 'css', label: 'CSS' }, + { syntax: 'd', label: 'D' }, + { syntax: 'dart', label: 'Dart' }, + { syntax: 'delphi', variants: 'dpr, dfm, pas, pascal', label: 'Delphi' }, + { syntax: 'diff', variants: 'patch', label: 'Diff' }, + { syntax: 'django', variants: 'jinja', label: 'Django' }, + { syntax: 'dns', variants: 'bind, zone', label: 'DNS Zone' }, + { syntax: 'dockerfile', variants: 'docker', label: 'Dockerfile' }, + { syntax: 'dos', variants: 'bat, cmd', label: 'Batch file (DOS)' }, + { syntax: 'dsconfig', label: 'DSConfig' }, + { syntax: 'dts', label: 'Device Tree' }, + { syntax: 'dust', variants: 'dst', label: 'Dust' }, + { syntax: 'ebnf', label: 'Extended Backus-Naur Form' }, + { syntax: 'elixir', variants: 'ex, exs', label: 'Elixir' }, + { syntax: 'elm', label: 'Elm' }, + { syntax: 'erb', label: 'ERB' }, + { syntax: 'erlang', variants: 'erl', label: 'Erlang' }, + { syntax: 'erlang-repl', label: 'Erlang REPL' }, + { syntax: 'excel', variants: 'xlsx, xls', label: 'Excel formulae' }, + { syntax: 'fix', label: 'FIX' }, + { syntax: 'flix', label: 'Flix' }, + { syntax: 'fortran', variants: 'f90, f95', label: 'Fortran' }, + { syntax: 'fsharp', variants: 'fs, f#', label: 'F#' }, + { syntax: 'gams', variants: 'gms', label: 'GAMS' }, + { syntax: 'gauss', variants: 'gss', label: 'GAUSS' }, + { syntax: 'gcode', variants: 'nc', label: 'G-code (ISO 6983)' }, + { syntax: 'gherkin', variants: 'feature', label: 'Gherkin' }, + { syntax: 'glsl', label: 'GLSL' }, + { syntax: 'gml', label: 'GML' }, + { syntax: 'go', variants: 'golang', label: 'Go' }, + { syntax: 'golo', label: 'Golo' }, + { syntax: 'gradle', label: 'Gradle' }, + { syntax: 'graphql', variants: 'gql', label: 'GraphQL' }, + { syntax: 'groovy', label: 'Groovy' }, + { syntax: 'haml', label: 'HAML' }, + { + syntax: 'handlebars', + variants: 'hbs, html.hbs, html.handlebars, htmlbars', + label: 'Handlebars', + }, + { syntax: 'haskell', variants: 'hs', label: 'Haskell' }, + { syntax: 'haxe', variants: 'hx', label: 'Haxe' }, + { syntax: 'hsp', label: 'HSP' }, + { syntax: 'http', variants: 'https', label: 'HTTP' }, + { syntax: 'hy', variants: 'hylang', label: 'Hy' }, + { syntax: 'inform7', variants: 'i7', label: 'Inform 7' }, + { syntax: 'ini', variants: 'toml', label: 'TOML, also INI' }, + { syntax: 'irpf90', label: 'IRPF90' }, + { syntax: 'isbl', label: 'ISBL' }, + { syntax: 'java', variants: 'jsp', label: 'Java' }, + { syntax: 'javascript', variants: 'js, jsx, mjs, cjs', label: 'Javascript' }, + { syntax: 'jboss-cli', variants: 'wildfly-cli', label: 'JBoss CLI' }, + { syntax: 'json', label: 'JSON' }, + { syntax: 'julia', label: 'Julia' }, + { syntax: 'julia-repl', variants: 'jldoctest', label: 'Julia REPL' }, + { syntax: 'kotlin', variants: 'kt, kts', label: 'Kotlin' }, + { syntax: 'lasso', variants: 'ls, lassoscript', label: 'Lasso' }, + { syntax: 'latex', variants: 'tex', label: 'LaTeX' }, + { syntax: 'ldif', label: 'LDIF' }, + { syntax: 'leaf', label: 'Leaf' }, + { syntax: 'less', label: 'Less' }, + { syntax: 'lisp', label: 'Lisp' }, + { syntax: 'livecodeserver', label: 'LiveCode' }, + { syntax: 'livescript', variants: 'ls', label: 'LiveScript' }, + { syntax: 'llvm', label: 'LLVM IR' }, + { syntax: 'lsl', label: 'LSL (Linden Scripting Language)' }, + { syntax: 'lua', label: 'Lua' }, + { syntax: 'makefile', variants: 'mk, mak, make', label: 'Makefile' }, + { syntax: 'markdown', variants: 'md, mkdown, mkd', label: 'Markdown' }, + { syntax: 'mathematica', variants: 'mma, wl', label: 'Mathematica' }, + { syntax: 'matlab', label: 'Matlab' }, + { syntax: 'maxima', label: 'Maxima' }, + { syntax: 'mel', label: 'MEL' }, + { syntax: 'mercury', variants: 'm, moo', label: 'Mercury' }, + { syntax: 'mipsasm', variants: 'mips', label: 'MIPS Assembly' }, + { syntax: 'mizar', label: 'Mizar' }, + { syntax: 'mojolicious', label: 'Mojolicious' }, + { syntax: 'monkey', label: 'Monkey' }, + { syntax: 'moonscript', variants: 'moon', label: 'MoonScript' }, + { syntax: 'n1ql', label: 'N1QL' }, + { syntax: 'nestedtext', variants: 'nt', label: 'Nested Text' }, + { syntax: 'nginx', variants: 'nginxconf', label: 'Nginx config' }, + { syntax: 'nim', label: 'Nim' }, + { syntax: 'nix', variants: 'nixos', label: 'Nix' }, + { syntax: 'node-repl', label: 'Node REPL' }, + { syntax: 'nsis', label: 'NSIS' }, + { + syntax: 'objectivec', + variants: 'mm, objc, obj-c, obj-c++, objective-c++', + label: 'Objective-C', + }, + { syntax: 'ocaml', variants: 'ml', label: 'OCaml' }, + { syntax: 'openscad', variants: 'scad', label: 'OpenSCAD' }, + { syntax: 'oxygene', label: 'Oxygene' }, + { syntax: 'parser3', label: 'Parser3' }, + { syntax: 'perl', variants: 'pl, pm', label: 'Perl' }, + { syntax: 'pf', variants: 'pf.conf', label: 'Packet Filter config' }, + { syntax: 'pgsql', variants: 'postgres, postgresql', label: 'PostgreSQL' }, + { syntax: 'php', label: 'PHP' }, + { syntax: 'php-template', label: 'PHP template' }, + { syntax: 'plaintext', variants: 'text, txt', label: 'Plain text' }, + { syntax: 'pony', label: 'Pony' }, + { syntax: 'powershell', variants: 'pwsh, ps, ps1', label: 'PowerShell' }, + { syntax: 'processing', variants: 'pde', label: 'Processing' }, + { syntax: 'profile', label: 'Python profiler' }, + { syntax: 'prolog', label: 'Prolog' }, + { syntax: 'properties', label: '.properties' }, + { syntax: 'protobuf', label: 'Protocol Buffers' }, + { syntax: 'puppet', variants: 'pp', label: 'Puppet' }, + { syntax: 'purebasic', variants: 'pb, pbi', label: 'PureBASIC' }, + { syntax: 'python', variants: 'py, gyp, ipython', label: 'Python' }, + { syntax: 'python-repl', variants: 'pycon', label: 'Python REPL' }, + { syntax: 'q', variants: 'k, kdb', label: 'Q' }, + { syntax: 'qml', variants: 'qt', label: 'QML' }, + { syntax: 'r', label: 'R' }, + { syntax: 'reasonml', variants: 're', label: 'ReasonML' }, + { syntax: 'rib', label: 'RenderMan RIB' }, + { syntax: 'roboconf', variants: 'graph, instances', label: 'Roboconf' }, + { syntax: 'routeros', variants: 'mikrotik', label: 'Microtik RouterOS script' }, + { syntax: 'rsl', label: 'RenderMan RSL' }, + { syntax: 'ruby', variants: 'rb, gemspec, podspec, thor, irb', label: 'Ruby' }, + { syntax: 'ruleslanguage', label: 'Oracle Rules Language' }, + { syntax: 'rust', variants: 'rs', label: 'Rust' }, + { syntax: 'sas', label: 'SAS' }, + { syntax: 'scala', label: 'Scala' }, + { syntax: 'scheme', label: 'Scheme' }, + { syntax: 'scilab', variants: 'sci', label: 'Scilab' }, + { syntax: 'scss', label: 'SCSS' }, + { syntax: 'shell', variants: 'console, shellsession', label: 'Shell Session' }, + { syntax: 'smali', label: 'Smali' }, + { syntax: 'smalltalk', variants: 'st', label: 'Smalltalk' }, + { syntax: 'sml', variants: 'ml', label: 'SML (Standard ML)' }, + { syntax: 'sqf', label: 'SQF' }, + { syntax: 'sql', label: 'SQL' }, + { syntax: 'stan', variants: 'stanfuncs', label: 'Stan' }, + { syntax: 'stata', variants: 'do, ado', label: 'Stata' }, + { syntax: 'step21', variants: 'p21, step, stp', label: 'STEP Part 21' }, + { syntax: 'stylus', variants: 'styl', label: 'Stylus' }, + { syntax: 'subunit', label: 'SubUnit' }, + { syntax: 'swift', label: 'Swift' }, + { syntax: 'taggerscript', label: 'Tagger Script' }, + { syntax: 'tap', label: 'Test Anything Protocol' }, + { syntax: 'tcl', variants: 'tk', label: 'Tcl' }, + { syntax: 'thrift', label: 'Thrift' }, + { syntax: 'tp', label: 'TP' }, + { syntax: 'twig', variants: 'craftcms', label: 'Twig' }, + { syntax: 'typescript', variants: 'ts, tsx', label: 'TypeScript' }, + { syntax: 'vala', label: 'Vala' }, + { syntax: 'vbnet', variants: 'vb', label: 'Visual Basic .NET' }, + { syntax: 'vbscript', variants: 'vbs', label: 'VBScript' }, + { syntax: 'vbscript-html', label: 'VBScript in HTML' }, + { syntax: 'verilog', variants: 'v, sv, svh', label: 'Verilog' }, + { syntax: 'vhdl', label: 'VHDL' }, + { syntax: 'vim', label: 'Vim Script' }, + { syntax: 'wasm', label: 'WebAssembly' }, + { syntax: 'wren', label: 'Wren' }, + { syntax: 'x86asm', label: 'Intel x86 Assembly' }, + { syntax: 'xl', variants: 'tao', label: 'XL' }, + { + syntax: 'xml', + variants: 'html, xhtml, rss, atom, xjb, xsd, xsl, plist, wsf, svg', + label: 'HTML, XML', + }, + { syntax: 'xquery', variants: 'xpath, xq', label: 'XQuery' }, + { syntax: 'yaml', variants: 'yml', label: 'YAML' }, + { syntax: 'zephir', variants: 'zep', label: 'Zephir' }, +]; +/* eslint-enable @gitlab/require-i18n-strings */ + +const codeBlockLanguageLoader = { + lowlight, + + allLanguages: CODE_BLOCK_LANGUAGES, + + findLanguageBySyntax(value) { + const lowercaseValue = value?.toLowerCase() || 'plaintext'; + return ( + this.allLanguages.find( + ({ syntax, variants }) => + syntax === lowercaseValue || variants?.toLowerCase().split(', ').includes(lowercaseValue), + ) || { + syntax: lowercaseValue, + label: sprintf(__(`Custom (%{language})`), { language: lowercaseValue }), + } + ); + }, + + filterLanguages(value) { + if (!value) return this.allLanguages; + + const lowercaseValue = value?.toLowerCase() || ''; + return this.allLanguages.filter( + ({ syntax, label, variants }) => + syntax.toLowerCase().includes(lowercaseValue) || + label.toLowerCase().includes(lowercaseValue) || + variants?.toLowerCase().includes(lowercaseValue), + ); + }, + + isLanguageLoaded(language) { + return this.lowlight.registered(language); + }, + + loadLanguagesFromDOM(domTree) { + const languages = []; + + domTree.querySelectorAll('pre').forEach((preElement) => { + languages.push(preElement.getAttribute('lang')); + }); + + return this.loadLanguages(languages); + }, + + loadLanguageFromInputRule(match) { + const { syntax } = this.findLanguageBySyntax(match[1]); + + this.loadLanguages([syntax]); + + return { language: syntax }; + }, + + loadLanguages(languageList = []) { + const loaders = languageList + .filter((languageName) => !this.isLanguageLoaded(languageName)) + .map((languageName) => { + return import( + /* webpackChunkName: 'highlight.language.js' */ `highlight.js/lib/languages/${languageName}` + ) + .then(({ default: language }) => { + this.lowlight.registerLanguage(languageName, language); + }) + .catch(() => false); + }); + + return Promise.all(loaders); + }, +}; + +export default codeBlockLanguageLoader; diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index c5638da2daf..56badf965ee 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -3,11 +3,12 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro /* eslint-disable no-underscore-dangle */ export class ContentEditor { - constructor({ tiptapEditor, serializer, deserializer, eventHub }) { + constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) { this._tiptapEditor = tiptapEditor; this._serializer = serializer; this._deserializer = deserializer; this._eventHub = eventHub; + this._languageLoader = languageLoader; } get tiptapEditor() { @@ -34,23 +35,33 @@ export class ContentEditor { } async setSerializedContent(serializedContent) { - const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this; + const { + _tiptapEditor: editor, + _deserializer: deserializer, + _eventHub: eventHub, + _languageLoader: languageLoader, + } = this; const { doc, tr } = editor.state; const selection = TextSelection.create(doc, 0, doc.content.size); try { eventHub.$emit(LOADING_CONTENT_EVENT); - const { document } = await deserializer.deserialize({ + const result = await deserializer.deserialize({ schema: editor.schema, content: serializedContent, }); - if (document) { + if (Object.keys(result).length !== 0) { + const { document, dom } = result; + + await languageLoader.loadLanguagesFromDOM(dom); + tr.setSelection(selection) .replaceSelectionWith(document, false) .setMeta('preventUpdate', true); editor.view.dispatch(tr); } + eventHub.$emit(LOADING_SUCCESS_EVENT); } catch (e) { eventHub.$emit(LOADING_ERROR_EVENT, e); diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index d9d39a387d0..af19a0ab0e4 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -1,5 +1,6 @@ import { Editor } from '@tiptap/vue-2'; import { isFunction } from 'lodash'; +import { lowlight } from 'lowlight/lib/core'; import eventHubFactory from '~/helpers/event_hub_factory'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import Attachment from '../extensions/attachment'; @@ -14,6 +15,7 @@ import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; +import Diagram from '../extensions/diagram'; import Division from '../extensions/division'; import Document from '../extensions/document'; import Dropcursor from '../extensions/dropcursor'; @@ -58,6 +60,7 @@ import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import createMarkdownDeserializer from './markdown_deserializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; +import languageLoader from './code_block_language_loader'; const createTiptapEditor = ({ extensions = [], ...options } = {}) => new Editor({ @@ -91,12 +94,13 @@ export const createContentEditor = ({ BulletList, Code, ColorChip, - CodeBlockHighlight, + CodeBlockHighlight.configure({ lowlight, languageLoader }), DescriptionItem, DescriptionList, Details, DetailsContent, Document, + Diagram, Division, Dropcursor, Emoji, @@ -105,7 +109,7 @@ export const createContentEditor = ({ FootnoteDefinition, FootnoteReference, FootnotesSection, - Frontmatter, + Frontmatter.configure({ lowlight }), Gapcursor, HardBreak, Heading, @@ -144,5 +148,5 @@ export const createContentEditor = ({ const serializer = createMarkdownSerializer({ serializerConfig }); const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); - return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer }); + return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer, languageLoader }); }; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index eaaf69c3068..c2be7bc9195 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -13,6 +13,7 @@ import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; import Division from '../extensions/division'; +import Diagram from '../extensions/diagram'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; @@ -48,6 +49,7 @@ import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { isPlainURL, + renderCodeBlock, renderHardBreak, renderTable, renderTableCell, @@ -130,13 +132,8 @@ const defaultSerializerConfig = { } }, [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, - [CodeBlockHighlight.name]: (state, node) => { - state.write(`\`\`\`${node.attrs.language || ''}\n`); - state.text(node.textContent, false); - state.ensureNewLine(); - state.write('```'); - state.closeBlock(node); - }, + [CodeBlockHighlight.name]: renderCodeBlock, + [Diagram.name]: renderCodeBlock, [Division.name]: (state, node) => { if (node.attrs.className?.includes('js-markdown-code')) { state.renderInline(node); diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 5fdd294aa96..3e48434c6f9 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -341,3 +341,11 @@ export function renderImage(state, node) { export function renderPlayable(state, node) { renderImage(state, node); } + +export function renderCodeBlock(state, node) { + state.write(`\`\`\`${node.attrs.language || ''}\n`); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write('```'); + state.closeBlock(node); +} diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js index eb1e4885ba6..b844b414343 100644 --- a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js +++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js @@ -8,12 +8,12 @@ import { INPUT_RULE_TRACKING_ACTION, } from '../constants'; -const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => { +const trackKeyboardShortcut = (contentType, commandFn, shortcut) => (...args) => { Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, { label: CONTENT_EDITOR_TRACKING_LABEL, property: `${contentType}.${shortcut}`, }); - return commandFn(); + return commandFn(...args); }; const trackInputRule = (contentType, inputRule) => { diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js index 1abecb8f414..ed2c4b39131 100644 --- a/app/assets/javascripts/content_editor/services/upload_helpers.js +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -5,6 +5,16 @@ import { extractFilename, readFileAsDataURL } from './utils'; export const acceptedMimes = { image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'], + audio: [ + 'audio/basic', + 'audio/mid', + 'audio/mpeg', + 'audio/x-aiff', + 'audio/ogg', + 'audio/vorbis', + 'audio/vnd.wav', + ], + video: ['video/mp4', 'video/quicktime'], }; const extractAttachmentLinkUrl = (html) => { @@ -50,11 +60,11 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => { return extractAttachmentLinkUrl(rendered); }; -const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => { +const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => { const encodedSrc = await readFileAsDataURL(file); const { view } = editor; - editor.commands.setImage({ uploading: true, src: encodedSrc }); + editor.commands.insertContent({ type, attrs: { uploading: true, src: encodedSrc } }); const { state } = view; const position = state.selection.from - 1; @@ -74,7 +84,7 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub } catch (e) { editor.commands.deleteRange({ from: position, to: position + 1 }); eventHub.$emit('alert', { - message: __('An error occurred while uploading the image. Please try again.'), + message: __('An error occurred while uploading the file. Please try again.'), variant: VARIANT_DANGER, }); } @@ -114,10 +124,12 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => { if (!file) return false; - if (acceptedMimes.image.includes(file?.type)) { - uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub }); + for (const [type, mimes] of Object.entries(acceptedMimes)) { + if (mimes.includes(file?.type)) { + uploadContent({ type, editor, file, uploadsPath, renderMarkdown, eventHub }); - return true; + return true; + } } uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub }); diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index d1a68e80608..f2ff77daf02 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -2,8 +2,6 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import { debounce } from 'lodash'; import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed'; @@ -114,7 +112,26 @@ export default class ContextualSidebar { this.toggleCollapsedSidebar(collapse, true); } - initInviteMembersModal(); - initInviteMembersTrigger(); + const modalEl = document.querySelector('.js-invite-members-modal'); + if (modalEl) { + import( + /* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal' + ) + .then(({ default: initInviteMembersModal }) => { + initInviteMembersModal(); + }) + .catch(() => {}); + + const inviteTriggers = document.querySelectorAll('.js-invite-members-trigger'); + if (inviteTriggers) { + import( + /* webpackChunkName: 'initInviteMembersTrigger' */ '~/invite_members/init_invite_members_trigger' + ) + .then(({ default: initInviteMembersTrigger }) => { + initInviteMembersTrigger(); + }) + .catch(() => {}); + } + } } } diff --git a/app/assets/javascripts/crm/components/contact_form.vue b/app/assets/javascripts/crm/components/contact_form.vue deleted file mode 100644 index 81ae5c246be..00000000000 --- a/app/assets/javascripts/crm/components/contact_form.vue +++ /dev/null @@ -1,224 +0,0 @@ -<script> -import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { produce } from 'immer'; -import { __, s__ } from '~/locale'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_GROUP } from '~/graphql_shared/constants'; -import createContactMutation from './queries/create_contact.mutation.graphql'; -import updateContactMutation from './queries/update_contact.mutation.graphql'; -import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; - -export default { - components: { - GlAlert, - GlButton, - GlDrawer, - GlFormGroup, - GlFormInput, - }, - inject: ['groupFullPath', 'groupId'], - props: { - drawerOpen: { - type: Boolean, - required: true, - }, - contact: { - type: Object, - required: false, - default: () => {}, - }, - }, - data() { - return { - firstName: '', - lastName: '', - phone: '', - email: '', - description: '', - submitting: false, - errorMessages: [], - }; - }, - computed: { - invalid() { - const { firstName, lastName, email } = this; - - return firstName.trim() === '' || lastName.trim() === '' || email.trim() === ''; - }, - editMode() { - return Boolean(this.contact); - }, - title() { - return this.editMode ? this.$options.i18n.editTitle : this.$options.i18n.newTitle; - }, - buttonLabel() { - return this.editMode - ? this.$options.i18n.editButtonLabel - : this.$options.i18n.createButtonLabel; - }, - mutation() { - return this.editMode ? updateContactMutation : createContactMutation; - }, - variables() { - const { contact, firstName, lastName, phone, email, description, editMode, groupId } = this; - - const variables = { - input: { - firstName, - lastName, - phone, - email, - description, - }, - }; - - if (editMode) { - variables.input.id = contact.id; - } else { - variables.input.groupId = convertToGraphQLId(TYPE_GROUP, groupId); - } - - return variables; - }, - }, - mounted() { - if (this.editMode) { - const { contact } = this; - - this.firstName = contact.firstName || ''; - this.lastName = contact.lastName || ''; - this.phone = contact.phone || ''; - this.email = contact.email || ''; - this.description = contact.description || ''; - } - }, - methods: { - save() { - const { mutation, variables, updateCache, close } = this; - - this.submitting = true; - - return this.$apollo - .mutate({ - mutation, - variables, - update: updateCache, - }) - .then(({ data }) => { - if ( - data.customerRelationsContactCreate?.errors.length === 0 || - data.customerRelationsContactUpdate?.errors.length === 0 - ) { - close(true); - } - - this.submitting = false; - }) - .catch(() => { - this.errorMessages = [this.$options.i18n.somethingWentWrong]; - this.submitting = false; - }); - }, - close(success) { - this.$emit('close', success); - }, - updateCache(store, { data }) { - const mutationData = - data.customerRelationsContactCreate || data.customerRelationsContactUpdate; - - if (mutationData?.errors.length > 0) { - this.errorMessages = mutationData.errors; - return; - } - - const queryArgs = { - query: getGroupContactsQuery, - variables: { groupFullPath: this.groupFullPath }, - }; - - const sourceData = store.readQuery(queryArgs); - - queryArgs.data = produce(sourceData, (draftState) => { - draftState.group.contacts.nodes = [ - ...sourceData.group.contacts.nodes.filter(({ id }) => id !== this.contact?.id), - mutationData.contact, - ]; - }); - - store.writeQuery(queryArgs); - }, - getDrawerHeaderHeight() { - const wrapperEl = document.querySelector('.content-wrapper'); - - if (wrapperEl) { - return `${wrapperEl.offsetTop}px`; - } - - return ''; - }, - }, - i18n: { - createButtonLabel: s__('Crm|Create new contact'), - editButtonLabel: __('Save changes'), - cancel: __('Cancel'), - firstName: s__('Crm|First name'), - lastName: s__('Crm|Last name'), - email: s__('Crm|Email'), - phone: s__('Crm|Phone number (optional)'), - description: s__('Crm|Description (optional)'), - newTitle: s__('Crm|New contact'), - editTitle: s__('Crm|Edit contact'), - somethingWentWrong: __('Something went wrong. Please try again.'), - }, -}; -</script> - -<template> - <gl-drawer - class="gl-drawer-responsive" - :open="drawerOpen" - :header-height="getDrawerHeaderHeight()" - @close="close(false)" - > - <template #title> - <h3>{{ title }}</h3> - </template> - <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []"> - <ul class="gl-mb-0! gl-ml-5"> - <li v-for="error in errorMessages" :key="error"> - {{ error }} - </li> - </ul> - </gl-alert> - <form @submit.prevent="save"> - <gl-form-group :label="$options.i18n.firstName" label-for="contact-first-name"> - <gl-form-input id="contact-first-name" v-model="firstName" /> - </gl-form-group> - <gl-form-group :label="$options.i18n.lastName" label-for="contact-last-name"> - <gl-form-input id="contact-last-name" v-model="lastName" /> - </gl-form-group> - <gl-form-group :label="$options.i18n.email" label-for="contact-email"> - <gl-form-input id="contact-email" v-model="email" /> - </gl-form-group> - <gl-form-group :label="$options.i18n.phone" label-for="contact-phone"> - <gl-form-input id="contact-phone" v-model="phone" /> - </gl-form-group> - <gl-form-group :label="$options.i18n.description" label-for="contact-description"> - <gl-form-input id="contact-description" v-model="description" /> - </gl-form-group> - <span class="gl-float-right"> - <gl-button data-testid="cancel-button" @click="close(false)"> - {{ $options.i18n.cancel }} - </gl-button> - <gl-button - variant="confirm" - :disabled="invalid" - :loading="submitting" - data-testid="save-contact-button" - type="submit" - >{{ buttonLabel }}</gl-button - > - </span> - </form> - </gl-drawer> -</template> diff --git a/app/assets/javascripts/crm/components/form.vue b/app/assets/javascripts/crm/components/form.vue index b24de1e95e8..4f94898ff63 100644 --- a/app/assets/javascripts/crm/components/form.vue +++ b/app/assets/javascripts/crm/components/form.vue @@ -61,11 +61,6 @@ export default { required: false, default: null, }, - existingModel: { - type: Object, - required: false, - default: () => ({}), - }, additionalCreateParams: { type: Object, required: false, @@ -76,25 +71,42 @@ export default { required: false, default: () => MSG_SAVE_CHANGES, }, + existingId: { + type: String, + required: false, + default: null, + }, }, data() { - const initialModel = this.fields.reduce( - (map, field) => - Object.assign(map, { - [field.name]: this.existingModel ? this.existingModel[field.name] : null, - }), - {}, - ); - return { - model: initialModel, + model: null, submitting: false, errorMessages: [], + records: [], + loading: true, }; }, + apollo: { + records: { + query() { + return this.getQuery.query; + }, + variables() { + return this.getQuery.variables; + }, + update(data) { + this.records = getPropValueByPath(data, this.getQueryNodePath).nodes || []; + this.setInitialModel(); + this.loading = false; + }, + error() { + this.errorMessages = [MSG_ERROR]; + }, + }, + }, computed: { isEditMode() { - return this.existingModel?.id; + return this.existingId; }, isInvalid() { const { fields, model } = this; @@ -115,13 +127,24 @@ export default { ); if (isEditMode) { - return { input: { id: this.existingModel.id, ...variables } }; + return { input: { id: this.existingId, ...variables } }; } return { input: { ...additionalCreateParams, ...variables } }; }, }, methods: { + setInitialModel() { + const existingModel = this.records.find(({ id }) => id === this.existingId); + + this.model = this.fields.reduce( + (map, field) => + Object.assign(map, { + [field.name]: !this.isEditMode || !existingModel ? null : existingModel[field.name], + }), + {}, + ); + }, formatValue(model, field) { if (!isEmpty(model[field.name]) && field.input?.type === 'number') { return parseFloat(model[field.name]); @@ -173,7 +196,7 @@ export default { const sourceData = store.readQuery(getQuery); const newData = produce(sourceData, (draftState) => { - getPropValueByPath(draftState, getQueryNodePath).nodes.push(getFirstPropertyValue(result)); + getPropValueByPath(draftState, getQueryNodePath).nodes.push(this.getPayload(result)); }); store.writeQuery({ @@ -185,6 +208,14 @@ export default { const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`; return field.label + optionalSuffix; }, + getPayload(data) { + if (!data) return null; + + const keys = Object.keys(data); + if (keys[0] === '__typename') return data[keys[1]]; + + return data[keys[0]]; + }, }, MSG_CANCEL, INDEX_ROUTE_NAME, @@ -192,7 +223,7 @@ export default { </script> <template> - <mounting-portal mount-to="#js-crm-form-portal" append> + <mounting-portal v-if="!loading" mount-to="#js-crm-form-portal" append> <gl-drawer class="gl-drawer-responsive gl-absolute" :open="drawerOpen" @close="close(false)"> <template #title> <h3>{{ title }}</h3> diff --git a/app/assets/javascripts/crm/components/new_organization_form.vue b/app/assets/javascripts/crm/components/new_organization_form.vue deleted file mode 100644 index 3b11edc6935..00000000000 --- a/app/assets/javascripts/crm/components/new_organization_form.vue +++ /dev/null @@ -1,164 +0,0 @@ -<script> -import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { produce } from 'immer'; -import { __, s__ } from '~/locale'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_GROUP } from '~/graphql_shared/constants'; -import createOrganization from './queries/create_organization.mutation.graphql'; -import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql'; - -export default { - components: { - GlAlert, - GlButton, - GlDrawer, - GlFormGroup, - GlFormInput, - }, - inject: ['groupFullPath', 'groupId'], - props: { - drawerOpen: { - type: Boolean, - required: true, - }, - }, - data() { - return { - name: '', - defaultRate: null, - description: '', - submitting: false, - errorMessages: [], - }; - }, - computed: { - invalid() { - return this.name.trim() === ''; - }, - }, - methods: { - save() { - this.submitting = true; - return this.$apollo - .mutate({ - mutation: createOrganization, - variables: { - input: { - groupId: convertToGraphQLId(TYPE_GROUP, this.groupId), - name: this.name, - defaultRate: this.defaultRate ? parseFloat(this.defaultRate) : null, - description: this.description, - }, - }, - update: this.updateCache, - }) - .then(({ data }) => { - if (data.customerRelationsOrganizationCreate.errors.length === 0) this.close(true); - - this.submitting = false; - }) - .catch(() => { - this.errorMessages = [this.$options.i18n.somethingWentWrong]; - this.submitting = false; - }); - }, - close(success) { - this.$emit('close', success); - }, - updateCache(store, { data: { customerRelationsOrganizationCreate } }) { - if (customerRelationsOrganizationCreate.errors.length > 0) { - this.errorMessages = customerRelationsOrganizationCreate.errors; - return; - } - - const variables = { - groupFullPath: this.groupFullPath, - }; - const sourceData = store.readQuery({ - query: getGroupOrganizationsQuery, - variables, - }); - - const data = produce(sourceData, (draftState) => { - draftState.group.organizations.nodes = [ - ...sourceData.group.organizations.nodes, - customerRelationsOrganizationCreate.organization, - ]; - }); - - store.writeQuery({ - query: getGroupOrganizationsQuery, - variables, - data, - }); - }, - getDrawerHeaderHeight() { - const wrapperEl = document.querySelector('.content-wrapper'); - - if (wrapperEl) { - return `${wrapperEl.offsetTop}px`; - } - - return ''; - }, - }, - i18n: { - buttonLabel: s__('Crm|Create organization'), - cancel: __('Cancel'), - name: __('Name'), - defaultRate: s__('Crm|Default rate (optional)'), - description: __('Description (optional)'), - title: s__('Crm|New Organization'), - somethingWentWrong: __('Something went wrong. Please try again.'), - }, -}; -</script> - -<template> - <gl-drawer - class="gl-drawer-responsive" - :open="drawerOpen" - :header-height="getDrawerHeaderHeight()" - @close="close(false)" - > - <template #title> - <h4>{{ $options.i18n.title }}</h4> - </template> - <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []"> - <ul class="gl-mb-0! gl-ml-5"> - <li v-for="error in errorMessages" :key="error"> - {{ error }} - </li> - </ul> - </gl-alert> - <form @submit.prevent="save"> - <gl-form-group :label="$options.i18n.name" label-for="organization-name"> - <gl-form-input id="organization-name" v-model="name" /> - </gl-form-group> - <gl-form-group :label="$options.i18n.defaultRate" label-for="organization-default-rate"> - <gl-form-input - id="organization-default-rate" - v-model="defaultRate" - type="number" - step="0.01" - /> - </gl-form-group> - <gl-form-group :label="$options.i18n.description" label-for="organization-description"> - <gl-form-input id="organization-description" v-model="description" /> - </gl-form-group> - <span class="gl-float-right"> - <gl-button data-testid="cancel-button" @click="close(false)"> - {{ $options.i18n.cancel }} - </gl-button> - <gl-button - variant="confirm" - :disabled="invalid" - :loading="submitting" - data-testid="create-new-organization-button" - type="submit" - >{{ $options.i18n.buttonLabel }}</gl-button - > - </span> - </form> - </gl-drawer> -</template> diff --git a/app/assets/javascripts/crm/contacts_bundle.js b/app/assets/javascripts/crm/contacts/bundle.js index f49ec64210f..f49ec64210f 100644 --- a/app/assets/javascripts/crm/contacts_bundle.js +++ b/app/assets/javascripts/crm/contacts/bundle.js diff --git a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue new file mode 100644 index 00000000000..58eaabfbb7f --- /dev/null +++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue @@ -0,0 +1,78 @@ +<script> +import { s__, __ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_CRM_CONTACT, TYPE_GROUP } from '~/graphql_shared/constants'; +import ContactForm from '../../components/form.vue'; +import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql'; +import createContactMutation from './graphql/create_contact.mutation.graphql'; +import updateContactMutation from './graphql/update_contact.mutation.graphql'; + +export default { + components: { + ContactForm, + }, + inject: ['groupFullPath', 'groupId'], + props: { + isEditMode: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + contactGraphQLId() { + if (!this.isEditMode) return null; + + return convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id); + }, + groupGraphQLId() { + return convertToGraphQLId(TYPE_GROUP, this.groupId); + }, + mutation() { + if (this.isEditMode) return updateContactMutation; + + return createContactMutation; + }, + getQuery() { + return { + query: getGroupContactsQuery, + variables: { groupFullPath: this.groupFullPath }, + }; + }, + title() { + if (this.isEditMode) return s__('Crm|Edit contact'); + + return s__('Crm|New contact'); + }, + successMessage() { + if (this.isEditMode) return s__('Crm|Contact has been updated.'); + + return s__('Crm|Contact has been added.'); + }, + additionalCreateParams() { + return { groupId: this.groupGraphQLId }; + }, + }, + fields: [ + { name: 'firstName', label: __('First name'), required: true }, + { name: 'lastName', label: __('Last name'), required: true }, + { name: 'email', label: __('Email'), required: true }, + { name: 'phone', label: __('Phone') }, + { name: 'description', label: __('Description') }, + ], +}; +</script> + +<template> + <contact-form + :drawer-open="true" + :get-query="getQuery" + get-query-node-path="group.contacts" + :mutation="mutation" + :additional-create-params="additionalCreateParams" + :existing-id="contactGraphQLId" + :fields="$options.fields" + :title="title" + :success-message="successMessage" + /> +</template> diff --git a/app/assets/javascripts/crm/components/contacts_root.vue b/app/assets/javascripts/crm/contacts/components/contacts_root.vue index 178ce84c64d..17be3800256 100644 --- a/app/assets/javascripts/crm/components/contacts_root.vue +++ b/app/assets/javascripts/crm/contacts/components/contacts_root.vue @@ -2,11 +2,9 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; import { parseBoolean } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; -import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_CRM_CONTACT } from '~/graphql_shared/constants'; -import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants'; -import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; -import ContactForm from './contact_form.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants'; +import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql'; export default { components: { @@ -14,12 +12,11 @@ export default { GlButton, GlLoadingIcon, GlTable, - ContactForm, }, directives: { GlTooltip: GlTooltipDirective, }, - inject: ['groupFullPath', 'groupIssuesPath', 'canAdminCrmContact'], + inject: ['canAdminCrmContact', 'groupFullPath', 'groupIssuesPath'], data() { return { contacts: [], @@ -48,50 +45,20 @@ export default { isLoading() { return this.$apollo.queries.contacts.loading; }, - showNewForm() { - return this.$route.name === NEW_ROUTE_NAME; - }, - showEditForm() { - return !this.isLoading && this.$route.name === EDIT_ROUTE_NAME; - }, canAdmin() { return parseBoolean(this.canAdminCrmContact); }, - editingContact() { - return this.contacts.find( - (contact) => contact.id === convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id), - ); - }, }, methods: { extractContacts(data) { const contacts = data?.group?.contacts?.nodes || []; return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName)); }, - displayNewForm() { - if (this.showNewForm) return; - - this.$router.push({ name: NEW_ROUTE_NAME }); - }, - hideNewForm(success) { - if (success) this.$toast.show(s__('Crm|Contact has been added')); - - this.$router.replace({ name: INDEX_ROUTE_NAME }); - }, - hideEditForm(success) { - if (success) this.$toast.show(s__('Crm|Contact has been updated')); - - this.editingContactId = 0; - this.$router.replace({ name: INDEX_ROUTE_NAME }); - }, getIssuesPath(path, value) { return `${path}?scope=all&state=opened&crm_contact_id=${value}`; }, - edit(value) { - if (this.showEditForm) return; - - this.editingContactId = value; - this.$router.push({ name: EDIT_ROUTE_NAME, params: { id: value } }); + getEditRoute(id) { + return { name: this.$options.EDIT_ROUTE_NAME, params: { id } }; }, }, fields: [ @@ -119,10 +86,12 @@ export default { emptyText: s__('Crm|No contacts found'), issuesButtonLabel: __('View issues'), editButtonLabel: __('Edit'), - title: s__('Crm|Customer Relations Contacts'), + title: s__('Crm|Customer relations contacts'), newContact: s__('Crm|New contact'), errorText: __('Something went wrong. Please try again.'), }, + EDIT_ROUTE_NAME, + NEW_ROUTE_NAME, }; </script> @@ -137,24 +106,15 @@ export default { <h2 class="gl-font-size-h2 gl-my-0"> {{ $options.i18n.title }} </h2> - <div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"> - <gl-button - v-if="canAdmin" - variant="confirm" - data-testid="new-contact-button" - @click="displayNewForm" - > - {{ $options.i18n.newContact }} - </gl-button> + <div v-if="canAdmin"> + <router-link :to="{ name: $options.NEW_ROUTE_NAME }"> + <gl-button variant="confirm" data-testid="new-contact-button"> + {{ $options.i18n.newContact }} + </gl-button> + </router-link> </div> </div> - <contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> - <contact-form - v-if="showEditForm" - :contact="editingContact" - :drawer-open="showEditForm" - @close="hideEditForm" - /> + <router-view /> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> <gl-table v-else @@ -164,23 +124,24 @@ export default { :empty-text="$options.i18n.emptyText" show-empty > - <template #cell(id)="data"> + <template #cell(id)="{ value: id }"> <gl-button v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" class="gl-mr-3" data-testid="issues-link" icon="issues" :aria-label="$options.i18n.issuesButtonLabel" - :href="getIssuesPath(groupIssuesPath, data.value)" - /> - <gl-button - v-if="canAdmin" - v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" - data-testid="edit-contact-button" - icon="pencil" - :aria-label="$options.i18n.editButtonLabel" - @click="edit(data.value)" + :href="getIssuesPath(groupIssuesPath, id)" /> + <router-link :to="getEditRoute(id)"> + <gl-button + v-if="canAdmin" + v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" + data-testid="edit-contact-button" + icon="pencil" + :aria-label="$options.i18n.editButtonLabel" + /> + </router-link> </template> </gl-table> </div> diff --git a/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql b/app/assets/javascripts/crm/contacts/components/graphql/create_contact.mutation.graphql index e0192459609..e0192459609 100644 --- a/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql +++ b/app/assets/javascripts/crm/contacts/components/graphql/create_contact.mutation.graphql diff --git a/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql b/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql index cef4083b446..cef4083b446 100644 --- a/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql +++ b/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql diff --git a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql index 2a8150e42e3..2a8150e42e3 100644 --- a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql +++ b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql diff --git a/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql b/app/assets/javascripts/crm/contacts/components/graphql/update_contact.mutation.graphql index f55f6a10e0a..f55f6a10e0a 100644 --- a/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql +++ b/app/assets/javascripts/crm/contacts/components/graphql/update_contact.mutation.graphql diff --git a/app/assets/javascripts/crm/routes.js b/app/assets/javascripts/crm/contacts/routes.js index 12aa17d73b6..18768e1c775 100644 --- a/app/assets/javascripts/crm/routes.js +++ b/app/assets/javascripts/crm/contacts/routes.js @@ -1,4 +1,5 @@ -import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants'; +import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants'; +import ContactFormWrapper from './components/contact_form_wrapper.vue'; export default [ { @@ -8,9 +9,12 @@ export default [ { name: NEW_ROUTE_NAME, path: '/new', + component: ContactFormWrapper, }, { name: EDIT_ROUTE_NAME, path: '/:id/edit', + component: ContactFormWrapper, + props: { isEditMode: true }, }, ]; diff --git a/app/assets/javascripts/crm/organizations_bundle.js b/app/assets/javascripts/crm/organizations/bundle.js index 828d7cd426c..828d7cd426c 100644 --- a/app/assets/javascripts/crm/organizations_bundle.js +++ b/app/assets/javascripts/crm/organizations/bundle.js diff --git a/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql b/app/assets/javascripts/crm/organizations/components/graphql/create_organization.mutation.graphql index 2cc7e53ee9b..2cc7e53ee9b 100644 --- a/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql +++ b/app/assets/javascripts/crm/organizations/components/graphql/create_organization.mutation.graphql diff --git a/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql b/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql index 4adc5742d3a..4adc5742d3a 100644 --- a/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql +++ b/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql diff --git a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql index e8d8109431e..e8d8109431e 100644 --- a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql +++ b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql diff --git a/app/assets/javascripts/crm/organizations/components/graphql/update_organization.mutation.graphql b/app/assets/javascripts/crm/organizations/components/graphql/update_organization.mutation.graphql new file mode 100644 index 00000000000..a4c46d1f0fa --- /dev/null +++ b/app/assets/javascripts/crm/organizations/components/graphql/update_organization.mutation.graphql @@ -0,0 +1,10 @@ +#import "./crm_organization_fields.fragment.graphql" + +mutation updateOrganization($input: CustomerRelationsOrganizationUpdateInput!) { + customerRelationsOrganizationUpdate(input: $input) { + organization { + ...OrganizationFragment + } + errors + } +} diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue new file mode 100644 index 00000000000..38468e1f4e4 --- /dev/null +++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue @@ -0,0 +1,80 @@ +<script> +import { s__, __ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_CRM_ORGANIZATION, TYPE_GROUP } from '~/graphql_shared/constants'; +import OrganizationForm from '../../components/form.vue'; +import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql'; +import createOrganizationMutation from './graphql/create_organization.mutation.graphql'; +import updateOrganizationMutation from './graphql/update_organization.mutation.graphql'; + +export default { + components: { + OrganizationForm, + }, + inject: ['groupFullPath', 'groupId'], + props: { + isEditMode: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + organizationGraphQLId() { + if (!this.isEditMode) return null; + + return convertToGraphQLId(TYPE_CRM_ORGANIZATION, this.$route.params.id); + }, + groupGraphQLId() { + return convertToGraphQLId(TYPE_GROUP, this.groupId); + }, + mutation() { + if (this.isEditMode) return updateOrganizationMutation; + + return createOrganizationMutation; + }, + getQuery() { + return { + query: getGroupOrganizationsQuery, + variables: { groupFullPath: this.groupFullPath }, + }; + }, + title() { + if (this.isEditMode) return s__('Crm|Edit organization'); + + return s__('Crm|New organization'); + }, + successMessage() { + if (this.isEditMode) return s__('Crm|Organization has been updated.'); + + return s__('Crm|Organization has been added.'); + }, + additionalCreateParams() { + return { groupId: this.groupGraphQLId }; + }, + }, + fields: [ + { name: 'name', label: __('Name'), required: true }, + { + name: 'defaultRate', + label: s__('Crm|Default rate'), + input: { type: 'number', step: '0.01' }, + }, + { name: 'description', label: __('Description') }, + ], +}; +</script> + +<template> + <organization-form + :drawer-open="true" + :get-query="getQuery" + get-query-node-path="group.organizations" + :mutation="mutation" + :additional-create-params="additionalCreateParams" + :existing-id="organizationGraphQLId" + :fields="$options.fields" + :title="title" + :success-message="successMessage" + /> +</template> diff --git a/app/assets/javascripts/crm/components/organizations_root.vue b/app/assets/javascripts/crm/organizations/components/organizations_root.vue index 9370c6377e9..522e29eb2af 100644 --- a/app/assets/javascripts/crm/components/organizations_root.vue +++ b/app/assets/javascripts/crm/organizations/components/organizations_root.vue @@ -3,9 +3,8 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@ import { parseBoolean } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME } from '../constants'; -import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql'; -import NewOrganizationForm from './new_organization_form.vue'; +import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants'; +import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql'; export default { components: { @@ -13,7 +12,6 @@ export default { GlButton, GlLoadingIcon, GlTable, - NewOrganizationForm, }, directives: { GlTooltip: GlTooltipDirective, @@ -21,8 +19,8 @@ export default { inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'], data() { return { - error: false, organizations: [], + error: false, }; }, apollo: { @@ -47,10 +45,7 @@ export default { isLoading() { return this.$apollo.queries.organizations.loading; }, - showNewForm() { - return this.$route.name === NEW_ROUTE_NAME; - }, - canCreateNew() { + canAdmin() { return parseBoolean(this.canAdminCrmOrganization); }, }, @@ -62,15 +57,8 @@ export default { getIssuesPath(path, value) { return `${path}?scope=all&state=opened&crm_organization_id=${value}`; }, - displayNewForm() { - if (this.showNewForm) return; - - this.$router.push({ name: NEW_ROUTE_NAME }); - }, - hideNewForm(success) { - if (success) this.$toast.show(this.$options.i18n.organizationAdded); - - this.$router.replace({ name: INDEX_ROUTE_NAME }); + getEditRoute(id) { + return { name: this.$options.EDIT_ROUTE_NAME, params: { id } }; }, }, fields: [ @@ -79,7 +67,7 @@ export default { { key: 'description', sortable: true }, { key: 'id', - label: __('Issues'), + label: '', formatter: (id) => { return getIdFromGraphQLId(id); }, @@ -88,11 +76,13 @@ export default { i18n: { emptyText: s__('Crm|No organizations found'), issuesButtonLabel: __('View issues'), - title: s__('Crm|Customer Relations Organizations'), + editButtonLabel: __('Edit'), + title: s__('Crm|Customer relations organizations'), newOrganization: s__('Crm|New organization'), errorText: __('Something went wrong. Please try again.'), - organizationAdded: s__('Crm|Organization has been added'), }, + EDIT_ROUTE_NAME, + NEW_ROUTE_NAME, }; </script> @@ -108,15 +98,17 @@ export default { {{ $options.i18n.title }} </h2> <div - v-if="canCreateNew" + v-if="canAdmin" class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end" > - <gl-button variant="confirm" data-testid="new-organization-button" @click="displayNewForm"> - {{ $options.i18n.newOrganization }} - </gl-button> + <router-link :to="{ name: $options.NEW_ROUTE_NAME }"> + <gl-button variant="confirm" data-testid="new-organization-button"> + {{ $options.i18n.newOrganization }} + </gl-button> + </router-link> </div> </div> - <new-organization-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> + <router-view /> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> <gl-table v-else @@ -126,14 +118,24 @@ export default { :empty-text="$options.i18n.emptyText" show-empty > - <template #cell(id)="data"> + <template #cell(id)="{ value: id }"> <gl-button v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" + class="gl-mr-3" data-testid="issues-link" icon="issues" :aria-label="$options.i18n.issuesButtonLabel" - :href="getIssuesPath(groupIssuesPath, data.value)" + :href="getIssuesPath(groupIssuesPath, id)" /> + <router-link :to="getEditRoute(id)"> + <gl-button + v-if="canAdmin" + v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" + data-testid="edit-organization-button" + icon="pencil" + :aria-label="$options.i18n.editButtonLabel" + /> + </router-link> </template> </gl-table> </div> diff --git a/app/assets/javascripts/crm/organizations/routes.js b/app/assets/javascripts/crm/organizations/routes.js new file mode 100644 index 00000000000..85bd3b32877 --- /dev/null +++ b/app/assets/javascripts/crm/organizations/routes.js @@ -0,0 +1,20 @@ +import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants'; +import OrganizationFormWrapper from './components/organization_form_wrapper.vue'; + +export default [ + { + name: INDEX_ROUTE_NAME, + path: '/', + }, + { + name: NEW_ROUTE_NAME, + path: '/new', + component: OrganizationFormWrapper, + }, + { + name: EDIT_ROUTE_NAME, + path: '/:id/edit', + component: OrganizationFormWrapper, + props: { isEditMode: true }, + }, +]; diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 0707ae02872..73d872cf962 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -1,4 +1,4 @@ -/* eslint-disable no-restricted-properties, babel/camelcase, +/* eslint-disable no-restricted-properties, camelcase, no-unused-expressions, default-case, consistent-return, no-param-reassign, no-shadow, no-useless-escape, @@ -143,7 +143,7 @@ export default class Notes { // resolve a discussion this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) - this.$wrapperEl.on('click', '.js-note-delete', this.removeNote); + this.$wrapperEl.on('ajax:success', '.js-note-delete', this.removeNote); // delete note attachment this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment); // update the file name when an attachment is selected @@ -188,7 +188,7 @@ export default class Notes { cleanBinding() { this.$wrapperEl.off('click', '.js-note-edit'); this.$wrapperEl.off('click', '.note-edit-cancel'); - this.$wrapperEl.off('click', '.js-note-delete'); + this.$wrapperEl.off('ajax:success', '.js-note-delete'); this.$wrapperEl.off('click', '.js-note-attachment-delete'); this.$wrapperEl.off('click', '.js-discussion-reply-button'); this.$wrapperEl.off('click', '.js-add-diff-note-button'); @@ -827,50 +827,53 @@ export default class Notes { */ removeNote(e) { const $note = $(e.currentTarget).closest('.note'); - const noteElId = $note.attr('id'); - $(`.note[id="${noteElId}"]`).each((i, el) => { - // A same note appears in the "Discussion" and in the "Changes" tab, we have - // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, - // where $('#noteId') would return only one. - const $note = $(el); - const $notes = $note.closest('.discussion-notes'); - const discussionId = $('.notes', $notes).data('discussionId'); - - $note.remove(); - - // check if this is the last note for this line - if ($notes.find('.note').length === 0) { - const notesTr = $notes.closest('tr'); - - // "Discussions" tab - $notes.closest('.timeline-entry').remove(); - - $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); - - // The notes tr can contain multiple lists of notes, like on the parallel diff - // notesTr does not exist for image diffs - if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { - const $diffFile = $notes.closest('.diff-file'); - if ($diffFile.length > 0) { - const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { - detail: { - // badgeNumber's start with 1 and index starts with 0 - badgeNumber: $notes.index() + 1, - }, - }); - $diffFile[0].dispatchEvent(removeBadgeEvent); - } + $note.one('ajax:complete', () => { + const noteElId = $note.attr('id'); + $(`.note[id="${noteElId}"]`).each((i, el) => { + // A same note appears in the "Discussion" and in the "Changes" tab, we have + // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, + // where $('#noteId') would return only one. + const $note = $(el); + const $notes = $note.closest('.discussion-notes'); + const discussionId = $('.notes', $notes).data('discussionId'); + + $note.remove(); + + // check if this is the last note for this line + if ($notes.find('.note').length === 0) { + const notesTr = $notes.closest('tr'); + + // "Discussions" tab + $notes.closest('.timeline-entry').remove(); + + $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); + + // The notes tr can contain multiple lists of notes, like on the parallel diff + // notesTr does not exist for image diffs + if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { + const $diffFile = $notes.closest('.diff-file'); + if ($diffFile.length > 0) { + const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { + detail: { + // badgeNumber's start with 1 and index starts with 0 + badgeNumber: $notes.index() + 1, + }, + }); + + $diffFile[0].dispatchEvent(removeBadgeEvent); + } - $notes.remove(); - } else if (notesTr.length > 0) { - notesTr.remove(); + $notes.remove(); + } else if (notesTr.length > 0) { + notesTr.remove(); + } } - } - }); + }); - Notes.checkMergeRequestStatus(); - return this.updateNotesCount(-1); + Notes.checkMergeRequestStatus(); + return this.updateNotesCount(-1); + }); } /** diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 7fefbab977d..618096c5bea 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; import createFlash from '~/flash'; import { s__ } from '~/locale'; @@ -26,15 +26,14 @@ export default { components: { ApolloMutation, DesignNote, + DesignNotePin, DesignNoteSignedOut, - ReplyPlaceholder, DesignReplyForm, - GlIcon, - GlLoadingIcon, + GlButton, GlLink, - ToggleRepliesWidget, + ReplyPlaceholder, TimeAgoTooltip, - DesignNotePin, + ToggleRepliesWidget, }, directives: { GlTooltip: GlTooltipDirective, @@ -239,18 +238,17 @@ export default { @error="$emit('update-note-error', $event)" > <template v-if="isLoggedIn && discussion.resolvable" #resolve-discussion> - <button + <gl-button v-gl-tooltip - :class="{ 'is-active': discussion.resolved }" - :title="resolveCheckboxText" :aria-label="resolveCheckboxText" - class="line-resolve-btn note-action-button gl-mr-3" + :icon="resolveIconName" + :title="resolveCheckboxText" + :loading="isResolving" + category="tertiary" data-testid="resolve-button" + size="small" @click.stop="toggleResolvedStatus" - > - <gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" /> - <gl-loading-icon v-else size="sm" inline /> - </button> + /> </template> <template v-if="discussion.resolved" #resolved-status> <p class="gl-text-gray-500 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message"> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index 1e1f5135290..5fb5989e11a 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -1,11 +1,17 @@ <script> -import { GlTooltipDirective, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import { + GlAvatar, + GlAvatarLink, + GlButton, + GlLink, + GlSafeHtmlDirective, + GlTooltipDirective, +} from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql'; import { hasErrors } from '../../utils/cache_update'; import { findNoteId, extractDesignNoteId } from '../../utils/design_management_utils'; @@ -16,13 +22,14 @@ export default { editCommentLabel: __('Edit comment'), }, components: { - UserAvatarLink, - TimelineEntryItem, - TimeAgoTooltip, - DesignReplyForm, ApolloMutation, - GlIcon, + DesignReplyForm, + GlAvatar, + GlAvatarLink, + GlButton, GlLink, + TimeAgoTooltip, + TimelineEntryItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -86,18 +93,17 @@ export default { <template> <timeline-entry-item :id="`note_${noteAnchorId}`" class="design-note note-form"> - <user-avatar-link - :link-href="author.webUrl" - :img-src="author.avatarUrl" - :img-alt="author.username" - :img-size="40" - /> + <gl-avatar-link :href="author.webUrl" class="gl-float-left gl-mr-3"> + <gl-avatar :size="32" :src="author.avatarUrl" :entity-name="author.username" /> + </gl-avatar-link> + <div class="gl-display-flex gl-justify-content-space-between"> <div> <gl-link v-once :href="author.webUrl" class="js-user-link" + data-testid="user-link" :data-user-id="authorId" :data-username="author.username" > @@ -117,24 +123,25 @@ export default { </div> <div class="gl-display-flex gl-align-items-baseline"> <slot name="resolve-discussion"></slot> - <button + <gl-button v-if="isEditButtonVisible" v-gl-tooltip - type="button" - :title="$options.i18n.editCommentLabel" :aria-label="$options.i18n.editCommentLabel" - class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" + :title="$options.i18n.editCommentLabel" + category="tertiary" + data-testid="note-edit" + icon="pencil" + size="small" @click="isEditing = true" - > - <gl-icon name="pencil" class="link-highlight" /> - </button> + /> </div> </div> <template v-if="!isEditing"> <div v-safe-html="note.bodyHtml" - class="note-text js-note-text md" + class="note-text md" data-qa-selector="note_content" + data-testid="note-text" ></div> <slot name="resolved-status"></slot> </template> diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 5707e4d67f9..c86f2c8451c 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -21,7 +21,7 @@ import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_wi import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import notesEventHub from '../../notes/event_hub'; +import notesEventHub from '~/notes/event_hub'; import { TREE_LIST_WIDTH_STORAGE_KEY, INITIAL_TREE_WIDTH, @@ -347,36 +347,34 @@ export default { this.setHighlightedRow(id.split('diff-content').pop().slice(1)); } - if (window.gon?.features?.diffSettingsUsageData) { - const events = []; + const events = []; - if (this.renderTreeList) { - events.push(TRACKING_FILE_BROWSER_TREE); - } else { - events.push(TRACKING_FILE_BROWSER_LIST); - } - - if (this.diffViewType === INLINE_DIFF_VIEW_TYPE) { - events.push(TRACKING_DIFF_VIEW_INLINE); - } else { - events.push(TRACKING_DIFF_VIEW_PARALLEL); - } + if (this.renderTreeList) { + events.push(TRACKING_FILE_BROWSER_TREE); + } else { + events.push(TRACKING_FILE_BROWSER_LIST); + } - if (this.showWhitespace) { - events.push(TRACKING_WHITESPACE_SHOW); - } else { - events.push(TRACKING_WHITESPACE_HIDE); - } + if (this.diffViewType === INLINE_DIFF_VIEW_TYPE) { + events.push(TRACKING_DIFF_VIEW_INLINE); + } else { + events.push(TRACKING_DIFF_VIEW_PARALLEL); + } - if (this.viewDiffsFileByFile) { - events.push(TRACKING_SINGLE_FILE_MODE); - } else { - events.push(TRACKING_MULTIPLE_FILES_MODE); - } + if (this.showWhitespace) { + events.push(TRACKING_WHITESPACE_SHOW); + } else { + events.push(TRACKING_WHITESPACE_HIDE); + } - queueRedisHllEvents(events); + if (this.viewDiffsFileByFile) { + events.push(TRACKING_SINGLE_FILE_MODE); + } else { + events.push(TRACKING_MULTIPLE_FILES_MODE); } + queueRedisHllEvents(events); + this.subscribeToVirtualScrollingEvents(); }, beforeCreate() { @@ -534,10 +532,8 @@ export default { if (delta >= 0 && delta < 1000) { this.disableVirtualScroller(); - if (window.gon?.features?.usageDataDiffSearches) { - api.trackRedisHllUserEvent('i_code_review_user_searches_diff'); - api.trackRedisCounterEvent('diff_searches'); - } + api.trackRedisHllUserEvent('i_code_review_user_searches_diff'); + api.trackRedisCounterEvent('diff_searches'); } } }); @@ -574,12 +570,8 @@ export default { this.scrollVirtualScrollerToIndex(index); } }, - async scrollVirtualScrollerToIndex(index) { + scrollVirtualScrollerToIndex(index) { this.virtualScrollCurrentIndex = index; - - await this.$nextTick(); - - this.virtualScrollCurrentIndex = -1; }, scrollVirtualScrollerToDiffNote() { const id = window?.location?.hash; @@ -705,7 +697,7 @@ export default { </dynamic-scroller-item> </template> </pre-renderer> - <virtual-scroller-scroll-sync :index="virtualScrollCurrentIndex" /> + <virtual-scroller-scroll-sync v-model="virtualScrollCurrentIndex" /> </template> </dynamic-scroller> <template v-else> diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index ba10f6deb29..42f4ea8eb58 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -103,7 +103,7 @@ export default { > <div v-if="commit.signature_html" - v-safe-html:[$options.safeHtmlConfig]="commit.signature_html" + v-html="commit.signature_html /* eslint-disable-line vue/no-v-html */" ></div> <commit-pipeline-status v-if="commit.pipeline_status_path" @@ -137,7 +137,7 @@ export default { :link-href="authorUrl" :img-src="authorAvatar" :img-alt="authorName" - :img-size="40" + :img-size="32" class="avatar-cell d-none d-sm-block" /> </div> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 2b871680d5e..4dfd672f99b 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective, GlIcon, GlLink, GlButtonGroup, GlButton, GlSprintf } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; -import { setUrlParams } from '../../lib/utils/url_utility'; +import { setUrlParams } from '~/lib/utils/url_utility'; import { EVT_EXPAND_ALL_FILES } from '../constants'; import eventHub from '../event_hub'; import CompareDropdownLayout from './compare_dropdown_layout.vue'; diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 7ed5713ebfa..b4bffdcb07f 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -9,9 +9,9 @@ import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue'; import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue'; -import NoteForm from '../../notes/components/note_form.vue'; -import eventHub from '../../notes/event_hub'; -import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import NoteForm from '~/notes/components/note_form.vue'; +import eventHub from '~/notes/event_hub'; +import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; import { getDiffMode } from '../store/utils'; import DiffDiscussions from './diff_discussions.vue'; @@ -170,7 +170,6 @@ export default { <note-form v-if="diffFileCommentForm" ref="noteForm" - :is-editing="false" :save-button-title="__('Comment')" class="diff-comment-form new-note discussion-form discussion-form-container" @handleFormUpdateAddToReview="addToReview" diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index 47a05ce11cc..b39b50c4cdc 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -2,7 +2,7 @@ import { GlIcon } from '@gitlab/ui'; import { mapActions } from 'vuex'; import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; -import noteableDiscussion from '../../notes/components/noteable_discussion.vue'; +import noteableDiscussion from '~/notes/components/noteable_discussion.vue'; export default { components: { diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 4e77bf81c1e..d8f27a967df 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -17,7 +17,7 @@ import { diffViewerErrors } from '~/ide/constants'; import { scrollToElement } from '~/lib/utils/common_utils'; import { sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import notesEventHub from '../../notes/event_hub'; +import notesEventHub from '~/notes/event_hub'; import { DIFF_FILE_AUTOMATIC_COLLAPSE, diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 495c87a695c..8cdbd2b7dbc 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -340,6 +340,7 @@ export default { :title="__('Copy file path')" :text="diffFile.file_path" :gfm="gfmCopyText" + size="small" data-testid="diff-file-copy-clipboard" category="tertiary" data-track-action="click_copy_file_button" @@ -392,6 +393,7 @@ export default { /> <gl-dropdown v-gl-tooltip.hover.focus="$options.i18n.optionsDropdownTitle" + size="small" right toggle-class="btn-icon js-diff-more-actions" class="gl-pt-0!" @@ -400,7 +402,7 @@ export default { @hidden="setMoreActionsShown(false)" > <template #button-content> - <gl-icon name="ellipsis_v" class="mr-0" /> + <gl-icon name="ellipsis_v" class="mr-0" :size="12" /> <span class="sr-only">{{ $options.i18n.optionsDropdownTitle }}</span> </template> <gl-dropdown-item diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index e2f3f9cad7b..a077c8ae3af 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -74,6 +74,7 @@ export default { v-for="note in notesInGutter" :key="note.id" :img-src="note.author.avatar_url" + :size="24" :tooltip-text="getTooltipText(note)" lazy class="diff-comment-avatar js-diff-comment-avatar" diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 9d355c96af1..7a30740e31b 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -4,13 +4,10 @@ import { s__ } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import MultilineCommentForm from '../../notes/components/multiline_comment_form.vue'; -import { - commentLineOptions, - formatLineRange, -} from '../../notes/components/multiline_comment_utils'; -import noteForm from '../../notes/components/note_form.vue'; -import autosave from '../../notes/mixins/autosave'; +import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue'; +import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils'; +import noteForm from '~/notes/components/note_form.vue'; +import autosave from '~/notes/mixins/autosave'; import { DIFF_NOTE_TYPE, INLINE_DIFF_LINES_KEY, @@ -221,7 +218,6 @@ export default { </div> <note-form ref="noteForm" - :is-editing="false" :line-code="line.line_code" :line="line" :lines="commentLines" diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue index ab518fcfb16..42af2ab7880 100644 --- a/app/assets/javascripts/diffs/components/no_changes.vue +++ b/app/assets/javascripts/diffs/components/no_changes.vue @@ -61,7 +61,7 @@ export default { </gl-sprintf> </div> <div class="text-center"> - <gl-button :href="getNoteableData.new_blob_path" variant="success" category="primary">{{ + <gl-button :href="getNoteableData.new_blob_path" variant="confirm" category="primary">{{ __('Create commit') }}</gl-button> </div> diff --git a/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js index 984c6f8c0c9..d44dffecc38 100644 --- a/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js +++ b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js @@ -2,6 +2,9 @@ import { handleLocationHash } from '~/lib/utils/common_utils'; export default { inject: ['vscrollParent'], + model: { + prop: 'index', + }, props: { index: { type: Number, @@ -39,6 +42,7 @@ export default { methods: { scrollToIndex(index) { this.vscrollParent.scrollToItem(index); + this.$emit('update', -1); setTimeout(() => { handleLocationHash(); diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index e967be23f42..d5cd4af4d06 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -13,7 +13,7 @@ import httpStatusCodes from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; -import notesEventHub from '../../notes/event_hub'; +import notesEventHub from '~/notes/event_hub'; import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE, @@ -376,9 +376,7 @@ export const setInlineDiffViewType = ({ commit }) => { const url = mergeUrlParams({ view: INLINE_DIFF_VIEW_TYPE }, window.location.href); historyPushState(url); - if (window.gon?.features?.diffSettingsUsageData) { - queueRedisHllEvents([TRACKING_CLICK_DIFF_VIEW_SETTING, TRACKING_DIFF_VIEW_INLINE]); - } + queueRedisHllEvents([TRACKING_CLICK_DIFF_VIEW_SETTING, TRACKING_DIFF_VIEW_INLINE]); }; export const setParallelDiffViewType = ({ commit }) => { @@ -388,9 +386,7 @@ export const setParallelDiffViewType = ({ commit }) => { const url = mergeUrlParams({ view: PARALLEL_DIFF_VIEW_TYPE }, window.location.href); historyPushState(url); - if (window.gon?.features?.diffSettingsUsageData) { - queueRedisHllEvents([TRACKING_CLICK_DIFF_VIEW_SETTING, TRACKING_DIFF_VIEW_PARALLEL]); - } + queueRedisHllEvents([TRACKING_CLICK_DIFF_VIEW_SETTING, TRACKING_DIFF_VIEW_PARALLEL]); }; export const showCommentForm = ({ commit }, { lineCode, fileHash }) => { @@ -576,7 +572,7 @@ export const setRenderTreeList = ({ commit }, { renderTreeList, trackClick = tru localStorage.setItem(TREE_LIST_STORAGE_KEY, renderTreeList); - if (window.gon?.features?.diffSettingsUsageData && trackClick) { + if (trackClick) { const events = [TRACKING_CLICK_FILE_BROWSER_SETTING]; if (renderTreeList) { @@ -600,7 +596,7 @@ export const setShowWhitespace = async ( commit(types.SET_SHOW_WHITESPACE, showWhitespace); notesEventHub.$emit('refetchDiffData'); - if (window.gon?.features?.diffSettingsUsageData && trackClick) { + if (trackClick) { const events = [TRACKING_CLICK_WHITESPACE_SETTING]; if (showWhitespace) { @@ -827,18 +823,16 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => { export const setFileByFile = ({ state, commit }, { fileByFile }) => { commit(types.SET_FILE_BY_FILE, fileByFile); - if (window.gon?.features?.diffSettingsUsageData) { - const events = [TRACKING_CLICK_SINGLE_FILE_SETTING]; - - if (fileByFile) { - events.push(TRACKING_SINGLE_FILE_MODE); - } else { - events.push(TRACKING_MULTIPLE_FILES_MODE); - } + const events = [TRACKING_CLICK_SINGLE_FILE_SETTING]; - queueRedisHllEvents(events); + if (fileByFile) { + events.push(TRACKING_SINGLE_FILE_MODE); + } else { + events.push(TRACKING_MULTIPLE_FILES_MODE); } + queueRedisHllEvents(events); + return axios .put(state.endpointUpdateUser, { view_diffs_file_by_file: fileByFile, diff --git a/app/assets/javascripts/diffs/utils/performance.js b/app/assets/javascripts/diffs/utils/performance.js index 50bf17001a6..ad768c333e2 100644 --- a/app/assets/javascripts/diffs/utils/performance.js +++ b/app/assets/javascripts/diffs/utils/performance.js @@ -7,7 +7,7 @@ import { MR_DIFFS_MARK_DIFF_FILES_END, MR_DIFFS_MEASURE_FILE_TREE_DONE, MR_DIFFS_MEASURE_DIFF_FILES_DONE, -} from '../../performance/constants'; +} from '~/performance/constants'; import { EVT_PERF_MARK_FILE_TREE_START, diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue new file mode 100644 index 00000000000..1427f2df461 --- /dev/null +++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue @@ -0,0 +1,70 @@ +<script> +import { isEmpty } from 'lodash'; +import { GlButtonGroup } from '@gitlab/ui'; +import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql'; +import { EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants'; +import SourceEditorToolbarButton from './source_editor_toolbar_button.vue'; + +export default { + name: 'SourceEditorToolbar', + components: { + SourceEditorToolbarButton, + GlButtonGroup, + }, + data() { + return { + items: [], + }; + }, + apollo: { + items: { + query: getToolbarItemsQuery, + update(data) { + return this.setDefaultGroup(data?.items?.nodes); + }, + }, + }, + computed: { + isVisible() { + return this.items.length; + }, + }, + methods: { + setDefaultGroup(nodes = []) { + return nodes.map((item) => { + return { + ...item, + group: + (this.$options.groups.includes(item.group) && item.group) || EDITOR_TOOLBAR_RIGHT_GROUP, + }; + }); + }, + getGroupItems(group) { + return this.items.filter((item) => item.group === group); + }, + hasGroupItems(group) { + return !isEmpty(this.getGroupItems(group)); + }, + }, + groups: [EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP], +}; +</script> +<template> + <section + v-if="isVisible" + id="se-toolbar" + class="gl-py-3 gl-px-5 gl-bg-white gl-border-t gl-border-b gl-display-flex gl-justify-content-space-between gl-align-items-center" + > + <template v-for="group in $options.groups"> + <gl-button-group v-if="hasGroupItems(group)" :key="group"> + <template v-for="item in getGroupItems(group)"> + <source-editor-toolbar-button + :key="item.id" + :button="item" + @click="$emit('click', item)" + /> + </template> + </gl-button-group> + </template> + </section> +</template> diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue new file mode 100644 index 00000000000..2595d67af34 --- /dev/null +++ b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue @@ -0,0 +1,89 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql'; +import getToolbarItemQuery from '~/editor/graphql/get_item.query.graphql'; + +export default { + name: 'SourceEditorToolbarButton', + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + button: { + type: Object, + required: false, + default() { + return {}; + }, + }, + }, + data() { + return { + buttonItem: this.button, + }; + }, + apollo: { + buttonItem: { + query: getToolbarItemQuery, + variables() { + return { + id: this.button.id, + }; + }, + update({ item }) { + return item; + }, + skip() { + return !this.button.id; + }, + }, + }, + computed: { + icon() { + return this.buttonItem.selected + ? this.buttonItem.selectedIcon || this.buttonItem.icon + : this.buttonItem.icon; + }, + label() { + return this.buttonItem.selected + ? this.buttonItem.selectedLabel || this.buttonItem.label + : this.buttonItem.label; + }, + }, + methods: { + clickHandler() { + if (this.buttonItem.onClick) { + this.buttonItem.onClick(); + } + this.$apollo.mutate({ + mutation: updateToolbarItemMutation, + variables: { + id: this.buttonItem.id, + propsToUpdate: { + selected: !this.buttonItem.selected, + }, + }, + }); + this.$emit('click'); + }, + }, +}; +</script> +<template> + <div> + <gl-button + v-gl-tooltip.hover + :category="buttonItem.category" + :variant="buttonItem.variant" + type="button" + :selected="buttonItem.selected" + :icon="icon" + :title="label" + :aria-label="label" + @click="clickHandler" + /> + </div> +</template> diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index 2ae9c377683..361122d8890 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -12,6 +12,9 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor'; export const EDITOR_CODE_INSTANCE_FN = 'createInstance'; export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance'; +export const EDITOR_TOOLBAR_LEFT_GROUP = 'left'; +export const EDITOR_TOOLBAR_RIGHT_GROUP = 'right'; + export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__( 'SourceEditor|"el" parameter is required for createInstance()', ); diff --git a/app/assets/javascripts/editor/graphql/get_item.query.graphql b/app/assets/javascripts/editor/graphql/get_item.query.graphql new file mode 100644 index 00000000000..7c8bc09f7b0 --- /dev/null +++ b/app/assets/javascripts/editor/graphql/get_item.query.graphql @@ -0,0 +1,9 @@ +query ToolbarItem($id: String!) { + item(id: $id) @client { + id + label + icon + selected + group + } +} diff --git a/app/assets/javascripts/editor/graphql/get_items.query.graphql b/app/assets/javascripts/editor/graphql/get_items.query.graphql new file mode 100644 index 00000000000..bfac816d276 --- /dev/null +++ b/app/assets/javascripts/editor/graphql/get_items.query.graphql @@ -0,0 +1,5 @@ +query ToolbarItems { + items @client { + nodes + } +} diff --git a/app/assets/javascripts/editor/graphql/update_item.mutation.graphql b/app/assets/javascripts/editor/graphql/update_item.mutation.graphql new file mode 100644 index 00000000000..f8424c65181 --- /dev/null +++ b/app/assets/javascripts/editor/graphql/update_item.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateItem($id: String!, $propsToUpdate: Item!) { + updateToolbarItem(id: $id, propsToUpdate: $propsToUpdate) @client +} diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 1c56327c03c..fe3229ac91b 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -187,6 +187,21 @@ } ] }, + "coverage_report": { + "type": "object", + "description": "Used to collect coverage reports from the job.", + "properties": { + "coverage_format": { + "description": "Code coverage format used by the test framework.", + "enum": ["cobertura"] + }, + "path": { + "description": "Path to the coverage report file that should be parsed.", + "type": "string", + "minLength": 1 + } + } + }, "codequality": { "$ref": "#/definitions/string_file_list", "description": "Path to file or list of files with code quality report(s) (such as Code Climate)." @@ -1276,7 +1291,7 @@ }, "pipeline_variables": { "type": "boolean", - "description": "Variables added for manual pipeline runs are passed to downstream pipelines.", + "description": "Variables added for manual pipeline runs and scheduled pipelines are passed to downstream pipelines.", "default": false } } @@ -1392,7 +1407,7 @@ }, "pipeline_variables": { "type": "boolean", - "description": "Variables added for manual pipeline runs are passed to downstream pipelines.", + "description": "Variables added for manual pipeline runs and scheduled pipelines are passed to downstream pipelines.", "default": false } } diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js index 0986533dcd1..931407f4cf7 100644 --- a/app/assets/javascripts/emoji/awards_app/index.js +++ b/app/assets/javascripts/emoji/awards_app/index.js @@ -5,6 +5,8 @@ import AwardsList from '~/vue_shared/components/awards_list.vue'; import createstore from './store'; export default (el) => { + if (!el) return null; + const { dataset: { path }, } = el; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index aaae1624bee..4fdcdcc1b04 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -245,5 +245,12 @@ export function glEmojiTag(inputName, options) { ? `data-fallback-sprite-class="${escape(fallbackSpriteClass)}" ` : ''; - return `<gl-emoji ${fallbackSpriteAttribute}data-name="${escape(name)}"></gl-emoji>`; + const fallbackUrl = opts.url; + const fallbackSrcAttribute = fallbackUrl + ? `data-fallback-src="${fallbackUrl}" data-unicode-version="custom"` + : ''; + + return `<gl-emoji ${fallbackSrcAttribute}${fallbackSpriteAttribute}data-name="${escape( + name, + )}"></gl-emoji>`; } diff --git a/app/assets/javascripts/emoji/support/unicode_support_map.js b/app/assets/javascripts/emoji/support/unicode_support_map.js index d90a774c293..9642993bd7d 100644 --- a/app/assets/javascripts/emoji/support/unicode_support_map.js +++ b/app/assets/javascripts/emoji/support/unicode_support_map.js @@ -1,4 +1,4 @@ -import AccessorUtilities from '../../lib/utils/accessor'; +import AccessorUtilities from '~/lib/utils/accessor'; const GL_EMOJI_VERSION = '0.2.0'; diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue index 36b9b647af7..563fa6c96fb 100644 --- a/app/assets/javascripts/environments/components/empty_state.vue +++ b/app/assets/javascripts/environments/components/empty_state.vue @@ -1,4 +1,7 @@ <script> +import { s__ } from '~/locale'; +import { ENVIRONMENTS_SCOPE } from '../constants'; + export default { name: 'EnvironmentsEmptyState', props: { @@ -6,6 +9,25 @@ export default { type: String, required: true, }, + scope: { + type: String, + required: true, + }, + }, + computed: { + title() { + return this.$options.i18n.title[this.scope]; + }, + }, + i18n: { + title: { + [ENVIRONMENTS_SCOPE.AVAILABLE]: s__("Environments|You don't have any environments."), + [ENVIRONMENTS_SCOPE.STOPPED]: s__("Environments|You don't have any stopped environments."), + }, + content: s__( + 'Environments|Environments are places where code gets deployed, such as staging or production.', + ), + link: s__('Environments|How do I create an environment?'), }, }; </script> @@ -13,14 +35,11 @@ export default { <div class="empty-state"> <div class="text-content"> <h4 class="js-blank-state-title"> - {{ s__("Environments|You don't have any environments right now") }} + {{ title }} </h4> <p> - {{ - s__(`Environments|Environments are places where - code gets deployed, such as staging or production.`) - }} - <a :href="helpPath"> {{ s__('Environments|More information') }} </a> + {{ $options.i18n.content }} + <a :href="helpPath"> {{ $options.i18n.link }} </a> </p> </div> </div> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index cfe35d26b94..7ffe8140a21 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,12 +1,20 @@ <script> -import { GlDropdown, GlTooltipDirective, GlIcon, GlLink, GlSprintf, GlBadge } from '@gitlab/ui'; +import { + GlDropdown, + GlTooltipDirective, + GlIcon, + GlLink, + GlSprintf, + GlBadge, + GlAvatar, + GlAvatarLink, +} from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __, s__, sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import CommitComponent from '~/vue_shared/components/commit.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import eventHub from '../event_hub'; import ActionsComponent from './environment_actions.vue'; @@ -41,7 +49,8 @@ export default { StopComponent, TerminalButtonComponent, TooltipOnTruncate, - UserAvatarLink, + GlAvatar, + GlAvatarLink, CiIcon, }, directives: { @@ -649,22 +658,27 @@ export default { class="table-section deployment-column d-none d-md-block" :class="tableData.deploy.spacing" role="gridcell" - data-testid="enviornment-deployment-id-cell" + data-testid="environment-deployment-id-cell" > <span v-if="shouldRenderDeploymentID" class="text-break-word"> {{ deploymentInternalId }} </span> - <span v-if="!isFolder && deploymentHasUser" class="text-break-word"> + <span + v-if="!isFolder && deploymentHasUser" + class="text-break-word gl-display-inline-flex gl-align-items-center" + > <gl-sprintf :message="s__('Environments|by %{avatar}')"> <template #avatar> - <user-avatar-link - :link-href="deploymentUser.web_url" - :img-src="deploymentUser.avatar_url" - :img-alt="userImageAltDescription" - :tooltip-text="deploymentUser.username" - class="js-deploy-user-container float-none" - /> + <gl-avatar-link :href="deploymentUser.web_url" class="gl-ml-2"> + <gl-avatar + :src="deploymentUser.avatar_url" + :entity-name="deploymentUser.username" + :title="deploymentUser.username" + :alt="userImageAltDescription" + :size="24" + /> + </gl-avatar-link> </template> </gl-sprintf> </span> @@ -753,20 +767,24 @@ export default { <ci-icon class="gl-mr-2" :status="upcomingDeployment.deployable.status" /> </gl-link> </div> - <div class="gl-display-flex"> - <span v-if="upcomingDeployment.user" class="text-break-word"> - <gl-sprintf :message="s__('Environments|by %{avatar}')"> - <template #avatar> - <user-avatar-link - :link-href="upcomingDeployment.user.web_url" - :img-src="upcomingDeployment.user.avatar_url" - :img-alt="upcomingDeploymentUserImageAltDescription" - :tooltip-text="upcomingDeployment.user.username" + <span + v-if="upcomingDeployment.user" + class="text-break-word gl-display-inline-flex gl-align-items-center gl-mt-2" + > + <gl-sprintf :message="s__('Environments|by %{avatar}')"> + <template #avatar> + <gl-avatar-link :href="upcomingDeployment.user.web_url" class="gl-ml-2"> + <gl-avatar + :src="upcomingDeployment.user.avatar_url" + :alt="upcomingDeploymentUserImageAltDescription" + :entity-name="upcomingDeployment.user.username" + :title="upcomingDeployment.user.username" + :size="24" /> - </template> - </gl-sprintf> - </span> - </div> + </gl-avatar-link> + </template> + </gl-sprintf> + </span> </div> </div> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index c7008c03099..f44182e822b 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -253,7 +253,7 @@ export default { @change="resetPolling" /> </template> - <empty-state v-else :help-path="helpPagePath" /> + <empty-state v-else :help-path="helpPagePath" :scope="scope" /> <gl-pagination align="center" :total-items="totalItems" diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index f35fabccae7..f5e9d612316 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -2,6 +2,7 @@ import { GlCollapse, GlDropdown, + GlBadge, GlButton, GlLink, GlSprintf, @@ -26,6 +27,7 @@ export default { components: { GlCollapse, GlDropdown, + GlBadge, GlButton, GlLink, GlSprintf, @@ -74,6 +76,7 @@ export default { 'Environments|There are no deployments for this environment yet. %{linkStart}Learn more about setting up deployments.%{linkEnd}', ), autoStopIn: s__('Environment|Auto stop %{time}'), + tierTooltip: s__('Environment|Deployment tier'), }, data() { return { visible: false }; @@ -100,6 +103,9 @@ export default { hasDeployment() { return Boolean(this.environment?.upcomingDeployment || this.environment?.lastDeployment); }, + tier() { + return this.lastDeployment?.tierInYaml; + }, hasOpenedAlert() { return this.environment?.hasOpenedAlert; }, @@ -206,6 +212,13 @@ export default { > {{ displayName }} </gl-link> + <gl-badge + v-if="tier" + v-gl-tooltip + :title="$options.i18n.tierTooltip" + class="gl-ml-3 gl-font-monospace" + >{{ tier }}</gl-badge + > </div> <div class="gl-display-flex gl-align-items-center"> <p v-if="canShowAutoStopDate" class="gl-font-sm gl-text-gray-700 gl-mr-5 gl-mb-0"> diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 206381e0b7e..4e5fe511f8a 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import Translate from '../../vue_shared/translate'; +import Translate from '~/vue_shared/translate'; import environmentsFolderApp from './environments_folder_view.vue'; Vue.use(Translate); diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index a7866c1e778..722bb78bcf9 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -24,7 +24,7 @@ const mapNestedEnvironment = (env) => ({ __typename: 'NestedLocalEnvironment', }); const mapEnvironment = (env) => ({ - ...convertObjectPropsToCamelCase(env), + ...convertObjectPropsToCamelCase(env, { deep: true }), __typename: 'LocalEnvironment', }); diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 0f9741784d6..8957a3074ed 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -4,11 +4,11 @@ import { isEqual, isFunction, omitBy } from 'lodash'; import Visibility from 'visibilityjs'; import createFlash from '~/flash'; -import Poll from '../../lib/utils/poll'; -import { getParameterByName } from '../../lib/utils/url_utility'; -import { s__, __ } from '../../locale'; -import tabs from '../../vue_shared/components/navigation_tabs.vue'; -import tablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; +import Poll from '~/lib/utils/poll'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import { s__, __ } from '~/locale'; +import tabs from '~/vue_shared/components/navigation_tabs.vue'; +import tablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import container from '../components/container.vue'; import environmentTable from '../components/environments_table.vue'; import eventHub from '../event_hub'; diff --git a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js index a76c8e445ed..55e2536e283 100644 --- a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js @@ -4,7 +4,7 @@ * Components need to have `scope`, `page` and `requestData` */ import { validateParams } from '~/pipelines/utils'; -import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/common_utils'; +import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; export default { methods: { diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js index 747f368b671..b26a96499ba 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js @@ -1,6 +1,6 @@ import createFlash from '~/flash'; -import axios from '../lib/utils/axios_utils'; -import { __ } from '../locale'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; export const getSelector = (highlightId) => `.js-feature-highlight[data-highlight=${highlightId}]`; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js index c3514198ad9..c147dd20c84 100644 --- a/app/assets/javascripts/filtered_search/services/recent_searches_service.js +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js @@ -1,4 +1,4 @@ -import AccessorUtilities from '../../lib/utils/accessor'; +import AccessorUtilities from '~/lib/utils/accessor'; import RecentSearchesServiceError from './recent_searches_service_error'; class RecentSearchesService { diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index fa605f8c056..24ec16bf20e 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -94,10 +94,10 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => { * * 1. Render a new alert * - * import { createAlert, ALERT_VARIANTS } from '~/flash'; + * import { createAlert, VARIANT_WARNING } from '~/flash'; * * createAlert({ message: 'My error message' }); - * createAlert({ message: 'My warning message', variant: ALERT_VARIANTS.WARNING }); + * createAlert({ message: 'My warning message', variant: VARIANT_WARNING }); * * 2. Dismiss this alert programmatically * diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue index 03b256297f6..b3d773e6bee 100644 --- a/app/assets/javascripts/google_cloud/components/app.vue +++ b/app/assets/javascripts/google_cloud/components/app.vue @@ -45,7 +45,7 @@ export default { }, methods: { feedbackUrl(template) { - return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new?issuable_template=${template}`; + return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=${template}`; }, }, }; diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js index f42152006d2..a44a5b30e1e 100644 --- a/app/assets/javascripts/google_tag_manager/index.js +++ b/app/assets/javascripts/google_tag_manager/index.js @@ -232,35 +232,40 @@ export const trackTransaction = (transactionDetails) => { pushEnhancedEcommerceEvent('EECtransactionSuccess', eventData); }; -export const trackAddToCartUsageTab = () => { +export const pushEECproductAddToCartEvent = () => { if (!isSupported()) { return; } - const getStartedButton = document.querySelector('.js-buy-additional-minutes'); - getStartedButton.addEventListener('click', () => { - window.dataLayer.push({ - event: 'EECproductAddToCart', - ecommerce: { - currencyCode: 'USD', - add: { - products: [ - { - name: 'CI/CD Minutes', - id: '0003', - price: '10', - brand: 'GitLab', - category: 'DevOps', - variant: 'add-on', - quantity: 1, - }, - ], - }, + window.dataLayer.push({ + event: 'EECproductAddToCart', + ecommerce: { + currencyCode: 'USD', + add: { + products: [ + { + name: 'CI/CD Minutes', + id: '0003', + price: '10', + brand: 'GitLab', + category: 'DevOps', + variant: 'add-on', + quantity: 1, + }, + ], }, - }); + }, }); }; +export const trackAddToCartUsageTab = () => { + const getStartedButton = document.querySelector('.js-buy-additional-minutes'); + if (!getStartedButton) { + return; + } + getStartedButton.addEventListener('click', pushEECproductAddToCartEvent); +}; + export const trackCombinedGroupProjectForm = () => { if (!isSupported()) { return; diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 4ebb49b4756..22fa2912881 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -3,6 +3,7 @@ export const MINIMUM_SEARCH_LENGTH = 3; export const TYPE_BOARD = 'Board'; export const TYPE_CI_RUNNER = 'Ci::Runner'; export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact'; +export const TYPE_CRM_ORGANIZATION = 'CustomerRelations::Organization'; export const TYPE_DISCUSSION = 'Discussion'; export const TYPE_EPIC = 'Epic'; export const TYPE_EPIC_BOARD = 'Boards::EpicBoard'; @@ -19,3 +20,4 @@ export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; export const TYPE_SITE_PROFILE = 'DastSiteProfile'; export const TYPE_USER = 'User'; export const TYPE_VULNERABILITY = 'Vulnerability'; +export const TYPE_WORK_ITEM = 'WorkItem'; diff --git a/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/page_info.fragment.graphql index e6f5d7db11a..e6f5d7db11a 100644 --- a/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/page_info.fragment.graphql diff --git a/app/assets/javascripts/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/page_info_cursors_only.fragment.graphql index 22bcefbecd3..22bcefbecd3 100644 --- a/app/assets/javascripts/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/page_info_cursors_only.fragment.graphql diff --git a/app/assets/javascripts/graphql_shared/possibleTypes.json b/app/assets/javascripts/graphql_shared/possibleTypes.json deleted file mode 100644 index 01116067887..00000000000 --- a/app/assets/javascripts/graphql_shared/possibleTypes.json +++ /dev/null @@ -1 +0,0 @@ -{"AlertManagementIntegration":["AlertManagementHttpIntegration","AlertManagementPrometheusIntegration"],"CurrentUserTodos":["BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest"],"DependencyLinkMetadata":["NugetDependencyLinkMetadata"],"DesignFields":["Design","DesignAtVersion"],"Entry":["Blob","Submodule","TreeEntry"],"Eventable":["BoardEpic","Epic"],"Issuable":["Epic","Issue","MergeRequest","WorkItem"],"JobNeedUnion":["CiBuildNeed","CiJob"],"MemberInterface":["GroupMember","ProjectMember"],"NoteableInterface":["AlertManagementAlert","BoardEpic","Design","Epic","EpicIssue","Issue","MergeRequest","Snippet","Vulnerability"],"NoteableType":["Design","Issue","MergeRequest"],"OrchestrationPolicy":["ScanExecutionPolicy","ScanResultPolicy"],"PackageFileMetadata":["ConanFileMetadata","HelmFileMetadata"],"PackageMetadata":["ComposerMetadata","ConanMetadata","MavenMetadata","NugetMetadata","PypiMetadata"],"ResolvableInterface":["Discussion","Note"],"Service":["BaseService","JiraService"],"TimeboxReportInterface":["Iteration","Milestone"],"Todoable":["AlertManagementAlert","BoardEpic","Commit","Design","Epic","EpicIssue","Issue","MergeRequest"],"User":["MergeRequestAssignee","MergeRequestAuthor","MergeRequestParticipant","MergeRequestReviewer","UserCore"],"VulnerabilityDetail":["VulnerabilityDetailBase","VulnerabilityDetailBoolean","VulnerabilityDetailCode","VulnerabilityDetailCommit","VulnerabilityDetailDiff","VulnerabilityDetailFileLocation","VulnerabilityDetailInt","VulnerabilityDetailList","VulnerabilityDetailMarkdown","VulnerabilityDetailModuleLocation","VulnerabilityDetailTable","VulnerabilityDetailText","VulnerabilityDetailUrl"],"VulnerabilityLocation":["VulnerabilityLocationClusterImageScanning","VulnerabilityLocationContainerScanning","VulnerabilityLocationCoverageFuzzing","VulnerabilityLocationDast","VulnerabilityLocationDependencyScanning","VulnerabilityLocationGeneric","VulnerabilityLocationSast","VulnerabilityLocationSecretDetection"]}
\ No newline at end of file diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json new file mode 100644 index 00000000000..3d6360fc4f8 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -0,0 +1,129 @@ +{ + "AlertManagementIntegration": [ + "AlertManagementHttpIntegration", + "AlertManagementPrometheusIntegration" + ], + "CurrentUserTodos": [ + "BoardEpic", + "Design", + "Epic", + "EpicIssue", + "Issue", + "MergeRequest" + ], + "DependencyLinkMetadata": [ + "NugetDependencyLinkMetadata" + ], + "DesignFields": [ + "Design", + "DesignAtVersion" + ], + "Entry": [ + "Blob", + "Submodule", + "TreeEntry" + ], + "Eventable": [ + "BoardEpic", + "Epic" + ], + "Issuable": [ + "Epic", + "Issue", + "MergeRequest", + "WorkItem" + ], + "JobNeedUnion": [ + "CiBuildNeed", + "CiJob" + ], + "MemberInterface": [ + "GroupMember", + "ProjectMember" + ], + "NoteableInterface": [ + "AlertManagementAlert", + "BoardEpic", + "Design", + "Epic", + "EpicIssue", + "Issue", + "MergeRequest", + "Snippet", + "Vulnerability" + ], + "NoteableType": [ + "Design", + "Issue", + "MergeRequest" + ], + "OrchestrationPolicy": [ + "ScanExecutionPolicy", + "ScanResultPolicy" + ], + "PackageFileMetadata": [ + "ConanFileMetadata", + "HelmFileMetadata" + ], + "PackageMetadata": [ + "ComposerMetadata", + "ConanMetadata", + "MavenMetadata", + "NugetMetadata", + "PypiMetadata" + ], + "ResolvableInterface": [ + "Discussion", + "Note" + ], + "Service": [ + "BaseService", + "JiraService" + ], + "TimeboxReportInterface": [ + "Iteration", + "Milestone" + ], + "Todoable": [ + "AlertManagementAlert", + "BoardEpic", + "Commit", + "Design", + "Epic", + "EpicIssue", + "Issue", + "MergeRequest" + ], + "User": [ + "MergeRequestAssignee", + "MergeRequestAuthor", + "MergeRequestParticipant", + "MergeRequestReviewer", + "UserCore" + ], + "VulnerabilityDetail": [ + "VulnerabilityDetailBase", + "VulnerabilityDetailBoolean", + "VulnerabilityDetailCode", + "VulnerabilityDetailCommit", + "VulnerabilityDetailDiff", + "VulnerabilityDetailFileLocation", + "VulnerabilityDetailInt", + "VulnerabilityDetailList", + "VulnerabilityDetailMarkdown", + "VulnerabilityDetailModuleLocation", + "VulnerabilityDetailTable", + "VulnerabilityDetailText", + "VulnerabilityDetailUrl" + ], + "VulnerabilityLocation": [ + "VulnerabilityLocationClusterImageScanning", + "VulnerabilityLocationContainerScanning", + "VulnerabilityLocationCoverageFuzzing", + "VulnerabilityLocationDast", + "VulnerabilityLocationDependencyScanning", + "VulnerabilityLocationGeneric", + "VulnerabilityLocationSast", + "VulnerabilityLocationSecretDetection" + ] +} diff --git a/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql index 58b7b4c898d..b59bd781537 100644 --- a/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getProjects( $search: String! diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue index 5f169832ee4..042d818338a 100644 --- a/app/assets/javascripts/groups/components/group_folder.vue +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -1,6 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; -import { n__ } from '../../locale'; +import { n__ } from '~/locale'; import { MAX_CHILDREN_COUNT } from '../constants'; export default { diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 707008ec493..4f21f68fa65 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -9,6 +9,7 @@ import { } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants'; import eventHub from '../event_hub'; @@ -112,6 +113,7 @@ export default { }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, + AVATAR_SHAPE_OPTION_RECT, }; </script> @@ -131,7 +133,7 @@ export default { > <div class="folder-toggle-wrap gl-mr-2 d-flex align-items-center"> <item-caret :is-group-open="group.isOpen" /> - <item-type-icon :item-type="group.type" :is-group-open="group.isOpen" /> + <item-type-icon :item-type="group.type" /> </div> <gl-loading-icon v-if="group.isChildrenLoading" @@ -145,7 +147,7 @@ export default { :aria-label="group.name" > <gl-avatar - shape="rect" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" :entity-name="group.name" :src="group.avatarUrl" :alt="group.name" diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 18a6d487703..313c8dadd1f 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -1,6 +1,6 @@ <script> import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; -import { getParameterByName } from '../../lib/utils/url_utility'; +import { getParameterByName } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; export default { diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index 3620c884c5f..2aa812250a0 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -55,7 +55,7 @@ export default { :title="__('Subgroups')" :value="item.subgroupCount" css-class="number-subgroups gl-ml-5" - icon-name="folder-o" + icon-name="subgroup" data-testid="subgroups-count" /> <item-stats-value @@ -63,7 +63,7 @@ export default { :title="__('Projects')" :value="item.projectCount" css-class="number-projects gl-ml-5" - icon-name="bookmark" + icon-name="project" data-testid="projects-count" /> <item-stats-value diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue index c3787c2df21..7821e604700 100644 --- a/app/assets/javascripts/groups/components/item_type_icon.vue +++ b/app/assets/javascripts/groups/components/item_type_icon.vue @@ -11,18 +11,13 @@ export default { type: String, required: true, }, - isGroupOpen: { - type: Boolean, - required: true, - default: false, - }, }, computed: { iconClass() { if (this.itemType === ITEM_TYPE.GROUP) { - return this.isGroupOpen ? 'folder-open' : 'folder-o'; + return 'subgroup'; } - return 'bookmark'; + return 'project'; }, }, }; diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index 005bac1e7b5..cacba2dfd23 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -1,4 +1,4 @@ -import { __, s__ } from '../locale'; +import { __, s__ } from '~/locale'; export const MAX_CHILDREN_COUNT = 20; diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index c2ef6414716..360a8d3bf8d 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -95,15 +95,10 @@ function trackShowUserDropdownLink(trackEvent, elToTrack, el) { export function initNavUserDropdownTracking() { const el = document.querySelector('.js-nav-user-dropdown'); const buyEl = document.querySelector('.js-buy-pipeline-minutes-link'); - const upgradeEl = document.querySelector('.js-upgrade-plan-link'); if (el && buyEl) { trackShowUserDropdownLink('show_buy_ci_minutes', buyEl, el); } - - if (el && upgradeEl) { - trackShowUserDropdownLink('show_upgrade_link', upgradeEl, el); - } } requestIdleCallback(initStatusTriggers); diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index 36fc48a2ba8..4406cacdf3f 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -11,6 +11,7 @@ import { SEARCH_BOX_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, + SEARCH_SHORTCUTS_MIN_CHARACTERS, } from '../constants'; import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue'; @@ -50,7 +51,7 @@ export default { }, computed: { ...mapState(['search', 'loading']), - ...mapGetters(['searchQuery', 'searchOptions']), + ...mapGetters(['searchQuery', 'searchOptions', 'autocompleteGroupedSearchOptions']), searchText: { get() { return this.search; @@ -66,14 +67,20 @@ export default { return this.currentFocusedOption?.html_id; }, isLoggedIn() { - return gon?.current_username; + return Boolean(gon?.current_username); }, showSearchDropdown() { - return this.showDropdown && this.isLoggedIn; + const hasResultsUnderMinCharacters = + this.searchText?.length === 1 ? this?.autocompleteGroupedSearchOptions?.length > 0 : true; + + return this.showDropdown && this.isLoggedIn && hasResultsUnderMinCharacters; }, showDefaultItems() { return !this.searchText; }, + showShortcuts() { + return this.searchText && this.searchText?.length >= SEARCH_SHORTCUTS_MIN_CHARACTERS; + }, defaultIndex() { if (this.showDefaultItems) { return SEARCH_BOX_INDEX; @@ -105,6 +112,9 @@ export default { count: this.searchOptions.length, }); }, + headerSearchActivityDescriptor() { + return this.showDropdown ? 'is-active' : 'is-not-active'; + }, }, methods: { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), @@ -136,13 +146,15 @@ export default { v-outside="closeDropdown" role="search" :aria-label="$options.i18n.searchGitlab" - class="header-search gl-relative" + class="header-search gl-relative gl-rounded-base" + :class="headerSearchActivityDescriptor" > <gl-search-box-by-type id="search" v-model="searchText" role="searchbox" class="gl-z-index-1" + data-qa-selector="search_term_field" autocomplete="off" :placeholder="$options.i18n.searchGitlab" :aria-activedescendant="currentFocusedId" @@ -182,7 +194,10 @@ export default { :current-focused-option="currentFocusedOption" /> <template v-else> - <header-search-scoped-items :current-focused-option="currentFocusedOption" /> + <header-search-scoped-items + v-if="showShortcuts" + :current-focused-option="currentFocusedOption" + /> <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> </template> </div> diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue index c0e2c18bece..025c48f355d 100644 --- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue @@ -11,7 +11,18 @@ import { import { mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import highlight from '~/lib/utils/highlight'; -import { GROUPS_CATEGORY, PROJECTS_CATEGORY, LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; +import { truncateNamespace } from '~/lib/utils/text_utility'; + +import { + GROUPS_CATEGORY, + PROJECTS_CATEGORY, + MERGE_REQUEST_CATEGORY, + ISSUES_CATEGORY, + RECENT_EPICS_CATEGORY, + LARGE_AVATAR_PX, + SMALL_AVATAR_PX, +} from '../constants'; export default { name: 'HeaderSearchAutocompleteItems', @@ -39,7 +50,7 @@ export default { }, }, computed: { - ...mapState(['search', 'loading', 'autocompleteError']), + ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']), ...mapGetters(['autocompleteGroupedSearchOptions']), }, watch: { @@ -52,6 +63,13 @@ export default { }, }, methods: { + truncateNamespace(string) { + if (string.split(' / ').length > 2) { + return truncateNamespace(string); + } + + return string; + }, highlightedName(val) { return highlight(val, this.search); }, @@ -65,15 +83,45 @@ export default { isOptionFocused(data) { return this.currentFocusedOption?.html_id === data.html_id; }, + isProjectsCategory(data) { + return data.category === PROJECTS_CATEGORY; + }, + getEntityId(data) { + switch (data.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return data.group_id || data.id || this.searchContext?.group?.id; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return data.project_id || data.id || this.searchContext?.project?.id; + default: + return data.id; + } + }, + getEntitytName(data) { + switch (data.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return data.group_name || data.value || data.label || this.searchContext?.group?.name; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return data.project_name || data.value || data.label || this.searchContext?.project?.name; + default: + return data.label; + } + }, }, + AVATAR_SHAPE_OPTION_RECT, }; </script> <template> <div> <template v-if="!loading"> - <div v-for="option in autocompleteGroupedSearchOptions" :key="option.category"> - <gl-dropdown-divider /> + <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category"> + <gl-dropdown-divider v-if="index > 0" /> <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header> <gl-dropdown-item v-for="data in option.data" @@ -90,12 +138,22 @@ export default { <gl-avatar v-if="data.avatar_url !== undefined" :src="data.avatar_url" - :entity-id="data.id" - :entity-name="data.label" + :entity-id="getEntityId(data)" + :entity-name="getEntitytName(data)" :size="avatarSize(data)" - shape="square" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> - <span v-safe-html="highlightedName(data.label)"></span> + <span class="gl-display-flex gl-flex-direction-column"> + <span + v-safe-html="highlightedName(data.value || data.label)" + class="gl-text-gray-900" + ></span> + <span + v-if="data.value" + v-safe-html="truncateNamespace(data.label)" + class="gl-font-sm gl-text-gray-500" + ></span> + </span> </div> </gl-dropdown-item> </div> diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue index 3aebee71509..34d1bd71399 100644 --- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { __, sprintf } from '~/locale'; @@ -7,6 +7,7 @@ export default { name: 'HeaderSearchScopedItems', components: { GlDropdownItem, + GlDropdownDivider, }, props: { currentFocusedOption: { @@ -17,7 +18,7 @@ export default { }, computed: { ...mapState(['search']), - ...mapGetters(['scopedSearchOptions']), + ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']), }, methods: { isOptionFocused(option) { @@ -53,5 +54,6 @@ export default { <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span> </span> </gl-dropdown-item> + <gl-dropdown-divider v-if="autocompleteGroupedSearchOptions.length > 0" /> </div> </template> diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index b2e45fcd648..045a552efb0 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -20,6 +20,12 @@ export const GROUPS_CATEGORY = 'Groups'; export const PROJECTS_CATEGORY = 'Projects'; +export const ISSUES_CATEGORY = 'Recent issues'; + +export const MERGE_REQUEST_CATEGORY = 'Recent merge requests'; + +export const RECENT_EPICS_CATEGORY = 'Recent epics'; + export const LARGE_AVATAR_PX = 32; export const SMALL_AVATAR_PX = 16; @@ -28,6 +34,8 @@ export const FIRST_DROPDOWN_INDEX = 0; export const SEARCH_BOX_INDEX = -1; +export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2; + export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description'; diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js index ee4c312fed0..3a86dcca409 100644 --- a/app/assets/javascripts/header_search/store/actions.js +++ b/app/assets/javascripts/header_search/store/actions.js @@ -5,7 +5,9 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => { commit(types.REQUEST_AUTOCOMPLETE); return axios .get(getters.autocompleteQuery) - .then(({ data }) => commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data)) + .then(({ data }) => { + commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data); + }) .catch(() => { commit(types.RECEIVE_AUTOCOMPLETE_ERROR); }); diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js index 87dec95153f..7d08aa859fb 100644 --- a/app/assets/javascripts/header_search/store/getters.js +++ b/app/assets/javascripts/header_search/store/getters.js @@ -190,7 +190,6 @@ export const autocompleteGroupedSearchOptions = (state) => { results.push(groupedOptions[option.category]); } }); - return results; }; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 44f543d9a76..38f3b094b7c 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -160,7 +160,7 @@ export default { data-testid="begin-commit-button" @click="beginCommit" > - {{ __('Commit…') }} + {{ __('Create commit...') }} </gl-button> </div> <p class="text-center bold">{{ overviewText }}</p> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 8f0e5aef456..2799ea1378e 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlPopover } from '@gitlab/ui'; -import { __, sprintf } from '../../../locale'; +import { __, sprintf } from '~/locale'; import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants'; export default { diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index e345e5dc099..45bbf93ebc9 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -166,7 +166,7 @@ export default { }} </p> <gl-button - variant="success" + variant="confirm" category="primary" :title="__('New file')" :aria-label="__('New file')" diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 28ca1b6750f..32f87cb0a92 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -3,8 +3,8 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; import timeAgoMixin from '~/vue_shared/mixins/timeago'; -import CiIcon from '../../vue_shared/components/ci_icon.vue'; -import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import { rightSidebarViews } from '../constants'; import IdeStatusList from './ide_status_list.vue'; import IdeStatusMr from './ide_status_mr.vue'; diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index 55ae5501cdb..8d6a0b99e0c 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective, GlButton, GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; import { throttle } from 'lodash'; import { mapActions, mapState } from 'vuex'; -import { __ } from '../../../locale'; +import { __ } from '~/locale'; import JobDescription from './detail/description.vue'; import ScrollButton from './detail/scroll_button.vue'; diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue index 9eaeabad5ef..8fd1973267c 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/description.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue @@ -1,6 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; -import CiIcon from '../../../../vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue index 6e1929a1948..bcbc68421c2 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '../../../../locale'; +import { __ } from '~/locale'; const directions = { up: 'up', diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 796ca1349c5..7797850f097 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon, GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui'; -import CiIcon from '../../../vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Item from './item.vue'; export default { diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue index 3699073adb8..6c26cde42e3 100644 --- a/app/assets/javascripts/ide/components/nav_dropdown_button.vue +++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue @@ -30,12 +30,12 @@ export default { </script> <template> - <dropdown-button> + <dropdown-button class="gl-w-full!"> <span class="row gl-flex-nowrap"> <span class="col-auto flex-fill text-truncate"> <gl-icon :size="16" :aria-label="__('Current Branch')" name="branch" /> {{ branchLabel }} </span> - <span v-if="showMergeRequests" class="col-5 pl-0 text-truncate"> + <span v-if="showMergeRequests" class="col-auto pl-0 text-truncate"> <gl-icon :size="16" :aria-label="__('Merge request')" name="merge-request" /> {{ mergeRequestLabel }} </span> diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 0ec808339fb..37a405e3fac 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -2,7 +2,7 @@ import { escape } from 'lodash'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; import { logError } from '~/lib/logger'; -import api from '../../../api'; +import api from '~/api'; import service from '../../services'; import * as types from '../mutation_types'; diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index f46d3cbe946..20d8dc3381d 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -5,7 +5,7 @@ import { WEBIDE_MARK_FETCH_FILES_START, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; -import { __ } from '../../../locale'; +import { __ } from '~/locale'; import { decorateFiles } from '../../lib/files'; import service from '../../services'; import * as types from '../mutation_types'; diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index 05e3601f381..0e7254e67be 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -1,4 +1,4 @@ -import { __ } from '../../../../locale'; +import { __ } from '~/locale'; import { COMMIT_TO_NEW_BRANCH } from './constants'; const BRANCH_SUFFIX_COUNT = 5; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js index 8446b93d14a..3408245b245 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js @@ -1,5 +1,5 @@ -import Api from '../../../../api'; -import { __ } from '../../../../locale'; +import Api from '~/api'; +import { __ } from '~/locale'; import { scopes } from './constants'; import * as types from './mutation_types'; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js index 51872993f16..62476b7fc63 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js @@ -1,8 +1,8 @@ import axios from 'axios'; import Visibility from 'visibilityjs'; -import httpStatus from '../../../../lib/utils/http_status'; -import Poll from '../../../../lib/utils/poll'; -import { __ } from '../../../../locale'; +import httpStatus from '~/lib/utils/http_status'; +import Poll from '~/lib/utils/poll'; +import { __ } from '~/locale'; import { rightSidebarViews } from '../../../constants'; import service from '../../../services'; import * as types from './mutation_types'; diff --git a/app/assets/javascripts/ide/stores/plugins/terminal.js b/app/assets/javascripts/ide/stores/plugins/terminal.js index 94139d5bdf0..f7ed3075b0c 100644 --- a/app/assets/javascripts/ide/stores/plugins/terminal.js +++ b/app/assets/javascripts/ide/stores/plugins/terminal.js @@ -3,10 +3,10 @@ import terminalModule from '../modules/terminal'; function getPathsFromData(el) { return { - webTerminalSvgPath: el.dataset.eeWebTerminalSvgPath, - webTerminalHelpPath: el.dataset.eeWebTerminalHelpPath, - webTerminalConfigHelpPath: el.dataset.eeWebTerminalConfigHelpPath, - webTerminalRunnersHelpPath: el.dataset.eeWebTerminalRunnersHelpPath, + webTerminalSvgPath: el.dataset.webTerminalSvgPath, + webTerminalHelpPath: el.dataset.webTerminalHelpPath, + webTerminalConfigHelpPath: el.dataset.webTerminalConfigHelpPath, + webTerminalRunnersHelpPath: el.dataset.webTerminalRunnersHelpPath, }; } diff --git a/app/assets/javascripts/image_diff/helpers/init_image_diff.js b/app/assets/javascripts/image_diff/helpers/init_image_diff.js index 51168b94e6d..55e1d802201 100644 --- a/app/assets/javascripts/image_diff/helpers/init_image_diff.js +++ b/app/assets/javascripts/image_diff/helpers/init_image_diff.js @@ -1,4 +1,4 @@ -import ImageFile from '../../commit/image_file'; +import ImageFile from '~/commit/image_file'; import ImageDiff from '../image_diff'; import ReplacedImageDiff from '../replaced_image_diff'; diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index cc6a057f587..9262a4e1e95 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -1,10 +1,66 @@ <script> -import { GlIcon } from '@gitlab/ui'; -import STATUS_MAP from '../constants'; +import { GlAccordion, GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { __, s__ } from '~/locale'; +import { STATUSES } from '../constants'; + +const STATISTIC_ITEMS = { + diff_note: __('Diff notes'), + issue: __('Issues'), + label: __('Labels'), + milestone: __('Milestones'), + note: __('Notes'), + pull_request: s__('GithubImporter|Pull requests'), + pull_request_merged_by: s__('GithubImporter|PR mergers'), + pull_request_review: s__('GithubImporter|PR reviews'), + release: __('Releases'), +}; + +// support both camel case and snake case versions +Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS)); + +const SCHEDULED_STATUS = { + icon: 'status-scheduled', + text: __('Pending'), + variant: 'muted', +}; + +const STATUS_MAP = { + [STATUSES.NONE]: { + icon: 'status-waiting', + text: __('Not started'), + variant: 'muted', + }, + [STATUSES.SCHEDULING]: SCHEDULED_STATUS, + [STATUSES.SCHEDULED]: SCHEDULED_STATUS, + [STATUSES.CREATED]: SCHEDULED_STATUS, + [STATUSES.STARTED]: { + icon: 'status-running', + text: __('Importing...'), + variant: 'info', + }, + [STATUSES.FAILED]: { + icon: 'status-failed', + text: __('Failed'), + variant: 'danger', + }, + [STATUSES.CANCELLED]: { + icon: 'status-stopped', + text: __('Cancelled'), + variant: 'neutral', + }, +}; + +function isIncompleteImport(stats) { + return Object.keys(stats.fetched).some((key) => stats.fetched[key] !== stats.imported[key]); +} export default { name: 'ImportStatus', components: { + GlAccordion, + GlAccordionItem, + GlBadge, GlIcon, }, props: { @@ -12,19 +68,88 @@ export default { type: String, required: true, }, + stats: { + type: Object, + required: false, + default: () => ({ fetched: {}, imported: {} }), + }, }, computed: { + knownStats() { + const knownStatisticKeys = Object.keys(STATISTIC_ITEMS); + return Object.keys(this.stats.fetched).filter((key) => knownStatisticKeys.includes(key)); + }, + + hasStats() { + return this.stats && this.knownStats.length > 0; + }, + mappedStatus() { + if (this.status === STATUSES.FINISHED) { + const isIncomplete = this.stats && isIncompleteImport(this.stats); + return { + icon: 'status-success', + ...(isIncomplete + ? { + text: __('Partial import'), + variant: 'warning', + } + : { + text: __('Complete'), + variant: 'success', + }), + }; + } + return STATUS_MAP[this.status]; }, }, + + methods: { + getStatisticIconProps(key) { + const fetched = this.stats.fetched[key]; + const imported = this.stats.imported[key]; + + if (fetched === imported) { + return { name: 'status-success', class: 'gl-text-green-400' }; + } else if (imported === 0) { + return { name: 'status-scheduled', class: 'gl-text-gray-400' }; + } + + return { name: 'status-running', class: 'gl-text-blue-400' }; + }, + }, + + STATISTIC_ITEMS, }; </script> <template> <div> - <gl-icon :name="mappedStatus.icon" :class="mappedStatus.iconClass" :size="12" class="gl-mr-2" /> - <span>{{ mappedStatus.text }}</span> + <div class="gl-display-inline-block gl-w-13"> + <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" class="gl-mr-2"> + {{ mappedStatus.text }} + </gl-badge> + </div> + <gl-accordion v-if="hasStats" :header-level="3"> + <gl-accordion-item :title="__('Details')"> + <ul class="gl-p-0 gl-list-style-none gl-font-sm"> + <li v-for="key in knownStats" :key="key"> + <div class="gl-display-flex gl-w-20 gl-align-items-center"> + <gl-icon + :size="12" + class="gl-mr-3 gl-flex-shrink-0" + v-bind="getStatisticIconProps(key)" + /> + <span class="">{{ $options.STATISTIC_ITEMS[key] }}</span> + <span class="gl-ml-auto"> + {{ stats.imported[key] || 0 }}/{{ stats.fetched[key] }} + </span> + </div> + </li> + </ul> + </gl-accordion-item> + </gl-accordion> </div> </template> diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js index 156e92e2d00..20a4d2d84b4 100644 --- a/app/assets/javascripts/import_entities/constants.js +++ b/app/assets/javascripts/import_entities/constants.js @@ -1,5 +1,3 @@ -import { __ } from '../locale'; - // The `scheduling` status is only present on the client-side, // it is used as the status when we are requesting to start an import. @@ -13,42 +11,3 @@ export const STATUSES = { SCHEDULING: 'scheduling', CANCELLED: 'cancelled', }; - -const SCHEDULED_STATUS = { - icon: 'status-scheduled', - text: __('Pending'), - iconClass: 'gl-text-orange-400', -}; - -const STATUS_MAP = { - [STATUSES.NONE]: { - icon: 'status-waiting', - text: __('Not started'), - iconClass: 'gl-text-gray-400', - }, - [STATUSES.SCHEDULING]: SCHEDULED_STATUS, - [STATUSES.SCHEDULED]: SCHEDULED_STATUS, - [STATUSES.CREATED]: SCHEDULED_STATUS, - [STATUSES.STARTED]: { - icon: 'status-running', - text: __('Importing...'), - iconClass: 'gl-text-blue-400', - }, - [STATUSES.FINISHED]: { - icon: 'status-success', - text: __('Complete'), - iconClass: 'gl-text-green-400', - }, - [STATUSES.FAILED]: { - icon: 'status-failed', - text: __('Failed'), - iconClass: 'gl-text-red-600', - }, - [STATUSES.CANCELLED]: { - icon: 'status-stopped', - text: __('Cancelled'), - iconClass: 'gl-text-red-600', - }, -}; - -export default STATUS_MAP; diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index bd0f4cd5dd7..e0703a77424 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -109,7 +109,7 @@ export default { </template> <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-mb-5"> <gl-button - variant="success" + variant="confirm" :loading="isImportingAnyRepo" :disabled="!hasImportableRepos" type="button" diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index c3d0ca4ed8c..e4090a378e1 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -69,6 +69,10 @@ export default { return getImportStatus(this.repo); }, + stats() { + return this.repo.importedProject?.stats; + }, + importTarget() { return this.getImportTarget(this.repo.importSource.id); }, @@ -101,11 +105,11 @@ export default { <template> <tr - class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11" + class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11 gl-vertical-align-top" data-qa-selector="project_import_row" :data-qa-source-project="repo.importSource.fullName" > - <td class="gl-p-4"> + <td class="gl-p-4 gl-vertical-align-top"> <gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink" >{{ repo.importSource.fullName }} <gl-icon v-if="repo.importSource.providerLink" name="external-link" /> @@ -156,10 +160,10 @@ export default { </template> <template v-else-if="repo.importedProject">{{ displayFullPath }}</template> </td> - <td class="gl-p-4" data-qa-selector="import_status_indicator"> - <import-status :status="importStatus" /> + <td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator"> + <import-status :status="importStatus" :stats="stats" /> </td> - <td data-testid="actions"> + <td data-testid="actions" class="gl-vertical-align-top gl-pt-4"> <gl-button v-if="isFinished" class="btn btn-default" diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js index 110cc77b20d..5146a0eb461 100644 --- a/app/assets/javascripts/import_entities/import_projects/index.js +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -16,12 +16,13 @@ export function initStoreFromElement(element) { jobsPath, importPath, namespacesPath, + defaultTargetNamespace, paginatable, } = element.dataset; return createStore({ initialState: { - defaultTargetNamespace: gon.current_username, + defaultTargetNamespace, ciCdOnly: parseBoolean(ciCdOnly), canSelectNamespace: parseBoolean(canSelectNamespace), provider, diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js index 45f7a684161..163a19976de 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js @@ -113,7 +113,11 @@ export default { updatedProjects.forEach((updatedProject) => { const repo = state.repositories.find((p) => p.importedProject?.id === updatedProject.id); if (repo?.importedProject) { - repo.importedProject.importStatus = updatedProject.importStatus; + repo.importedProject = { + ...repo.importedProject, + stats: updatedProject.stats, + importStatus: updatedProject.importStatus, + }; } }); }, diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 324797ad645..bfc5bd823a2 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -33,6 +33,7 @@ import { TH_CREATED_AT_TEST_ID, TH_INCIDENT_SLA_TEST_ID, TH_SEVERITY_TEST_ID, + TH_ESCALATION_STATUS_TEST_ID, TH_PUBLISHED_TEST_ID, INCIDENT_DETAILS_PATH, trackIncidentCreateNewOptions, @@ -67,8 +68,11 @@ export default { { key: 'escalationStatus', label: s__('IncidentManagement|Status'), - thClass: `${thClass} gl-w-eighth gl-pointer-events-none`, - tdClass, + thClass: `${thClass} gl-w-eighth`, + tdClass: `${tdClass} sortable-cell`, + actualSortKey: 'ESCALATION_STATUS', + sortable: true, + thAttr: TH_ESCALATION_STATUS_TEST_ID, }, { key: 'createdAt', @@ -354,7 +358,7 @@ export default { :loading="redirecting" :disabled="redirecting" category="primary" - variant="success" + variant="confirm" :href="newIncidentPath" @click="navigateToCreateNewIncident" > @@ -388,19 +392,24 @@ export default { </template> <template #cell(title)="{ item }"> - <div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }"> + <div + :class="{ + 'gl-display-flex gl-align-items-center gl-max-w-full': item.state === 'closed', + }" + > <gl-link - v-gl-tooltip - :title="item.title" data-testid="incident-link" :href="showIncidentLink(item)" + class="gl-min-w-0" > - {{ item.title }} + <tooltip-on-truncate :title="item.title" class="gl-text-truncate gl-display-block"> + {{ item.title }} + </tooltip-on-truncate> </gl-link> <gl-icon v-if="item.state === 'closed'" name="issue-close" - class="gl-mx-1 gl-fill-blue-500 gl-flex-shrink-0" + class="gl-ml-2 gl-fill-blue-500 gl-flex-shrink-0" :size="16" data-testid="incident-closed" /> diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js index 21cdbef05a1..ee3f30de880 100644 --- a/app/assets/javascripts/incidents/constants.js +++ b/app/assets/javascripts/incidents/constants.js @@ -47,6 +47,7 @@ export const ESCALATION_STATUSES = { export const DEFAULT_PAGE_SIZE = 20; export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' }; +export const TH_ESCALATION_STATUS_TEST_ID = { 'data-testid': 'incident-management-status-sort' }; export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla' }; export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' }; export const INCIDENT_DETAILS_PATH = 'incident'; diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 6e89872ff68..661299920c7 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -4,7 +4,6 @@ import axios from 'axios'; import * as Sentry from '@sentry/browser'; import { mapState, mapActions, mapGetters } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, @@ -18,8 +17,6 @@ import { testIntegrationSettings } from '../api'; import ActiveCheckbox from './active_checkbox.vue'; import ConfirmationModal from './confirmation_modal.vue'; import DynamicField from './dynamic_field.vue'; -import JiraIssuesFields from './jira_issues_fields.vue'; -import JiraTriggerFields from './jira_trigger_fields.vue'; import OverrideDropdown from './override_dropdown.vue'; import ResetConfirmationModal from './reset_confirmation_modal.vue'; import TriggerFields from './trigger_fields.vue'; @@ -29,8 +26,6 @@ export default { components: { OverrideDropdown, ActiveCheckbox, - JiraTriggerFields, - JiraIssuesFields, TriggerFields, DynamicField, ConfirmationModal, @@ -54,12 +49,6 @@ export default { GlModal: GlModalDirective, SafeHtml, }, - mixins: [glFeatureFlagsMixin()], - provide() { - return { - hasSections: this.hasSections, - }; - }, inject: { helpHtml: { default: '', @@ -80,9 +69,6 @@ export default { isEditable() { return this.propsSource.editable; }, - isJira() { - return this.propsSource.type === 'jira'; - }, isInstanceOrGroupLevel() { return ( this.customState.integrationLevel === integrationLevels.INSTANCE || @@ -98,14 +84,11 @@ export default { disableButtons() { return Boolean(this.isSaving || this.isResetting || this.isTesting); }, - sectionsEnabled() { - return this.glFeatures.integrationFormSections; - }, hasSections() { - return this.sectionsEnabled && this.customState.sections.length !== 0; + return this.customState.sections.length !== 0; }, fieldsWithoutSection() { - return this.sectionsEnabled + return this.hasSections ? this.propsSource.fields.filter((field) => !field.section) : this.propsSource.fields; }, @@ -184,6 +167,9 @@ export default { this.integrationActive = integrationActive; }, }, + descriptionHtmlConfig: { + ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented + }, helpHtmlConfig: { ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented ADD_TAGS: ['use'], // to support icon SVGs @@ -229,7 +215,7 @@ export default { <div class="row"> <div class="col-lg-4"> <h4 class="gl-mt-0">{{ section.title }}</h4> - <p v-safe-html="section.description"></p> + <p v-safe-html:[$options.descriptionHtmlConfig]="section.description"></p> </div> <div class="col-lg-8"> @@ -257,14 +243,8 @@ export default { :key="`${currentKey}-active-checkbox`" @toggle-integration-active="onToggleIntegrationState" /> - <jira-trigger-fields - v-if="isJira && !hasSections" - :key="`${currentKey}-jira-trigger-fields`" - v-bind="propsSource.triggerFieldsProps" - :is-validated="isValidated" - /> <trigger-fields - v-else-if="propsSource.triggerEvents.length && !hasSections" + v-if="propsSource.triggerEvents.length && !hasSections" :key="`${currentKey}-trigger-fields`" :events="propsSource.triggerEvents" :type="propsSource.type" @@ -275,13 +255,6 @@ export default { v-bind="field" :is-validated="isValidated" /> - <jira-issues-fields - v-if="isJira && !isInstanceOrGroupLevel && !hasSections" - :key="`${currentKey}-jira-issues-fields`" - v-bind="propsSource.jiraIssuesProps" - :is-validated="isValidated" - @request-jira-issue-types="onRequestJiraIssueTypes" - /> </div> </div> diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 7cf8e11f162..f00339c92fa 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -1,5 +1,5 @@ <script> -import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { s__, __ } from '~/locale'; import JiraUpgradeCta from './jira_upgrade_cta.vue'; @@ -10,17 +10,10 @@ export default { GlFormGroup, GlFormCheckbox, GlFormInput, - GlSprintf, - GlLink, JiraUpgradeCta, JiraIssueCreationVulnerabilities: () => import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'), }, - inject: { - hasSections: { - default: false, - }, - }, props: { showJiraIssuesIntegration: { type: Boolean, @@ -52,21 +45,11 @@ export default { required: false, default: null, }, - gitlabIssuesEnabled: { - type: Boolean, - required: false, - default: true, - }, upgradePlanPath: { type: String, required: false, default: '', }, - editProjectPath: { - type: String, - required: false, - default: '', - }, isValidated: { type: Boolean, required: false, @@ -86,7 +69,6 @@ export default { }, }, i18n: { - sectionTitle: s__('JiraService|View Jira issues in GitLab'), sectionDescription: s__( 'JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues.', ), @@ -97,89 +79,70 @@ export default { projectKeyLabel: s__('JiraService|Jira project key'), projectKeyPlaceholder: s__('JiraService|For example, AB'), requiredFieldFeedback: __('This field is required.'), - issueTrackerConflictWarning: s__( - 'JiraService|Displaying Jira issues while leaving GitLab issues also enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.', - ), }, }; </script> <template> <div> - <gl-form-group - :label="hasSections ? null : $options.i18n.sectionTitle" - label-for="jira-issue-settings" - > - <div id="jira-issue-settings"> - <p v-if="!hasSections"> - {{ $options.i18n.sectionDescription }} - </p> - <template v-if="showJiraIssuesIntegration"> - <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" /> - <gl-form-checkbox - v-model="enableJiraIssues" - :disabled="isInheriting" - data-qa-selector="service_jira_issues_enabled_checkbox" - > - {{ $options.i18n.enableCheckboxLabel }} - <template #help> - {{ $options.i18n.enableCheckboxHelp }} - </template> - </gl-form-checkbox> - <template v-if="enableJiraIssues"> - <jira-issue-creation-vulnerabilities - :project-key="projectKey" - :initial-is-enabled="initialEnableJiraVulnerabilities" - :initial-issue-type-id="initialVulnerabilitiesIssuetype" - :show-full-feature="showJiraVulnerabilitiesIntegration" - data-testid="jira-for-vulnerabilities" - @request-jira-issue-types="$emit('request-jira-issue-types')" - /> - <jira-upgrade-cta - v-if="!showJiraVulnerabilitiesIntegration" - class="gl-mt-2 gl-ml-6" - data-testid="ultimate-upgrade-cta" - show-ultimate-message - :upgrade-plan-path="upgradePlanPath" - /> - </template> + <template v-if="showJiraIssuesIntegration"> + <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" /> + <gl-form-checkbox + v-model="enableJiraIssues" + :disabled="isInheriting" + data-qa-selector="service_jira_issues_enabled_checkbox" + > + {{ $options.i18n.enableCheckboxLabel }} + <template #help> + {{ $options.i18n.enableCheckboxHelp }} </template> + </gl-form-checkbox> + + <div v-if="enableJiraIssues" class="gl-pl-6 gl-mt-3"> + <gl-form-group + :label="$options.i18n.projectKeyLabel" + label-for="service_project_key" + :invalid-feedback="$options.i18n.requiredFieldFeedback" + :state="validProjectKey" + class="gl-max-w-26" + data-testid="project-key-form-group" + > + <gl-form-input + id="service_project_key" + v-model="projectKey" + name="service[project_key]" + data-qa-selector="service_jira_project_key_field" + :placeholder="$options.i18n.projectKeyPlaceholder" + :required="enableJiraIssues" + :state="validProjectKey" + :readonly="isInheriting" + /> + </gl-form-group> + + <jira-issue-creation-vulnerabilities + :project-key="projectKey" + :initial-is-enabled="initialEnableJiraVulnerabilities" + :initial-issue-type-id="initialVulnerabilitiesIssuetype" + :show-full-feature="showJiraVulnerabilitiesIntegration" + class="gl-mt-6" + data-testid="jira-for-vulnerabilities" + @request-jira-issue-types="$emit('request-jira-issue-types')" + /> <jira-upgrade-cta - v-else - class="gl-mt-2" - data-testid="premium-upgrade-cta" - show-premium-message + v-if="!showJiraVulnerabilitiesIntegration" + class="gl-mt-2 gl-ml-6" + data-testid="ultimate-upgrade-cta" + show-ultimate-message :upgrade-plan-path="upgradePlanPath" /> </div> - </gl-form-group> - <template v-if="showJiraIssuesIntegration"> - <gl-form-group - :label="$options.i18n.projectKeyLabel" - label-for="service_project_key" - :invalid-feedback="$options.i18n.requiredFieldFeedback" - :state="validProjectKey" - data-testid="project-key-form-group" - > - <gl-form-input - id="service_project_key" - v-model="projectKey" - name="service[project_key]" - data-qa-selector="service_jira_project_key_field" - :placeholder="$options.i18n.projectKeyPlaceholder" - :required="enableJiraIssues" - :state="validProjectKey" - :disabled="!enableJiraIssues" - :readonly="isInheriting" - /> - </gl-form-group> - <p v-if="gitlabIssuesEnabled" data-testid="conflict-warning-text"> - <gl-sprintf :message="$options.i18n.issueTrackerConflictWarning"> - <template #link="{ content }"> - <gl-link :href="editProjectPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> </template> + + <jira-upgrade-cta + v-else + data-testid="premium-upgrade-cta" + show-premium-message + :upgrade-plan-path="upgradePlanPath" + /> </div> </template> diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index 3c06660e7c5..c7cbdff72e3 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -62,11 +62,6 @@ export default { GlLink, GlSprintf, }, - inject: { - hasSections: { - default: false, - }, - }, props: { initialTriggerCommit: { type: Boolean, @@ -138,17 +133,7 @@ export default { <template> <div> - <gl-form-group - :label="hasSections ? null : __('Trigger')" - label-for="service[trigger]" - :description=" - hasSections - ? null - : s__( - 'JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.', - ) - " - > + <div class="gl-mb-5"> <input name="service[commit_events]" type="hidden" :value="triggerCommit || false" /> <gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting"> {{ __('Commit') }} @@ -162,7 +147,7 @@ export default { <gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting"> {{ __('Merge request') }} </gl-form-checkbox> - </gl-form-group> + </div> <gl-form-group v-show="showTriggerSettings" diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 3e58dd0be99..9a9aae36657 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -1,9 +1,12 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import IntegrationForm from './components/integration_form.vue'; import { createStore } from './store'; +Vue.use(GlToast); + function parseBooleanInData(data) { const result = {}; Object.entries(data).forEach(([key, value]) => { @@ -19,7 +22,6 @@ function parseDatasetToProps(data) { commentDetail, projectKey, upgradePlanPath, - editProjectPath, learnMorePath, triggerEvents, sections, @@ -49,7 +51,6 @@ function parseDatasetToProps(data) { showJiraVulnerabilitiesIntegration, enableJiraIssues, enableJiraVulnerabilities, - gitlabIssuesEnabled, } = parseBooleanInData(booleanAttributes); return { @@ -78,9 +79,7 @@ function parseDatasetToProps(data) { initialEnableJiraVulnerabilities: enableJiraVulnerabilities, initialVulnerabilitiesIssuetype: vulnerabilitiesIssuetype, initialProjectKey: projectKey, - gitlabIssuesEnabled, upgradePlanPath, - editProjectPath, }, learnMorePath, triggerEvents: JSON.parse(triggerEvents), diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue index 04a8ec3400f..fc14b2eba6a 100644 --- a/app/assets/javascripts/invite_members/components/group_select.vue +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -24,10 +24,6 @@ export default { prop: 'selectedGroup', }, props: { - accessLevels: { - type: Object, - required: true, - }, groupsFilter: { type: String, required: false, @@ -58,13 +54,6 @@ export default { isFetchResultEmpty() { return this.groups.length === 0; }, - defaultFetchOptions() { - return { - exclude_internal: true, - active: true, - min_access_level: this.accessLevels.Guest, - }; - }, }, watch: { searchTerm() { @@ -107,9 +96,13 @@ export default { fetchGroups() { switch (this.groupsFilter) { case GROUP_FILTERS.DESCENDANT_GROUPS: - return getDescendentGroups(this.parentGroupId, this.searchTerm, this.defaultFetchOptions); + return getDescendentGroups( + this.parentGroupId, + this.searchTerm, + this.$options.defaultFetchOptions, + ); default: - return getGroups(this.searchTerm, this.defaultFetchOptions); + return getGroups(this.searchTerm, this.$options.defaultFetchOptions); } }, }, @@ -118,6 +111,10 @@ export default { searchPlaceholder: s__('GroupSelect|Search groups'), emptySearchResult: s__('GroupSelect|No matching results'), }, + defaultFetchOptions: { + exclude_internal: true, + active: true, + }, }; </script> <template> diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue index f266d978ffa..2ad4bb1a11a 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -2,11 +2,11 @@ import { uniqueId } from 'lodash'; import Api from '~/api'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; +import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants'; import eventHub from '../event_hub'; import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; import GroupSelect from './group_select.vue'; -import InviteModalBase from './invite_modal_base.vue'; export default { name: 'InviteMembersModal', @@ -19,6 +19,10 @@ export default { type: String, required: true, }, + rootId: { + type: String, + required: true, + }, isProject: { type: Boolean, required: true, @@ -147,6 +151,8 @@ export default { :label-intro-text="labelIntroText" :label-search-field="$options.labels.searchField" :submit-disabled="inviteDisabled" + :new-group-to-invite="groupToBeSharedWith.id" + :root-group-id="rootId" :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" @reset="resetFields" @@ -155,7 +161,6 @@ export default { <template #select> <group-select v-model="groupToBeSharedWith" - :access-levels="accessLevels" :groups-filter="groupSelectFilter" :parent-group-id="groupSelectParentId" :invalid-groups="invalidGroups" diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index be48a58d838..a9aa0e9b760 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -24,6 +24,7 @@ import { responseMessageFromSuccess } from '../utils/response_message_parser'; import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; import ModalConfetti from './confetti.vue'; import MembersTokenSelect from './members_token_select.vue'; +import UserLimitNotification from './user_limit_notification.vue'; export default { name: 'InviteMembersModal', @@ -37,6 +38,7 @@ export default { InviteModalBase, MembersTokenSelect, ModalConfetti, + UserLimitNotification, }, inject: ['newProjectPath'], props: { @@ -44,6 +46,10 @@ export default { type: String, required: true, }, + rootId: { + type: String, + required: true, + }, isProject: { type: Boolean, required: true, @@ -187,46 +193,28 @@ export default { this.invalidFeedbackMessage = ''; const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); - const promises = []; - const baseData = { + + const apiAddByInvite = this.isProject + ? Api.inviteProjectMembers.bind(Api) + : Api.inviteGroupMembers.bind(Api); + + const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {}; + const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {}; + + this.trackinviteMembersForTask(); + + apiAddByInvite(this.id, { format: 'json', expires_at: expiresAt, access_level: accessLevel, invite_source: this.source, tasks_to_be_done: this.tasksToBeDoneForPost, tasks_project_id: this.tasksProjectForPost, - }; - - if (usersToInviteByEmail !== '') { - const apiInviteByEmail = this.isProject - ? Api.inviteProjectMembersByEmail.bind(Api) - : Api.inviteGroupMembersByEmail.bind(Api); - - promises.push( - apiInviteByEmail(this.id, { - ...baseData, - email: usersToInviteByEmail, - }), - ); - } - - if (usersToAddById !== '') { - const apiAddByUserId = this.isProject - ? Api.addProjectMembersByUserId.bind(Api) - : Api.addGroupMembersByUserId.bind(Api); - - promises.push( - apiAddByUserId(this.id, { - ...baseData, - user_id: usersToAddById, - }), - ); - } - this.trackinviteMembersForTask(); - - Promise.all(promises) - .then((responses) => { - const message = responseMessageFromSuccess(responses); + ...email, + ...userId, + }) + .then((response) => { + const message = responseMessageFromSuccess(response); if (message) { this.showInvalidFeedbackMessage({ @@ -290,6 +278,8 @@ export default { :submit-disabled="inviteDisabled" :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" + :new-users-to-invite="newUsersToInvite" + :root-group-id="rootId" @reset="resetFields" @submit="sendInvite" @access-level="onAccessLevelUpdate" @@ -302,6 +292,11 @@ export default { <span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span> <modal-confetti v-if="isCelebration" /> </template> + + <template #user-limit-notification> + <user-limit-notification /> + </template> + <template #select="{ validationState, labelId }"> <members-token-select v-model="newUsersToInvite" diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue index bafbe94b8bd..d9297614a7e 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -7,7 +7,6 @@ import { GlDatepicker, GlLink, GlSprintf, - GlButton, GlFormInput, } from '@gitlab/ui'; import { sprintf } from '~/locale'; @@ -41,7 +40,6 @@ export default { GlDropdown, GlDropdownItem, GlSprintf, - GlButton, GlFormInput, ContentTransition, }, @@ -104,6 +102,11 @@ export default { required: false, default: INVITE_BUTTON_TEXT, }, + cancelButtonText: { + type: String, + required: false, + default: CANCEL_BUTTON_TEXT, + }, currentSlot: { type: String, required: false, @@ -114,6 +117,11 @@ export default { required: false, default: () => [], }, + preventCancelDefault: { + type: Boolean, + required: false, + default: false, + }, }, data() { // Be sure to check out reset! @@ -141,6 +149,22 @@ export default { contentSlots() { return [...DEFAULT_SLOTS, ...(this.extraSlots || [])]; }, + actionPrimary() { + return { + text: this.submitButtonText, + attributes: { + variant: 'confirm', + disabled: this.submitDisabled, + loading: this.isLoading, + 'data-qa-selector': 'invite_button', + }, + }; + }, + actionCancel() { + return { + text: this.cancelButtonText, + }; + }, }, watch: { selectedAccessLevel: { @@ -151,7 +175,7 @@ export default { }, }, methods: { - reset() { + onReset() { // This component isn't necessarily disposed, // so we might need to reset it's state. this.selectedAccessLevel = this.defaultAccessLevel; @@ -159,14 +183,23 @@ export default { this.$emit('reset'); }, - closeModal() { - this.reset(); - this.$refs.modal.hide(); + onCloseModal(e) { + if (this.preventCancelDefault) { + e.preventDefault(); + } else { + this.onReset(); + this.$refs.modal.hide(); + } + + this.$emit('cancel'); }, changeSelectedItem(item) { this.selectedAccessLevel = item; }, - submit() { + onSubmit(e) { + // We never want to hide when submitting + e.preventDefault(); + this.$emit('submit', { accessLevel: this.selectedAccessLevel, expiresAt: this.selectedDate, @@ -192,9 +225,11 @@ export default { size="sm" :title="modalTitle" :header-close-label="$options.HEADER_CLOSE_LABEL" - @hidden="reset" - @close="reset" - @hide="reset" + :action-primary="actionPrimary" + :action-cancel="actionCancel" + @primary="onSubmit" + @cancel="onCloseModal" + @hidden="onReset" > <content-transition class="gl-display-grid" @@ -215,6 +250,8 @@ export default { <slot name="intro-text-after"></slot> </div> + <slot name="user-limit-notification"></slot> + <gl-form-group :invalid-feedback="invalidFeedbackMessage" :state="validationState" @@ -280,22 +317,5 @@ export default { <slot :name="key"></slot> </template> </content-transition> - <template #modal-footer> - <slot name="cancel-button"> - <gl-button data-testid="cancel-button" @click="closeModal"> - {{ $options.CANCEL_BUTTON_TEXT }} - </gl-button> - </slot> - <gl-button - :disabled="submitDisabled" - :loading="isLoading" - variant="confirm" - data-qa-selector="invite_button" - data-testid="invite-button" - @click="submit" - > - {{ submitButtonText }} - </gl-button> - </template> </gl-modal> </template> diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index e299e3f27b3..0a191f6d406 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -117,7 +117,7 @@ export default { this.$emit('clear'); }, }, - defaultQueryOptions: { exclude_internal: true, active: true }, + defaultQueryOptions: { without_project_bots: true, active: true }, i18n: { inviteTextMessage: __('Invite "%{email}" by email'), }, diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue new file mode 100644 index 00000000000..beef1aef8a1 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue @@ -0,0 +1,97 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { s__, n__, sprintf } from '~/locale'; + +const CLOSE_TO_LIMIT_COUNT = 2; + +const WARNING_ALERT_TITLE = s__( + 'InviteMembersModal|You only have space for %{count} more %{members} in %{name}', +); + +const DANGER_ALERT_TITLE = s__( + "InviteMembersModal|You've reached your %{count} %{members} limit for %{name}", +); + +const CLOSE_TO_LIMIT_MESSAGE = s__( + 'InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.', +); + +const REACHED_LIMIT_MESSAGE = s__( + 'InviteMembersModal|New members will be unable to participate. You can manage your members by removing ones you no longer need.', +).concat(' ', CLOSE_TO_LIMIT_MESSAGE); + +export default { + name: 'UserLimitNotification', + components: { GlAlert, GlSprintf, GlLink }, + inject: ['name', 'newTrialRegistrationPath', 'purchasePath', 'freeUsersLimit', 'membersCount'], + computed: { + reachedLimit() { + return this.isLimit(); + }, + closeToLimit() { + return this.isLimit(CLOSE_TO_LIMIT_COUNT); + }, + warningAlertTitle() { + return sprintf(WARNING_ALERT_TITLE, { + count: this.freeUsersLimit - this.membersCount, + members: this.pluralMembers(this.freeUsersLimit - this.membersCount), + name: this.name, + }); + }, + dangerAlertTitle() { + return sprintf(DANGER_ALERT_TITLE, { + count: this.freeUsersLimit, + members: this.pluralMembers(this.freeUsersLimit), + name: this.name, + }); + }, + variant() { + return this.reachedLimit ? 'danger' : 'warning'; + }, + title() { + return this.reachedLimit ? this.dangerAlertTitle : this.warningAlertTitle; + }, + message() { + if (this.reachedLimit) { + return this.$options.i18n.reachedLimitMessage; + } + + return this.$options.i18n.closeToLimitMessage; + }, + }, + methods: { + isLimit(deviation = 0) { + if (this.freeUsersLimit && this.membersCount) { + return this.membersCount >= this.freeUsersLimit - deviation; + } + + return false; + }, + pluralMembers(count) { + return n__('member', 'members', count); + }, + }, + i18n: { + reachedLimitMessage: REACHED_LIMIT_MESSAGE, + closeToLimitMessage: CLOSE_TO_LIMIT_MESSAGE, + }, +}; +</script> + +<template> + <gl-alert + v-if="reachedLimit || closeToLimit" + :variant="variant" + :dismissible="false" + :title="title" + > + <gl-sprintf :message="message"> + <template #trialLink="{ content }"> + <gl-link :href="newTrialRegistrationPath" class="gl-label-link">{{ content }}</gl-link> + </template> + <template #upgradeLink="{ content }"> + <gl-link :href="purchasePath" class="gl-label-link">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index cf2ee508184..3cd0bfc0181 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1,4 +1,4 @@ -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; export const SEARCH_DELAY = 200; @@ -14,9 +14,6 @@ export const GROUP_FILTERS = { DESCENDANT_GROUPS: 'descendant_groups', }; -export const API_MESSAGES = { - EMAIL_ALREADY_INVITED: __('Invite email has already been taken'), -}; export const USERS_FILTER_ALL = 'all'; export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id'; export const TRIGGER_ELEMENT_BUTTON = 'button'; diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index e9d620cedf0..958121ad735 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -5,45 +5,47 @@ import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(GlToast); -let initedInviteMembersModal; +export default (function initInviteMembersModal() { + let inviteMembersModal; -export default function initInviteMembersModal() { - if (initedInviteMembersModal) { - // if we already loaded this in another part of the dom, we don't want to do it again - // else we will stack the modals - return false; - } + return () => { + if (!inviteMembersModal) { + // https://gitlab.com/gitlab-org/gitlab/-/issues/344955 + // bug lying in wait here for someone to put group and project invite in same screen + // once that happens we'll need to mount these differently, perhaps split + // group/project to each mount one, with many ways to open it. + const el = document.querySelector('.js-invite-members-modal'); - // https://gitlab.com/gitlab-org/gitlab/-/issues/344955 - // bug lying in wait here for someone to put group and project invite in same screen - // once that happens we'll need to mount these differently, perhaps split - // group/project to each mount one, with many ways to open it. - const el = document.querySelector('.js-invite-members-modal'); + if (!el) { + return false; + } - if (!el) { - return false; - } - - initedInviteMembersModal = true; - - return new Vue({ - el, - name: 'InviteMembersModalRoot', - provide: { - newProjectPath: el.dataset.newProjectPath, - }, - render: (createElement) => - createElement(InviteMembersModal, { - props: { - ...el.dataset, - isProject: parseBoolean(el.dataset.isProject), - accessLevels: JSON.parse(el.dataset.accessLevels), - defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), - tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), - projects: JSON.parse(el.dataset.projects || '[]'), - usersFilter: el.dataset.usersFilter, - filterId: parseInt(el.dataset.filterId, 10), + inviteMembersModal = new Vue({ + el, + name: 'InviteMembersModalRoot', + provide: { + name: el.dataset.name, + newProjectPath: el.dataset.newProjectPath, + newTrialRegistrationPath: el.dataset.newTrialRegistrationPath, + purchasePath: el.dataset.purchasePath, + freeUsersLimit: el.dataset.freeUsersLimit && parseInt(el.dataset.freeUsersLimit, 10), + membersCount: el.dataset.membersCount && parseInt(el.dataset.membersCount, 10), }, - }), - }); -} + render: (createElement) => + createElement(InviteMembersModal, { + props: { + ...el.dataset, + isProject: parseBoolean(el.dataset.isProject), + accessLevels: JSON.parse(el.dataset.accessLevels), + defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), + tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), + projects: JSON.parse(el.dataset.projects || '[]'), + usersFilter: el.dataset.usersFilter, + filterId: parseInt(el.dataset.filterId, 10), + }, + }), + }); + } + return inviteMembersModal; + }; +})(); diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js index 52ec3be3205..db8ac303dc4 100644 --- a/app/assets/javascripts/invite_members/utils/response_message_parser.js +++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js @@ -1,28 +1,15 @@ import { isString } from 'lodash'; -import { API_MESSAGES } from '~/invite_members/constants'; function responseKeyedMessageParsed(keyedMessage) { try { const keys = Object.keys(keyedMessage); const msg = keyedMessage[keys[0]]; - if (msg === API_MESSAGES.EMAIL_ALREADY_INVITED) { - return ''; - } return msg; } catch { return ''; } } -function responseMessageStringForMultiple(message) { - return message.includes(':'); -} -function responseMessageStringFirstPart(message) { - const firstPart = message.split(':')[1]; - const firstMsg = firstPart.split(/ and [\w-]*$/)[0].trim(); - - return firstMsg; -} export function responseMessageFromError(response) { if (!response?.response?.data) { @@ -33,36 +20,25 @@ export function responseMessageFromError(response) { response: { data }, } = response; - return ( - data.error || - data.message?.user?.[0] || - data.message?.access_level?.[0] || - data.message?.error || - data.message || - '' - ); + return data.error || data.message?.error || data.message || ''; } export function responseMessageFromSuccess(response) { - if (!response?.[0]?.data) { + if (!response?.data) { return ''; } - const { data } = response[0]; + const { data } = response; - if (data.message && !data.message.user) { + if (data.message) { const { message } = data; if (isString(message)) { - if (responseMessageStringForMultiple(message)) { - return responseMessageStringFirstPart(message); - } - return message; } return responseKeyedMessageParsed(message); } - return data.message || data.message?.user || data.error || ''; + return data.error || ''; } diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue index 269f720bac9..dadb1419649 100644 --- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue +++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue @@ -102,6 +102,7 @@ export default { :text="$options.i18n.importIssuesText" :text-sr-only="!showLabel" :icon="importButtonIcon" + class="gl-w-full gl-md-w-auto" > <gl-dropdown-item v-gl-modal="importModalId"> {{ $options.i18n.importCsvText }} diff --git a/app/assets/javascripts/issuable/components/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue index 6a0c21602bd..11fc032f34f 100644 --- a/app/assets/javascripts/issuable/components/issue_milestone.vue +++ b/app/assets/javascripts/issuable/components/issue_milestone.vue @@ -72,7 +72,7 @@ export default { </script> <template> <div ref="milestoneDetails" class="issue-milestone-details"> - <gl-icon :size="16" class="gl-mr-2" name="clock" /> + <gl-icon :size="16" class="gl-mr-2 flex-shrink-0" 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 /> diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index 88c1748db0b..018cadad50f 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -12,6 +12,7 @@ import ZenMode from '~/zen_mode'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; const MR_TARGET_BRANCH = 'merge_request[target_branch]'; +const DATA_ISSUES_NEW_PATH = 'data-new-issue-path'; function organizeQuery(obj, isFallbackKey = false) { if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) { @@ -68,6 +69,7 @@ export default class IssuableForm { this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search'); this.zenMode = new ZenMode(); + this.newIssuePath = form[0].getAttribute(DATA_ISSUES_NEW_PATH); this.titleField = this.form.find('input[name*="[title]"]'); this.descriptionField = this.form.find('textarea[name*="[description]"]'); if (!(this.titleField.length && this.descriptionField.length)) { @@ -104,8 +106,8 @@ export default class IssuableForm { } initAutosave() { - const { search } = document.location; - const searchTerm = format(search); + const { search, pathname } = document.location; + const searchTerm = this.newIssuePath === pathname ? '' : format(search); const fallbackKey = getFallbackKey(); this.autosave = new Autosave( diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index 247f8dd0bd6..c96af6da720 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -43,7 +43,7 @@ export default class CreateMergeRequestDropdown { this.refInput = this.wrapperEl.querySelector('.js-ref'); this.refMessage = this.wrapperEl.querySelector('.js-ref-message'); this.unavailableButton = this.wrapperEl.querySelector('.unavailable'); - this.unavailableButtonSpinner = this.unavailableButton.querySelector('.gl-spinner'); + this.unavailableButtonSpinner = this.unavailableButton.querySelector('.js-create-mr-spinner'); this.unavailableButtonText = this.unavailableButton.querySelector('.text'); this.branchCreated = false; @@ -453,7 +453,7 @@ export default class CreateMergeRequestDropdown { removeMessage(target) { const { input, message } = this.getTargetData(target); const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline']; - const messageClasses = ['text-muted', 'text-danger', 'text-success']; + const messageClasses = ['gl-text-gray-600', 'gl-text-red-500', 'gl-text-green-500']; inputClasses.forEach((cssClass) => input.classList.remove(cssClass)); messageClasses.forEach((cssClass) => message.classList.remove(cssClass)); @@ -462,10 +462,10 @@ export default class CreateMergeRequestDropdown { setUnavailableButtonState(isLoading = true) { if (isLoading) { - this.unavailableButtonSpinner.classList.remove('hide'); + this.unavailableButtonSpinner.classList.remove('gl-display-none'); this.unavailableButtonText.textContent = __('Checking branch availability...'); } else { - this.unavailableButtonSpinner.classList.add('hide'); + this.unavailableButtonSpinner.classList.add('gl-display-none'); this.unavailableButtonText.textContent = __('New branch unavailable'); } } @@ -476,7 +476,7 @@ export default class CreateMergeRequestDropdown { this.removeMessage(target); input.classList.add('gl-field-success-outline'); - message.classList.add('text-success'); + message.classList.add('gl-text-green-500'); message.textContent = sprintf(__('%{text} is available'), { text }); message.style.display = 'inline-block'; } @@ -486,7 +486,7 @@ export default class CreateMergeRequestDropdown { const text = target === 'branch' ? __('branch name') : __('source'); this.removeMessage(target); - message.classList.add('text-muted'); + message.classList.add('gl-text-gray-600'); message.textContent = sprintf(__('Checking %{text} availability…'), { text }); message.style.display = 'inline-block'; } @@ -498,7 +498,7 @@ export default class CreateMergeRequestDropdown { this.removeMessage(target); input.classList.add('gl-field-error-outline'); - message.classList.add('text-danger'); + message.classList.add('gl-text-red-500'); message.textContent = text; message.style.display = 'inline-block'; } diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index 2ee9ac2a682..bcd729785b3 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; -import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; @@ -22,6 +21,7 @@ import MilestoneSelect from '~/milestones/milestone_select'; import initNotesApp from '~/notes'; import { store } from '~/notes/stores'; import ZenMode from '~/zen_mode'; +import initAwardsApp from '~/emoji/awards_app'; import FilteredSearchServiceDesk from './filtered_search_service_desk'; export function initFilteredSearchServiceDesk() { @@ -72,15 +72,7 @@ export function initShow() { initRelatedMergeRequests(); initSentryErrorStackTrace(); - const awardEmojiEl = document.getElementById('js-vue-awards-block'); - - if (awardEmojiEl) { - import('~/emoji/awards_app') - .then((m) => m.default(awardEmojiEl)) - .catch(() => {}); - } else { - loadAwardsHandler(); - } + initAwardsApp(document.getElementById('js-vue-awards-block')); import(/* webpackChunkName: 'design_management' */ '~/design_management') .then((module) => module.default()) diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue index aece7372182..1139861ae78 100644 --- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue @@ -1,11 +1,13 @@ <script> import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { IssuableStatus } from '~/issues/constants'; import { dateInWords, getTimeRemainingInWords, isInFuture, isInPast, isToday, + newDateAsLocaleTime, } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; @@ -27,7 +29,7 @@ export default { milestoneDate() { if (this.issue.milestone?.dueDate) { const { dueDate, startDate } = this.issue.milestone; - const date = dateInWords(new Date(dueDate), true); + const date = dateInWords(newDateAsLocaleTime(dueDate), true); const remainingTime = this.milestoneRemainingTime(dueDate, startDate); return `${date} (${remainingTime})`; } @@ -37,10 +39,13 @@ export default { return this.issue.milestone.webPath || this.issue.milestone.webUrl; }, dueDate() { - return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true); + return this.issue.dueDate && dateInWords(newDateAsLocaleTime(this.issue.dueDate), true); }, showDueDateInRed() { - return isInPast(new Date(this.issue.dueDate)) && !this.issue.closedAt; + return ( + isInPast(newDateAsLocaleTime(this.issue.dueDate)) && + this.issue.state !== IssuableStatus.Closed + ); }, timeEstimate() { return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate; @@ -48,8 +53,8 @@ export default { }, methods: { milestoneRemainingTime(dueDate, startDate) { - const due = new Date(dueDate); - const start = new Date(startDate); + const due = newDateAsLocaleTime(dueDate); + const start = newDateAsLocaleTime(startDate); if (dueDate && isInPast(due)) { return __('Past due'); diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index a532fa5b771..a43aed6c521 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -19,6 +19,7 @@ import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ITEM_TYPE } from '~/groups/constants'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; +import { IssuableStatus } from '~/issues/constants'; import axios from '~/lib/utils/axios_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; @@ -260,6 +261,9 @@ export default { showCsvButtons() { return this.isProject && this.isSignedIn; }, + showIssuableByEmail() { + return this.initialEmail && this.isSignedIn; + }, showNewIssueDropdown() { return !this.isProject && this.hasAnyProjects; }, @@ -477,10 +481,10 @@ export default { return `${this.exportCsvPath}${window.location.search}`; }, getStatus(issue) { - if (issue.closedAt && issue.moved) { + if (issue.state === IssuableStatus.Closed && issue.moved) { return this.$options.i18n.closedMoved; } - if (issue.closedAt) { + if (issue.state === IssuableStatus.Closed) { return this.$options.i18n.closed; } return undefined; @@ -624,8 +628,9 @@ export default { </script> <template> - <div v-if="hasAnyIssues"> + <div> <issuable-list + v-if="hasAnyIssues" :namespace="fullPath" recent-searches-storage-key="issues" :search-input-placeholder="$options.i18n.searchPlaceholder" @@ -768,50 +773,50 @@ export default { </template> </issuable-list> - <issuable-by-email v-if="initialEmail" class="gl-text-center gl-pt-5 gl-pb-7" /> - </div> + <template v-else-if="isSignedIn"> + <gl-empty-state + :description="$options.i18n.noIssuesSignedInDescription" + :title="$options.i18n.noIssuesSignedInTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + <csv-import-export-buttons + v-if="showCsvButtons" + class="gl-w-full gl-sm-w-auto gl-sm-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="currentTabCount" + /> + <new-issue-dropdown v-if="showNewIssueDropdown" /> + </template> + </gl-empty-state> + <hr /> + <p class="gl-text-center gl-font-weight-bold gl-mb-0"> + {{ $options.i18n.jiraIntegrationTitle }} + </p> + <p class="gl-text-center gl-mb-0"> + <gl-sprintf :message="$options.i18n.jiraIntegrationMessage"> + <template #jiraDocsLink="{ content }"> + <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p class="gl-text-center gl-text-gray-500"> + {{ $options.i18n.jiraIntegrationSecondaryMessage }} + </p> + </template> - <div v-else-if="isSignedIn"> <gl-empty-state - :description="$options.i18n.noIssuesSignedInDescription" - :title="$options.i18n.noIssuesSignedInTitle" + v-else + :description="$options.i18n.noIssuesSignedOutDescription" + :title="$options.i18n.noIssuesSignedOutTitle" :svg-path="emptyStateSvgPath" - > - <template #actions> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - <csv-import-export-buttons - v-if="showCsvButtons" - class="gl-mr-3" - :export-csv-path="exportCsvPathWithQuery" - :issuable-count="currentTabCount" - /> - <new-issue-dropdown v-if="showNewIssueDropdown" /> - </template> - </gl-empty-state> - <hr /> - <p class="gl-text-center gl-font-weight-bold gl-mb-0"> - {{ $options.i18n.jiraIntegrationTitle }} - </p> - <p class="gl-text-center gl-mb-0"> - <gl-sprintf :message="$options.i18n.jiraIntegrationMessage"> - <template #jiraDocsLink="{ content }"> - <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - <p class="gl-text-center gl-text-gray-500"> - {{ $options.i18n.jiraIntegrationSecondaryMessage }} - </p> - </div> + :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" + :primary-button-link="signInPath" + /> - <gl-empty-state - v-else - :description="$options.i18n.noIssuesSignedOutDescription" - :title="$options.i18n.noIssuesSignedOutTitle" - :svg-path="emptyStateSvgPath" - :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" - :primary-button-link="signInPath" - /> + <issuable-by-email v-if="showIssuableByEmail" class="gl-text-center gl-pt-5 gl-pb-7" /> + </div> </template> diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql index 529262d2162..ec24ea7c56a 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" #import "./issue.fragment.graphql" query getIssues( diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index 430d494deab..d09e4d9df2b 100644 --- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -2,7 +2,6 @@ fragment IssueFragment on Issue { __typename id iid - closedAt confidential createdAt downvotes @@ -11,6 +10,7 @@ fragment IssueFragment on Issue { humanTimeEstimate mergeRequestsCount moved + state title updatedAt upvotes diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js index 8fb891f62f7..bc1cffef943 100644 --- a/app/assets/javascripts/issues/manual_ordering.js +++ b/app/assets/javascripts/issues/manual_ordering.js @@ -1,11 +1,8 @@ import Sortable from 'sortablejs'; -import { - getBoardSortableDefaultOptions, - sortableStart, -} from '~/boards/mixins/sortable_default_options'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; +import { getSortableDefaultOptions, sortableStart } from '~/sortable/utils'; const updateIssue = (url, { move_before_id, move_after_id }) => axios @@ -28,7 +25,7 @@ const initManualOrdering = () => { Sortable.create( issueList, - getBoardSortableDefaultOptions({ + getSortableDefaultOptions({ scroll: true, fallbackTolerance: 1, dataIdAttr: 'data-id', diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 0490728c6bc..456a2029703 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -185,6 +185,11 @@ export default { required: false, default: false, }, + issueId: { + type: Number, + required: false, + default: null, + }, }, data() { const store = new Store({ @@ -322,9 +327,12 @@ export default { }); }, + updateFormState(state) { + this.store.setFormState(state); + }, + updateAndShowForm(templates = {}) { if (!this.showForm) { - this.showForm = true; this.store.setFormState({ title: this.state.titleText, description: this.state.descriptionText, @@ -333,6 +341,7 @@ export default { updateLoading: false, issuableTemplates: templates, }); + this.showForm = true; } }, @@ -364,6 +373,10 @@ export default { }, updateIssuable() { + this.store.setFormState({ + updateLoading: true, + }); + const { store: { formState }, issueState, @@ -371,7 +384,9 @@ export default { const issuablePayload = issueState.isDirty ? { ...formState, issue_type: issueState.issueType } : formState; + this.clearFlash(); + return this.service .updateIssuable(issuablePayload) .then((res) => res.data) @@ -426,7 +441,7 @@ export default { clearFlash() { if (this.flashContainer) { - this.flashContainer.style.display = 'none'; + this.flashContainer.close(); this.flashContainer = null; } }, @@ -468,6 +483,7 @@ export default { :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" :issuable-type="issuableType" + @updateForm="updateFormState" /> </div> <div v-else> @@ -534,6 +550,7 @@ export default { <component :is="descriptionComponent" + :issue-id="issueId" :can-update="canUpdate" :description-html="state.descriptionHtml" :description-text="state.descriptionText" @@ -545,6 +562,7 @@ export default { @taskListUpdateStarted="taskListUpdateStarted" @taskListUpdateSucceeded="taskListUpdateSucceeded" @taskListUpdateFailed="taskListUpdateFailed" + @updateDescription="state.descriptionHtml = $event" /> <edited-component diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 68ed7bb4062..0b7e128c47b 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -2,13 +2,18 @@ import { GlSafeHtmlDirective as SafeHtml, GlModal, + GlToast, + GlTooltip, GlModalDirective, - GlPopover, - GlButton, } from '@gitlab/ui'; import $ from 'jquery'; +import Vue from 'vue'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import createFlash from '~/flash'; -import { __, sprintf } from '~/locale'; +import { isPositiveInteger } from '~/lib/utils/number_utils'; +import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; +import { __, s__, sprintf } from '~/locale'; import TaskList from '~/task_list'; import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -16,6 +21,8 @@ import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal. import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import animateMixin from '../mixins/animate'; +Vue.use(GlToast); + export default { directives: { SafeHtml, @@ -23,9 +30,8 @@ export default { }, components: { GlModal, - GlPopover, CreateWorkItem, - GlButton, + GlTooltip, WorkItemDetailModal, }, mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()], @@ -63,15 +69,24 @@ export default { required: false, default: 0, }, + issueId: { + type: Number, + required: false, + default: null, + }, }, data() { + const workItemId = getParameterByName('work_item_id'); + return { preAnimation: false, pulseAnimation: false, initialUpdate: true, taskButtons: [], activeTask: {}, - workItemId: null, + workItemId: isPositiveInteger(workItemId) + ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId) + : undefined, }; }, computed: { @@ -81,6 +96,9 @@ export default { workItemsEnabled() { return this.glFeatures.workItems; }, + issueGid() { + return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null; + }, }, watch: { descriptionHtml(newDescription, oldDescription) { @@ -92,6 +110,9 @@ export default { this.$nextTick(() => { this.renderGFM(); + if (this.workItemsEnabled) { + this.renderTaskActions(); + } }); }, taskStatus() { @@ -168,9 +189,25 @@ export default { return; } + this.taskButtons = []; const taskListFields = this.$el.querySelectorAll('.task-list-item'); taskListFields.forEach((item, index) => { + const taskLink = item.querySelector('.gfm-issue'); + if (taskLink) { + const { issue, referenceType } = taskLink.dataset; + taskLink.addEventListener('click', (e) => { + e.preventDefault(); + this.workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue); + this.updateWorkItemIdUrlQuery(issue); + this.track('viewed_work_item_from_modal', { + category: 'workItems:show', + label: 'work_item_view', + property: `type_${referenceType}`, + }); + }); + return; + } const button = document.createElement('button'); button.classList.add( 'btn', @@ -188,59 +225,44 @@ export default { this.taskButtons.push(button.id); button.innerHTML = ` <svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14"> - <use href="${gon.sprite_icons}#ellipsis_v"></use> + <use href="${gon.sprite_icons}#doc-new"></use> </svg> `; + button.setAttribute('aria-label', s__('WorkItem|Convert to work item')); + button.addEventListener('click', () => this.openCreateTaskModal(button.id)); item.prepend(button); }); }, openCreateTaskModal(id) { - this.activeTask = { id, title: this.$el.querySelector(`#${id}`).parentElement.innerText }; + const { parentElement } = this.$el.querySelector(`#${id}`); + const lineNumbers = parentElement.getAttribute('data-sourcepos').match(/\b\d+(?=:)/g); + this.activeTask = { + id, + title: parentElement.innerText, + lineNumberStart: lineNumbers[0], + lineNumberEnd: lineNumbers[1], + }; this.$refs.modal.show(); }, closeCreateTaskModal() { this.$refs.modal.hide(); }, closeWorkItemDetailModal() { - this.workItemId = null; + this.workItemId = undefined; + this.updateWorkItemIdUrlQuery(undefined); }, - handleWorkItemDetailModalError(message) { - createFlash({ message }); - }, - handleCreateTask({ id, title, type }) { - const listItem = this.$el.querySelector(`#${this.activeTask.id}`).parentElement; - const taskBadge = document.createElement('span'); - taskBadge.innerHTML = ` - <svg data-testid="issue-open-m-icon" role="img" aria-hidden="true" class="gl-icon gl-fill-green-500 s12"> - <use href="${gon.sprite_icons}#issue-open-m"></use> - </svg> - <span class="badge badge-info badge-pill gl-badge sm gl-mr-1"> - ${__('Task')} - </span> - `; - const button = this.createWorkItemDetailButton(id, title, type); - taskBadge.append(button); - - listItem.insertBefore(taskBadge, listItem.lastChild); - listItem.removeChild(listItem.lastChild); + handleCreateTask(description) { + this.$emit('updateDescription', description); this.closeCreateTaskModal(); }, - createWorkItemDetailButton(id, title, type) { - const button = document.createElement('button'); - button.addEventListener('click', () => { - this.workItemId = id; - this.track('viewed_work_item_from_modal', { - category: 'workItems:show', - label: 'work_item_view', - property: `type_${type}`, - }); - }); - button.classList.add('btn-link'); - button.innerText = title; - return button; + handleDeleteTask() { + this.$toast.show(s__('WorkItem|Work item deleted')); }, - focusButton() { - this.$refs.convertButton[0].$el.focus(); + updateWorkItemIdUrlQuery(workItemId) { + updateHistory({ + url: setUrlParams({ work_item_id: workItemId }), + replace: true, + }); }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] }, @@ -266,17 +288,17 @@ export default { }" class="md" ></div> - <!-- eslint-disable vue/no-mutating-props --> + <textarea v-if="descriptionText" - v-model="descriptionText" + :value="descriptionText" :data-update-url="updateUrl" class="hidden js-task-list-field" dir="auto" data-testid="textarea" > </textarea> - <!-- eslint-enable vue/no-mutating-props --> + <gl-modal ref="modal" modal-id="create-task-modal" @@ -285,36 +307,27 @@ export default { body-class="gl-p-0!" > <create-work-item - :is-modal="true" + is-modal :initial-title="activeTask.title" + :issue-gid="issueGid" + :lock-version="lockVersion" + :line-number-start="activeTask.lineNumberStart" + :line-number-end="activeTask.lineNumberEnd" @closeModal="closeCreateTaskModal" @onCreate="handleCreateTask" /> </gl-modal> <work-item-detail-modal + :can-update="canUpdate" :visible="showWorkItemDetailModal" :work-item-id="workItemId" + @workItemDeleted="handleDeleteTask" @close="closeWorkItemDetailModal" - @error="handleWorkItemDetailModalError" /> <template v-if="workItemsEnabled"> - <gl-popover - v-for="item in taskButtons" - :key="item" - :target="item" - placement="top" - triggers="focus" - @shown="focusButton" - > - <gl-button - ref="convertButton" - variant="link" - data-testid="convert-to-task" - class="gl-text-gray-900! gl-text-decoration-none! gl-outline-0!" - @click="openCreateTaskModal(item)" - >{{ s__('WorkItem|Convert to work item') }}</gl-button - > - </gl-popover> + <gl-tooltip v-for="item in taskButtons" :key="item" :target="item"> + {{ s__('WorkItem|Convert to work item') }} + </gl-tooltip> </template> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue index 0da1900a6d0..41cc3964055 100644 --- a/app/assets/javascripts/issues/show/components/edited.vue +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -32,7 +32,7 @@ export default { </script> <template> - <small class="edited-text"> + <small class="edited-text js-issue-widgets"> Edited <time-ago-tooltip v-if="updatedAt" :time="updatedAt" tooltip-placement="bottom" /> <span v-if="hasUpdatedBy"> diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index d5ac7b28afc..0bb5e7cb2ee 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,5 +1,6 @@ <script> import markdownField from '~/vue_shared/components/markdown/field.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; import updateMixin from '../../mixins/update'; export default { @@ -8,8 +9,8 @@ export default { }, mixins: [updateMixin], props: { - formState: { - type: Object, + value: { + type: String, required: true, }, markdownPreviewPath: { @@ -31,6 +32,11 @@ export default { default: true, }, }, + computed: { + quickActionsDocsPath() { + return helpPagePath('user/project/quick_actions'); + }, + }, mounted() { this.$refs.textarea.focus(); }, @@ -43,26 +49,26 @@ export default { <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" - :textarea-value="formState.description" + :textarea-value="value" > <template #textarea> - <!-- eslint-disable vue/no-mutating-props --> <textarea id="issue-description" ref="textarea" - v-model="formState.description" + :value="value" class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" dir="auto" data-supports-quick-actions="true" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" + @input="$emit('input', $event.target.value)" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" > </textarea> - <!-- eslint-enable vue/no-mutating-props --> </template> </markdown-field> </div> diff --git a/app/assets/javascripts/issues/show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue index d528641dcb6..98f92c97f77 100644 --- a/app/assets/javascripts/issues/show/components/fields/description_template.vue +++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue @@ -8,8 +8,8 @@ export default { GlIcon, }, props: { - formState: { - type: Object, + value: { + type: String, required: true, }, issuableTemplates: { @@ -39,10 +39,9 @@ export default { // Create the editor for the template const editor = document.querySelector('.detail-page-description .note-textarea') || {}; editor.setValue = (val) => { - // eslint-disable-next-line vue/no-mutating-props - this.formState.description = val; + this.$emit('input', val); }; - editor.getValue = () => this.formState.description; + editor.getValue = () => this.value; this.issuableTemplate = new IssuableTemplateSelectors({ $dropdowns: $(this.$refs.toggle), diff --git a/app/assets/javascripts/issues/show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue index a73926575d0..594d1a65700 100644 --- a/app/assets/javascripts/issues/show/components/fields/title.vue +++ b/app/assets/javascripts/issues/show/components/fields/title.vue @@ -4,8 +4,8 @@ import updateMixin from '../../mixins/update'; export default { mixins: [updateMixin], props: { - formState: { - type: Object, + value: { + type: String, required: true, }, }, @@ -15,19 +15,18 @@ export default { <template> <fieldset> <label class="sr-only" for="issuable-title">{{ __('Title') }}</label> - <!-- eslint-disable vue/no-mutating-props --> <input id="issuable-title" ref="input" - v-model="formState.title" + :value="value" class="form-control qa-title-input gl-border-gray-200" dir="auto" type="text" :placeholder="__('Title')" :aria-label="__('Title')" + @input="$emit('input', $event.target.value)" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" /> - <!-- eslint-enable vue/no-mutating-props --> </fieldset> </template> diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index 6447ec85b4e..e2c12edf46d 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -86,6 +86,10 @@ export default { }, data() { return { + formData: { + title: this.formState.title, + description: this.formState.description, + }, showOutdatedDescriptionWarning: false, }; }, @@ -100,6 +104,14 @@ export default { return this.issuableType === IssuableType.Issue; }, }, + watch: { + formData: { + handler(value) { + this.$emit('updateForm', value); + }, + deep: true, + }, + }, created() { eventHub.$on('delete.issuable', this.resetAutosave); eventHub.$on('update.issuable', this.resetAutosave); @@ -191,16 +203,17 @@ export default { > <div class="row gl-mb-3"> <div class="col-12"> - <issuable-title-field ref="title" :form-state="formState" /> + <issuable-title-field ref="title" v-model="formData.title" /> </div> </div> <div class="row"> <div v-if="isIssueType" class="col-12 col-md-4 pr-md-0"> <issuable-type-field ref="issue-type" /> </div> + <div v-if="hasIssuableTemplates" class="col-12 col-md-4 pl-md-2"> <description-template-field - :form-state="formState" + v-model="formData.description" :issuable-templates="issuableTemplates" :project-path="projectPath" :project-id="projectId" @@ -208,14 +221,16 @@ export default { /> </div> </div> + <description-field ref="description" - :form-state="formState" + v-model="formData.description" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" /> + <edit-actions :endpoint="endpoint" :form-state="formState" diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 04ddc7f3501..ea0e15adfed 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -17,12 +17,13 @@ export default { GlTab, GlTabs, HighlightBar, - MetricsTab: () => import('ee_component/issues/show/components/incidents/metrics_tab.vue'), TimelineTab: () => import('ee_component/issues/show/components/incidents/timeline_events_tab.vue'), + IncidentMetricTab: () => + import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'), }, mixins: [glFeatureFlagsMixin()], - inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'], + inject: ['fullPath', 'iid'], apollo: { alert: { query: getAlert, @@ -52,7 +53,7 @@ export default { return this.$apollo.queries.alert.loading; }, incidentTabEnabled() { - return this.glFeatures.incidentTimelineEvents && this.glFeatures.incidentTimelineEventTab; + return this.glFeatures.incidentTimelineEvents && this.glFeatures.incidentTimeline; }, }, mounted() { @@ -63,18 +64,37 @@ export default { const { category, action } = trackIncidentDetailsViewsOptions; Tracking.event(category, action); }, + handleTabChange(tabIndex) { + const parent = document.querySelector('.js-issue-details'); + + if (parent !== null) { + const itemsToHide = parent.querySelectorAll('.js-issue-widgets'); + const lineSeparator = parent.querySelector('.js-detail-page-description'); + + lineSeparator.classList.toggle('gl-border-b-0', tabIndex > 0); + + itemsToHide.forEach(function hide(item) { + item.classList.toggle('gl-display-none', tabIndex > 0); + }); + } + }, }, }; </script> <template> <div> - <gl-tabs content-class="gl-reset-line-height" class="gl-mt-n3" data-testid="incident-tabs"> + <gl-tabs + content-class="gl-reset-line-height" + class="gl-mt-n3" + data-testid="incident-tabs" + @input="handleTabChange" + > <gl-tab :title="s__('Incident|Summary')"> <highlight-bar :alert="alert" /> <description-component v-bind="$attrs" /> </gl-tab> - <metrics-tab v-if="uploadMetricsFeatureAvailable" data-testid="metrics-tab" /> + <incident-metric-tab /> <gl-tab v-if="alert" class="alert-management-details" diff --git a/app/assets/javascripts/issues/show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue index 4b99888ae73..12feacb027b 100644 --- a/app/assets/javascripts/issues/show/components/locked_warning.vue +++ b/app/assets/javascripts/issues/show/components/locked_warning.vue @@ -1,5 +1,5 @@ <script> -import { GlSprintf, GlLink } from '@gitlab/ui'; +import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; const alertMessage = __( @@ -11,6 +11,7 @@ export default { components: { GlSprintf, GlLink, + GlAlert, }, computed: { currentPath() { @@ -21,7 +22,7 @@ export default { </script> <template> - <div class="alert alert-danger"> + <gl-alert variant="danger" class="gl-mb-5" :dismissible="false"> <gl-sprintf :message="$options.alertMessage"> <template #link="{ content }"> <gl-link :href="currentPath" target="_blank" rel="nofollow"> @@ -29,5 +30,5 @@ export default { </gl-link> </template> </gl-sprintf> - </div> + </gl-alert> </template> diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index c9af5d9b4a7..4a5ebf9615b 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -102,7 +102,7 @@ export function initIssueApp(issueData, store) { isConfidential: this.getNoteableData?.confidential, isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, - id: this.getNoteableData?.id, + issueId: this.getNoteableData?.id, }, }); }, diff --git a/app/assets/javascripts/issues/show/mixins/update.js b/app/assets/javascripts/issues/show/mixins/update.js index 72be65b426f..31b29de580c 100644 --- a/app/assets/javascripts/issues/show/mixins/update.js +++ b/app/assets/javascripts/issues/show/mixins/update.js @@ -3,7 +3,6 @@ import eventHub from '../event_hub'; export default { methods: { updateIssuable() { - this.formState.updateLoading = true; eventHub.$emit('update.issuable'); }, }, diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue index 88005cccd89..9b36642feb7 100644 --- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue +++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue @@ -7,6 +7,7 @@ import { GlAvatarLabeled, } from '@gitlab/ui'; import { __ } from '~/locale'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import { PROJECTS_PER_PAGE } from '../constants'; import getProjectsQuery from '../graphql/queries/get_projects.query.graphql'; @@ -80,6 +81,7 @@ export default { i18n: { selectProjectText: __('Select a project'), }, + AVATAR_SHAPE_OPTION_RECT, }; </script> @@ -107,7 +109,7 @@ export default { > <gl-avatar-labeled class="gl-text-truncate" - shape="rect" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" :size="32" :src="project.avatarUrl" :label="project.name" diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql index 03e8e3e986b..d9fba40688d 100644 --- a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql +++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query jiraGetProjects( $search: String! diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue index 005c3bcd0e3..1fc40e5c0d6 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue @@ -43,7 +43,9 @@ export default { message: s__( 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', ), - linkUrl: helpPagePath('integration/jira_development_panel.html', { anchor: 'usage' }), + linkUrl: helpPagePath('integration/jira_development_panel.html', { + anchor: 'use-the-integration', + }), variant: 'success', }); diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index afdb414e82c..51db3e784aa 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -3,12 +3,15 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapState, mapMutations } from 'vuex'; import { retrieveAlert } from '~/jira_connect/subscriptions/utils'; +import AccessorUtilities from '~/lib/utils/accessor'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '../constants'; import { SET_ALERT } from '../store/mutation_types'; import SignInPage from '../pages/sign_in.vue'; import SubscriptionsPage from '../pages/subscriptions.vue'; import UserLink from './user_link.vue'; import CompatibilityAlert from './compatibility_alert.vue'; +import BrowserSupportAlert from './browser_support_alert.vue'; export default { name: 'JiraConnectApp', @@ -18,9 +21,11 @@ export default { GlSprintf, UserLink, CompatibilityAlert, + BrowserSupportAlert, SignInPage, SubscriptionsPage, }, + mixins: [glFeatureFlagMixin()], inject: { usersPath: { default: '', @@ -45,6 +50,16 @@ export default { userSignedIn() { return Boolean(!this.usersPath || this.user); }, + isOauthEnabled() { + return this.glFeatures.jiraConnectOauth; + }, + /** + * Returns false if the GitLab for Jira app doesn't support the user's browser. + * Any web API that the GitLab for Jira app depends on should be checked here. + */ + isBrowserSupported() { + return !this.isOauthEnabled || AccessorUtilities.canUseCrypto(); + }, }, created() { this.setInitialAlert(); @@ -71,14 +86,15 @@ export default { </script> <template> - <div> - <compatibility-alert /> + <browser-support-alert v-if="!isBrowserSupported" class="gl-mb-7" /> + <div v-else data-testid="jira-connect-app"> + <compatibility-alert class="gl-mb-7" /> <gl-alert v-if="shouldShowAlert" - class="gl-mb-7" :variant="alert.variant" :title="alert.title" + class="gl-mb-5" data-testid="jira-connect-persisted-alert" @dismiss="setAlert" > diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue new file mode 100644 index 00000000000..ea7db5be0c4 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue @@ -0,0 +1,30 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + name: 'BrowserSupportAlert', + components: { + GlAlert, + GlSprintf, + GlLink, + }, + i18n: { + title: s__('Integrations|Your browser is not supported'), + body: s__( + 'Integrations|You must use a %{linkStart}supported browser%{linkEnd} to use the GitLab for Jira app.', + ), + }, + DOCS_LINK_URL: helpPagePath('install/requirements', { anchor: 'supported-web-browsers' }), +}; +</script> +<template> + <gl-alert variant="danger" :title="$options.i18n.title" :dismissible="false"> + <gl-sprintf :message="$options.i18n.body"> + <template #link="{ content }"> + <gl-link :href="$options.DOCS_LINK_URL" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue index 3cfbd87ac53..c5b56535247 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue @@ -46,16 +46,13 @@ export default { > <gl-alert v-if="shouldShowAlert" - class="gl-mb-7" variant="info" :title="$options.i18n.title" @dismiss="dismissAlert" > <gl-sprintf :message="$options.i18n.body"> <template #link="{ content }"> - <gl-link :href="$options.DOCS_LINK_URL" target="_blank" rel="noopener noreferrer">{{ - content - }}</gl-link> + <gl-link :href="$options.DOCS_LINK_URL" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </gl-alert> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue b/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue index e6c172dae9e..509a32460bb 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue @@ -1,5 +1,6 @@ <script> import { GlAvatar, GlIcon } from '@gitlab/ui'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; export default { components: { @@ -12,6 +13,7 @@ export default { required: true, }, }, + AVATAR_SHAPE_OPTION_RECT, }; </script> @@ -19,7 +21,12 @@ export default { <div class="gl-display-flex gl-align-items-center"> <gl-icon name="folder-o" class="gl-mr-3" /> <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3"> - <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" /> + <gl-avatar + :size="32" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :entity-name="group.name" + :src="group.avatar_url" + /> </div> <div> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue index d7ec909cb28..dfed57df7d6 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue @@ -24,7 +24,7 @@ export default { canUseCrypto: AccessorUtilities.canUseCrypto(), }; }, - mounted() { + created() { window.addEventListener('message', this.handleWindowMessage); }, beforeDestroy() { diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue index 33126040c16..0251728c896 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlTable } from '@gitlab/ui'; +import { GlButton, GlTableLite } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapMutations } from 'vuex'; import { removeSubscription } from '~/jira_connect/subscriptions/api'; @@ -12,7 +12,7 @@ import GroupItemName from './group_item_name.vue'; export default { components: { GlButton, - GlTable, + GlTableLite, GroupItemName, TimeagoTooltip, }, @@ -78,7 +78,7 @@ export default { </script> <template> - <gl-table :items="subscriptions" :fields="$options.fields"> + <gl-table-lite :items="subscriptions" :fields="$options.fields"> <template #cell(name)="{ item }"> <group-item-name :group="item.group" /> </template> @@ -95,5 +95,5 @@ export default { >{{ __('Unlink') }}</gl-button > </template> - </gl-table> + </gl-table-lite> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js index 320f0f8aa6c..3b584b5fe98 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/index.js @@ -1,4 +1,4 @@ -import '../../webpack'; +import '~/webpack'; import setConfigs from '@gitlab/ui/dist/config'; import Vue from 'vue'; @@ -48,4 +48,4 @@ export function initJiraConnect() { }); } -document.addEventListener('DOMContentLoaded', initJiraConnect); +initJiraConnect(); diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index 1b6e365fdb2..af4a26a7352 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -12,7 +12,7 @@ import { GlLoadingIcon, GlSearchBoxByType, GlSprintf, - GlTable, + GlTableLite, } from '@gitlab/ui'; import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -45,7 +45,7 @@ export default { GlLoadingIcon, GlSearchBoxByType, GlSprintf, - GlTable, + GlTableLite, }, currentUsername: gon.current_username, dropdownLabel, @@ -295,7 +295,7 @@ export default { <p>{{ $options.userMappingMessage }}</p> - <gl-table :fields="$options.tableConfig" :items="userMappings" fixed> + <gl-table-lite :fields="$options.tableConfig" :items="userMappings" fixed> <template #cell(arrow)> <gl-icon name="arrow-right" :aria-label="__('Will be mapped to')" /> </template> @@ -326,9 +326,9 @@ export default { </gl-dropdown-text> </gl-dropdown> </template> - </gl-table> + </gl-table-lite> - <gl-loading-icon v-if="isInitialLoadingState" size="sm" /> + <gl-loading-icon v-if="isInitialLoadingState" size="md" /> <gl-button v-if="hasMoreUsers" @@ -343,7 +343,7 @@ export default { <gl-button type="submit" category="primary" - variant="success" + variant="confirm" class="js-no-auto-disable" :loading="isSubmitting" data-qa-selector="jira_issues_import_button" diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 695a237bf50..c1701cd94c2 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -20,6 +20,7 @@ export default function mountJiraImportApp() { return new Vue({ el, + name: 'JiraImportRoot', apolloProvider, render(createComponent) { return createComponent(App, { diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index 9d451f94e8a..da72cbeb856 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -2,7 +2,7 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import { __ } from '../../locale'; +import { __ } from '~/locale'; export default { creatingEnvironment: 'creating', diff --git a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue new file mode 100644 index 00000000000..fe7b7428c6e --- /dev/null +++ b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue @@ -0,0 +1,42 @@ +<script> +import { GlFilteredSearch } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import JobStatusToken from './tokens/job_status_token.vue'; + +export default { + tokenTypes: { + status: 'status', + }, + components: { + GlFilteredSearch, + }, + computed: { + tokens() { + return [ + { + type: this.$options.tokenTypes.status, + icon: 'status', + title: s__('Jobs|Status'), + unique: true, + token: JobStatusToken, + operators: OPERATOR_IS_ONLY, + }, + ]; + }, + }, + methods: { + onSubmit(filters) { + this.$emit('filterJobsBySearch', filters); + }, + }, +}; +</script> + +<template> + <gl-filtered-search + :placeholder="s__('Jobs|Filter jobs')" + :available-tokens="tokens" + @submit="onSubmit" + /> +</template> diff --git a/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue b/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue new file mode 100644 index 00000000000..aad86ded80a --- /dev/null +++ b/app/assets/javascripts/jobs/components/filtered_search/tokens/job_status_token.vue @@ -0,0 +1,122 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + statuses() { + return [ + { + class: 'ci-status-icon-canceled', + icon: 'status_canceled', + text: s__('Job|Canceled'), + value: 'CANCELED', + }, + { + class: 'ci-status-icon-created', + icon: 'status_created', + text: s__('Job|Created'), + value: 'CREATED', + }, + { + class: 'ci-status-icon-failed', + icon: 'status_failed', + text: s__('Job|Failed'), + value: 'FAILED', + }, + { + class: 'ci-status-icon-manual', + icon: 'status_manual', + text: s__('Job|Manual'), + value: 'MANUAL', + }, + { + class: 'ci-status-icon-success', + icon: 'status_success', + text: s__('Job|Passed'), + value: 'SUCCESS', + }, + { + class: 'ci-status-icon-pending', + icon: 'status_pending', + text: s__('Job|Pending'), + value: 'PENDING', + }, + { + class: 'ci-status-icon-preparing', + icon: 'status_preparing', + text: s__('Job|Preparing'), + value: 'PREPARING', + }, + { + class: 'ci-status-icon-running', + icon: 'status_running', + text: s__('Job|Running'), + value: 'RUNNING', + }, + { + class: 'ci-status-icon-scheduled', + icon: 'status_scheduled', + text: s__('Job|Scheduled'), + value: 'SCHEDULED', + }, + { + class: 'ci-status-icon-skipped', + icon: 'status_skipped', + text: s__('Job|Skipped'), + value: 'SKIPPED', + }, + { + class: 'ci-status-icon-waiting-for-resource', + icon: 'status-waiting', + text: s__('Job|Waiting for resource'), + value: 'WAITING_FOR_RESOURCE', + }, + ]; + }, + findActiveStatus() { + return this.statuses.find((status) => status.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <div :class="findActiveStatus.class"> + <gl-icon :name="findActiveStatus.icon" class="gl-mr-2 gl-display-block" /> + </div> + <span>{{ findActiveStatus.text }}</span> + </div> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="(status, index) in statuses" + :key="index" + :value="status.value" + > + <div class="gl-display-flex" :class="status.class"> + <gl-icon :name="status.icon" class="gl-mr-3" /> + <span>{{ status.text }}</span> + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue index 753a15871ab..f16e0287d5d 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -171,6 +171,7 @@ export default { data-testid="cancel-button" icon="cancel" :title="$options.CANCEL" + :aria-label="$options.CANCEL" :disabled="cancelBtnDisabled" @click="cancelJob()" /> @@ -182,6 +183,7 @@ export default { v-gl-modal-directive="$options.playJobModalId" icon="play" :title="$options.ACTIONS_START_NOW" + :aria-label="$options.ACTIONS_START_NOW" data-testid="play-scheduled" /> <gl-modal @@ -196,6 +198,7 @@ export default { <gl-button icon="time-out" :title="$options.ACTIONS_UNSCHEDULE" + :aria-label="$options.ACTIONS_UNSCHEDULE" :disabled="unscheduleBtnDisabled" data-testid="unschedule" @click="unscheduleJob()" @@ -207,6 +210,7 @@ export default { v-if="manualJobPlayable" icon="play" :title="$options.ACTIONS_PLAY" + :aria-label="$options.ACTIONS_PLAY" :disabled="playManualBtnDisabled" data-testid="play" @click="playJob()" @@ -215,6 +219,7 @@ export default { v-else-if="isRetryable" icon="repeat" :title="$options.ACTIONS_RETRY" + :aria-label="$options.ACTIONS_RETRY" :method="currentJobMethod" :disabled="retryBtnDisabled" data-testid="retry" @@ -226,6 +231,7 @@ export default { v-if="shouldDisplayArtifacts" icon="download" :title="$options.ACTIONS_DOWNLOAD_ARTIFACTS" + :aria-label="$options.ACTIONS_DOWNLOAD_ARTIFACTS" :href="artifactDownloadPath" rel="nofollow" download diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue index 19594c4955d..120f01db8f0 100644 --- a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { formatDate, getTimeago, durationTimeFormatted } from '~/lib/utils/datetime_utility'; export default { iconSize: 12, @@ -10,7 +10,6 @@ export default { components: { GlIcon, }, - mixins: [timeagoMixin], props: { job: { type: Object, @@ -24,6 +23,15 @@ export default { duration() { return this.job?.duration; }, + timeFormatted() { + return getTimeago().format(this.finishedTime); + }, + tooltipTitle() { + return formatDate(this.finishedTime); + }, + durationFormatted() { + return durationTimeFormatted(this.duration); + }, }, }; </script> @@ -32,18 +40,18 @@ export default { <div> <div v-if="duration" data-testid="job-duration"> <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> - {{ durationTimeFormatted(duration) }} + {{ durationFormatted }} </div> <div v-if="finishedTime" data-testid="job-finished-time"> <gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" /> <time v-gl-tooltip - :title="tooltipTitle(finishedTime)" + :title="tooltipTitle" :datetime="finishedTime" data-placement="top" data-container="body" > - {{ timeFormatted(finishedTime) }} + {{ timeFormatted }} </time> </div> </div> diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js index 951d9324813..853834ed51d 100644 --- a/app/assets/javascripts/jobs/components/table/constants.js +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -4,6 +4,9 @@ import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; /* Error constants */ export const POST_FAILURE = 'post_failure'; export const DEFAULT = 'default'; +export const RAW_TEXT_WARNING = s__( + 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.', +); /* Job Status Constants */ export const JOB_SCHEDULED = 'SCHEDULED'; diff --git a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js index b9946925c95..8bcd7ffd10f 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js +++ b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js @@ -13,16 +13,40 @@ export default { merge(existing = {}, incoming, { args = {} }) { let nodes; + const areNodesEqual = isEqual(existing.nodes, incoming.nodes); + const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses; + const { pageInfo } = incoming; + if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) { - nodes = [...existing.nodes, ...incoming.nodes]; + if (areNodesEqual) { + if (incoming.pageInfo.hasNextPage) { + nodes = [...existing.nodes, ...incoming.nodes]; + } else { + nodes = [...incoming.nodes]; + } + } else { + if (!existing.pageInfo?.hasNextPage) { + nodes = [...incoming.nodes]; + + return { + nodes, + statuses, + pageInfo, + count: incoming.count, + }; + } + + nodes = [...existing.nodes, ...incoming.nodes]; + } } else { nodes = [...incoming.nodes]; } return { nodes, - statuses: Array.isArray(args.statuses) ? [...args.statuses] : args.statuses, - pageInfo: incoming.pageInfo, + statuses, + pageInfo, + count: incoming.count, }; }, }, diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql index 151e49af87e..f3ca958b3ca 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -3,6 +3,7 @@ query getJobs($fullPath: ID!, $after: String, $statuses: [CiJobStatus!]) { id __typename jobs(after: $after, first: 30, statuses: $statuses) { + count pageInfo { endCursor hasNextPage diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js index 1b9c7cdcfdd..88da1169e01 100644 --- a/app/assets/javascripts/jobs/components/table/index.js +++ b/app/assets/javascripts/jobs/components/table/index.js @@ -27,7 +27,6 @@ export default (containerId = 'js-jobs-table') => { const { fullPath, - jobCounts, jobStatuses, pipelineEditorPath, emptyStateSvgPath, @@ -42,7 +41,6 @@ export default (containerId = 'js-jobs-table') => { fullPath, pipelineEditorPath, jobStatuses: JSON.parse(jobStatuses), - jobCounts: JSON.parse(jobCounts), admin: parseBoolean(admin), }, render(createElement) { diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index 864e322eecd..3ea50dfb7a3 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -1,26 +1,34 @@ <script> import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; +import createFlash from '~/flash'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue'; import eventHub from './event_hub'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; import JobsTable from './jobs_table.vue'; import JobsTableEmptyState from './jobs_table_empty_state.vue'; import JobsTableTabs from './jobs_table_tabs.vue'; +import { RAW_TEXT_WARNING } from './constants'; export default { i18n: { errorMsg: __('There was an error fetching the jobs for your project.'), loadingAriaLabel: __('Loading'), }, + filterSearchBoxStyles: + 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-gray-100 gl-border-b', components: { GlAlert, GlSkeletonLoader, + JobsFilteredSearch, JobsTable, JobsTableEmptyState, JobsTableTabs, GlIntersectionObserver, GlLoadingIcon, }, + mixins: [glFeatureFlagMixin()], inject: { fullPath: { default: '', @@ -35,10 +43,11 @@ export default { }; }, update(data) { - const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {}; + const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data.project || {}; return { list, pageInfo, + count, }; }, error() { @@ -54,19 +63,52 @@ export default { hasError: false, isAlertDismissed: false, scope: null, - firstLoad: true, + infiniteScrollingTriggered: false, + filterSearchTriggered: false, + count: 0, }; }, computed: { + loading() { + return this.$apollo.queries.jobs.loading; + }, shouldShowAlert() { return this.hasError && !this.isAlertDismissed; }, + // Show when on All tab with no jobs + // Show only when not loading and filtered search has not been triggered + // So we don't show empty state when results are empty on a filtered search showEmptyState() { - return this.jobs.list.length === 0 && !this.scope; + return ( + this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered + ); }, hasNextPage() { return this.jobs?.pageInfo?.hasNextPage; }, + showLoadingSpinner() { + return this.loading && this.infiniteScrollingTriggered; + }, + showSkeletonLoader() { + return this.loading && !this.showLoadingSpinner; + }, + showFilteredSearch() { + return this.glFeatures?.jobsTableVueSearch && !this.scope; + }, + jobsCount() { + return this.jobs.count; + }, + }, + watch: { + // this watcher ensures that the count on the all tab + // is not updated when switching to the finished tab + jobsCount(newCount, oldCount) { + if (this.scope) { + this.count = oldCount; + } else { + this.count = newCount; + } + }, }, mounted() { eventHub.$on('jobActionPerformed', this.handleJobAction); @@ -79,16 +121,38 @@ export default { this.$apollo.queries.jobs.refetch({ statuses: this.scope }); }, fetchJobsByStatus(scope) { - this.firstLoad = true; + this.infiniteScrollingTriggered = false; this.scope = scope; this.$apollo.queries.jobs.refetch({ statuses: scope }); }, + filterJobsBySearch(filters) { + this.infiniteScrollingTriggered = false; + this.filterSearchTriggered = true; + + // Eventually there will be more tokens available + // this code is written to scale for those tokens + filters.forEach((filter) => { + // Raw text input in filtered search does not have a type + // when a user enters raw text we alert them that it is + // not supported and we do not make an additional API call + if (!filter.type) { + createFlash({ + message: RAW_TEXT_WARNING, + type: 'warning', + }); + } + + if (filter.type === 'status') { + this.$apollo.queries.jobs.refetch({ statuses: filter.value.data }); + } + }); + }, fetchMoreJobs() { - this.firstLoad = false; + if (!this.loading) { + this.infiniteScrollingTriggered = true; - if (!this.$apollo.queries.jobs.loading) { this.$apollo.queries.jobs.fetchMore({ variables: { fullPath: this.fullPath, @@ -113,9 +177,19 @@ export default { {{ $options.i18n.errorMsg }} </gl-alert> - <jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" /> + <jobs-table-tabs + :all-jobs-count="count" + :loading="loading" + @fetchJobsByStatus="fetchJobsByStatus" + /> + + <jobs-filtered-search + v-if="showFilteredSearch" + :class="$options.filterSearchBoxStyles" + @filterJobsBySearch="filterJobsBySearch" + /> - <div v-if="$apollo.loading && firstLoad" class="gl-mt-5"> + <div v-if="showSkeletonLoader" class="gl-mt-5"> <gl-skeleton-loader :width="1248" :height="73"> <circle cx="748.031" cy="37.7193" r="15.0307" /> <circle cx="787.241" cy="37.7193" r="15.0307" /> @@ -138,7 +212,7 @@ export default { <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs"> <gl-loading-icon - v-if="$apollo.loading" + v-if="showLoadingSpinner" size="md" :aria-label="$options.i18n.loadingAriaLabel" /> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue index 26791e4284d..0a25dc5bea5 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue @@ -1,56 +1,56 @@ <script> -import { GlBadge, GlTab, GlTabs } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; export default { components: { GlBadge, GlTab, GlTabs, + GlLoadingIcon, }, inject: { - jobCounts: { - default: {}, - }, jobStatuses: { default: {}, }, }, + props: { + allJobsCount: { + type: Number, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + }, computed: { tabs() { return [ { - text: __('All'), - count: this.jobCounts.all, + text: s__('Jobs|All'), + count: this.allJobsCount, scope: null, testId: 'jobs-all-tab', + showBadge: true, }, { - text: __('Pending'), - count: this.jobCounts.pending, - scope: this.jobStatuses.pending, - testId: 'jobs-pending-tab', - }, - { - text: __('Running'), - count: this.jobCounts.running, - scope: this.jobStatuses.running, - testId: 'jobs-running-tab', - }, - { - text: __('Finished'), - count: this.jobCounts.finished, + text: s__('Jobs|Finished'), scope: [this.jobStatuses.success, this.jobStatuses.failed, this.jobStatuses.canceled], testId: 'jobs-finished-tab', + showBadge: false, }, ]; }, + showLoadingIcon() { + return this.loading && !this.allJobsCount; + }, }, }; </script> <template> - <gl-tabs content-class="gl-pb-0"> + <gl-tabs content-class="gl-py-0"> <gl-tab v-for="tab in tabs" :key="tab.text" @@ -59,7 +59,11 @@ export default { > <template #title> <span>{{ tab.text }}</span> - <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge> + <gl-loading-icon v-if="showLoadingIcon && tab.showBadge" class="gl-ml-2" /> + + <gl-badge v-else-if="tab.showBadge" size="sm" class="gl-tab-counter-badge"> + {{ tab.count }} + </gl-badge> </template> </gl-tab> </gl-tabs> diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index b1ddede8fe8..1afc1c9a595 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlTable } from '@gitlab/ui'; +import { GlButton, GlTableLite } from '@gitlab/ui'; import { __ } from '~/locale'; const DEFAULT_TD_CLASSES = 'gl-w-half gl-font-sm! gl-border-gray-200!'; @@ -25,7 +25,7 @@ export default { ], components: { GlButton, - GlTable, + GlTableLite, }, props: { trigger: { @@ -84,7 +84,7 @@ export default { > </p> - <gl-table :items="trigger.variables" :fields="$options.fields" small bordered fixed> + <gl-table-lite :items="trigger.variables" :fields="$options.fields" small bordered fixed> <template #cell(key)="{ item }"> <span class="gl-overflow-break-word">{{ item.key }}</span> </template> @@ -92,7 +92,7 @@ export default { <template #cell(value)="data"> <span class="gl-overflow-break-word">{{ getDisplayValue(data.value) }}</span> </template> - </gl-table> + </gl-table-lite> </template> </div> </template> diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 8bca448ee11..7dfe24afa23 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -1,4 +1,4 @@ -import { parseBoolean } from '../../lib/utils/common_utils'; +import { parseBoolean } from '~/lib/utils/common_utils'; /** * Adds the line number property diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js new file mode 100644 index 00000000000..07388f1fdfa --- /dev/null +++ b/app/assets/javascripts/lib/gfm/index.js @@ -0,0 +1,38 @@ +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import rehypeRaw from 'rehype-raw'; + +const createParser = () => { + return unified().use(remarkParse).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw); +}; + +const compilerFactory = (renderer) => + function compiler() { + Object.assign(this, { + Compiler(tree) { + return renderer(tree); + }, + }); + }; + +/** + * Parses a Markdown string and provides the result Abstract + * Syntax Tree (AST) to a renderer function to convert the + * tree in any desired representation + * + * @param {String} params.markdown Markdown to parse + * @param {(tree: MDast -> any)} params.renderer A function that accepts mdast + * AST tree and returns an object of any type that represents the result of + * rendering the tree. See the references below to for more information + * about MDast. + * + * MDastTree documentation https://github.com/syntax-tree/mdast + * @returns {Promise<any>} Returns a promise with the result of rendering + * the MDast tree + */ +export const render = async ({ markdown, renderer }) => { + const { value } = await createParser().use(compilerFactory(renderer)).process(markdown); + + return value; +}; diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index f533ba3671c..451950346b0 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -3,7 +3,7 @@ import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; -import possibleTypes from '~/graphql_shared/possibleTypes.json'; +import possibleTypes from '~/graphql_shared/possible_types.json'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import csrf from '~/lib/utils/csrf'; import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; @@ -47,6 +47,9 @@ export const typePolicies = { DesignCollection: { merge: true, }, + TreeEntry: { + keyFields: ['webPath'], + }, }; export const stripWhitespaceFromQuery = (url, path) => { diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue index f3380b7b4ba..1d8eb73d3d7 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue @@ -26,6 +26,16 @@ export default { required: false, default: 'confirm', }, + secondaryText: { + type: String, + required: false, + default: '', + }, + secondaryVariant: { + type: String, + required: false, + default: 'confirm', + }, modalHtmlMessage: { type: String, required: false, @@ -39,7 +49,26 @@ export default { }, computed: { primaryAction() { - return { text: this.primaryText, attributes: { variant: this.primaryVariant } }; + return { + text: this.primaryText, + attributes: { + variant: this.primaryVariant, + 'data-qa-selector': 'confirm_ok_button', + }, + }; + }, + secondaryAction() { + if (!this.secondaryText) { + return null; + } + + return { + text: this.secondaryText, + attributes: { + variant: this.secondaryVariant, + category: 'secondary', + }, + }; }, cancelAction() { return this.hideCancel ? null : this.$options.cancelAction; @@ -63,6 +92,7 @@ export default { :title="title" :action-primary="primaryAction" :action-cancel="cancelAction" + :action-secondary="secondaryAction" :hide-header="!shouldShowHeader" @primary="$emit('confirmed')" @hidden="$emit('closed')" diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js index a8a89d0644a..1adb6f9c26f 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js @@ -2,7 +2,15 @@ import Vue from 'vue'; export function confirmAction( message, - { primaryBtnVariant, primaryBtnText, modalHtmlMessage, title, hideCancel } = {}, + { + primaryBtnVariant, + primaryBtnText, + secondaryBtnVariant, + secondaryBtnText, + modalHtmlMessage, + title, + hideCancel, + } = {}, ) { return new Promise((resolve) => { let confirmed = false; @@ -16,6 +24,8 @@ export function confirmAction( 'confirm-modal', { props: { + secondaryText: secondaryBtnText, + secondaryVariant: secondaryBtnVariant, primaryVariant: primaryBtnVariant, primaryText: primaryBtnText, title, diff --git a/app/assets/javascripts/lib/utils/css_utils.js b/app/assets/javascripts/lib/utils/css_utils.js index 76ac442a470..e4f68dd1b6c 100644 --- a/app/assets/javascripts/lib/utils/css_utils.js +++ b/app/assets/javascripts/lib/utils/css_utils.js @@ -19,3 +19,7 @@ export function loadCSSFile(path) { } }); } + +export function getCssVariable(variable) { + return getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); +} diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js index 396c1703c1e..4e7086e62c5 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js @@ -1,5 +1,5 @@ import { isNumber } from 'lodash'; -import { __, n__ } from '../../../locale'; +import { __, n__ } from '~/locale'; import { getDayName, parseSeconds } from './date_format_utility'; const DAYS_IN_WEEK = 7; @@ -189,13 +189,21 @@ export const getDateInFuture = (date, daysInFuture) => */ export const isValidDate = (date) => date instanceof Date && !Number.isNaN(date.getTime()); -/* +/** * Appending T00:00:00 makes JS assume local time and prevents it from shifting the date * to match the user's time zone. We want to display the date in server time for now, to * be consistent with the "edit issue -> due date" UI. + * + * @param {String} date Date without time, e.g. `2022-03-22` + * @return {Date} new Date object */ - export const newDateAsLocaleTime = (date) => { + if (!date || typeof date !== 'string') { + return null; + } + if (date.includes('T')) { + return new Date(date); + } const suffix = 'T00:00:00'; return new Date(`${date}${suffix}`); }; diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js index 7bff2bf3e47..830f4604382 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -2,7 +2,7 @@ import dateFormat from 'dateformat'; import { isString, mapValues, reduce, isDate, unescape } from 'lodash'; import { roundToNearestHalf } from '~/lib/utils/common_utils'; import { sanitize } from '~/lib/dompurify'; -import { s__, n__, __, sprintf } from '../../../locale'; +import { s__, n__, __, sprintf } from '~/locale'; /** * Returns i18n month names array. @@ -386,3 +386,23 @@ export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, mont } return '-'; }; + +export const durationTimeFormatted = (duration) => { + const date = new Date(duration * 1000); + + let hh = date.getUTCHours(); + let mm = date.getUTCMinutes(); + let ss = date.getSeconds(); + + if (hh < 10) { + hh = `0${hh}`; + } + if (mm < 10) { + mm = `0${mm}`; + } + if (ss < 10) { + ss = `0${ss}`; + } + + return `${hh}:${mm}:${ss}`; +}; diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js index d68682ebed1..095a29a2eff 100644 --- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js @@ -1,5 +1,5 @@ import * as timeago from 'timeago.js'; -import { languageCode, s__, createDateTimeFormat } from '../../../locale'; +import { languageCode, s__, createDateTimeFormat } from '~/locale'; import { formatDate } from './date_format_utility'; /** @@ -70,8 +70,41 @@ const memoizedLocale = () => { }; }; +/** + * Registers timeago time duration + */ +const memoizedLocaleDuration = () => { + const cache = []; + + const durations = [ + () => [s__('Duration|%s seconds')], + () => [s__('Duration|%s seconds')], + () => [s__('Duration|1 minute')], + () => [s__('Duration|%s minutes')], + () => [s__('Duration|1 hour')], + () => [s__('Duration|%s hours')], + () => [s__('Duration|1 day')], + () => [s__('Duration|%s days')], + () => [s__('Duration|1 week')], + () => [s__('Duration|%s weeks')], + () => [s__('Duration|1 month')], + () => [s__('Duration|%s months')], + () => [s__('Duration|1 year')], + () => [s__('Duration|%s years')], + ]; + + return (_, index) => { + if (cache[index]) { + return cache[index]; + } + cache[index] = durations[index] && durations[index](); + return cache[index]; + }; +}; + timeago.register(timeagoLanguageCode, memoizedLocale()); timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); +timeago.register(`${timeagoLanguageCode}-duration`, memoizedLocaleDuration()); let memoizedFormatter = null; @@ -133,3 +166,16 @@ export const timeFor = (time, expiredLabel) => { } return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim(); }; + +/** + * Returns a duration of time given an amount. + * + * @param {number} milliseconds - Duration in milliseconds. + * @returns {string} A formatted duration, e.g. "10 minutes". + */ +export const duration = (milliseconds) => { + const now = new Date(); + return timeago + .format(now.getTime() - Math.abs(milliseconds), `${timeagoLanguageCode}-duration`) + .trim(); +}; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index ac2eb34260c..52fa90c7791 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -9,7 +9,10 @@ const LINK_TAG_PATTERN = '[{text}](url)'; // a bullet point character (*+-) and an optional checkbox ([ ] [x]) // OR a number with a . after it and an optional checkbox ([ ] [x]) // followed by one or more whitespace characters -const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/; +const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX\s])\])?\s)(?<content>.)?/; + +// detect a horizontal rule that might be mistaken for a list item (not full pattern for an <hr>) +const HR_PATTERN = /^((\s{0,3}-+\s*-+\s*-+\s*[\s-]*)|(\s{0,3}\*+\s*\*+\s*\*+\s*[\s*]*))$/; function selectedText(text, textarea) { return text.substring(textarea.selectionStart, textarea.selectionEnd); @@ -381,16 +384,20 @@ function handleContinueList(e, textArea) { let itemToInsert; + // Behaviors specific to either `ol` or `ul` if (isOl) { const nextLine = lineAfter(textArea.value, textArea, false); const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN); itemToInsert = continueOlText(result, nextLineResult); } else { - // isUl + if (currentLine.match(HR_PATTERN)) return; + itemToInsert = `${indent}${leader}`; } + itemToInsert = itemToInsert.replace(/\[x\]/i, '[ ]'); + e.preventDefault(); updateText({ diff --git a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js index 418cc69bf5a..08c32944181 100644 --- a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js +++ b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js @@ -5,7 +5,7 @@ import { formatNumber } from '~/locale'; * * @param {Number} number to be converted * - * @param {options.maxCharLength} Max output char length at the + * @param {options.maxLength} Max output char length at the * expense of precision, if the output is longer than this, * the formatter switches to using exponential notation. * @@ -16,10 +16,10 @@ import { formatNumber } from '~/locale'; * `formatNumber` such as `valueFactor`, `unit` and `style`. * */ -const formatNumberNormalized = (value, { maxCharLength, valueFactor = 1, ...options }) => { +const formatNumberNormalized = (value, { maxLength, valueFactor = 1, ...options }) => { const formatted = formatNumber(value * valueFactor, options); - if (maxCharLength !== undefined && formatted.length > maxCharLength) { + if (maxLength !== undefined && formatted.length > maxLength) { // 123456 becomes 1.23e+8 return value.toExponential(2); } @@ -27,6 +27,25 @@ const formatNumberNormalized = (value, { maxCharLength, valueFactor = 1, ...opti }; /** + * This function converts the old positional arguments into an options + * object. + * + * This is done so we can support legacy fractionDigits and maxLength as positional + * arguments, as well as the better options object. + * + * @param {Object|Number} options + * @returns {Object} options given to the formatter + */ +const getFormatterArguments = (options) => { + if (typeof options === 'object' && options !== null) { + return options; + } + return { + maxLength: options, + }; +}; + +/** * Formats a number as a string scaling it up according to units. * * While the number is scaled down, the units are scaled up. @@ -40,7 +59,9 @@ const scaledFormatter = (units, unitFactor = 1000) => { return new RangeError(`unitFactor cannot have the value 0.`); } - return (value, fractionDigits) => { + return (value, fractionDigits, options) => { + const { maxLength, unitSeparator = '' } = getFormatterArguments(options); + if (value === null) { return ''; } @@ -66,11 +87,13 @@ const scaledFormatter = (units, unitFactor = 1000) => { } const unit = units[scale]; + const length = maxLength !== undefined ? maxLength - unit.length : undefined; return `${formatNumberNormalized(num, { + maxLength: length, maximumFractionDigits: fractionDigits, minimumFractionDigits: fractionDigits, - })}${unit}`; + })}${unitSeparator}${unit}`; }; }; @@ -78,14 +101,16 @@ const scaledFormatter = (units, unitFactor = 1000) => { * Returns a function that formats a number as a string. */ export const numberFormatter = (style = 'decimal', valueFactor = 1) => { - return (value, fractionDigits, maxCharLength) => { - return `${formatNumberNormalized(value, { - maxCharLength, + return (value, fractionDigits, options) => { + const { maxLength } = getFormatterArguments(options); + + return formatNumberNormalized(value, { + maxLength, valueFactor, style, maximumFractionDigits: fractionDigits, minimumFractionDigits: fractionDigits, - })}`; + }); }; }; @@ -93,15 +118,16 @@ export const numberFormatter = (style = 'decimal', valueFactor = 1) => { * Returns a function that formats a number as a string with a suffix. */ export const suffixFormatter = (unit = '', valueFactor = 1) => { - return (value, fractionDigits, maxCharLength) => { - const length = maxCharLength !== undefined ? maxCharLength - unit.length : undefined; + return (value, fractionDigits, options) => { + const { maxLength, unitSeparator = '' } = getFormatterArguments(options); + const length = maxLength !== undefined ? maxLength - unit.length : undefined; return `${formatNumberNormalized(value, { - maxCharLength: length, + maxLength: length, valueFactor, maximumFractionDigits: fractionDigits, minimumFractionDigits: fractionDigits, - })}${unit}`; + })}${unitSeparator}${unit}`; }; }; diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js index bc82c6aa74d..5c5210027e4 100644 --- a/app/assets/javascripts/lib/utils/unit_format/index.js +++ b/app/assets/javascripts/lib/utils/unit_format/index.js @@ -126,9 +126,11 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { * * @function * @param {Number} value - Number to format - * @param {Number} fractionDigits - precision decimals - * @param {Number} maxLength - Max length of formatted number + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const number = getFormatter(SUPPORTED_FORMATS.number); @@ -137,9 +139,11 @@ export const number = getFormatter(SUPPORTED_FORMATS.number); * * @function * @param {Number} value - Number to format, `1` is rendered as `100%` - * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max length of formatted number + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const percent = getFormatter(SUPPORTED_FORMATS.percent); @@ -148,9 +152,11 @@ export const percent = getFormatter(SUPPORTED_FORMATS.percent); * * @function * @param {Number} value - Number to format, `100` is rendered as `100%` - * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max length of formatted number + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const percentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred); @@ -159,9 +165,11 @@ export const percentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred); * * @function * @param {Number} value - Number to format, `1` is rendered as `1s` - * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max length of formatted number + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const seconds = getFormatter(SUPPORTED_FORMATS.seconds); @@ -170,9 +178,11 @@ export const seconds = getFormatter(SUPPORTED_FORMATS.seconds); * * @function * @param {Number} value - Number to format, `1` is formatted as `1ms` - * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max length of formatted number + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const milliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds); @@ -182,7 +192,11 @@ export const milliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds); * * @function * @param {Number} value - Number to format, `1` is formatted as `1B` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const decimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes); @@ -192,7 +206,11 @@ export const decimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1kB` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const kilobytes = getFormatter(SUPPORTED_FORMATS.kilobytes); @@ -202,7 +220,11 @@ export const kilobytes = getFormatter(SUPPORTED_FORMATS.kilobytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1MB` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const megabytes = getFormatter(SUPPORTED_FORMATS.megabytes); @@ -212,7 +234,11 @@ export const megabytes = getFormatter(SUPPORTED_FORMATS.megabytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1GB` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const gigabytes = getFormatter(SUPPORTED_FORMATS.gigabytes); @@ -222,7 +248,11 @@ export const gigabytes = getFormatter(SUPPORTED_FORMATS.gigabytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1GB` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const terabytes = getFormatter(SUPPORTED_FORMATS.terabytes); @@ -232,7 +262,11 @@ export const terabytes = getFormatter(SUPPORTED_FORMATS.terabytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1PB` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const petabytes = getFormatter(SUPPORTED_FORMATS.petabytes); @@ -242,7 +276,11 @@ export const petabytes = getFormatter(SUPPORTED_FORMATS.petabytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1B` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const bytes = getFormatter(SUPPORTED_FORMATS.bytes); @@ -252,7 +290,11 @@ export const bytes = getFormatter(SUPPORTED_FORMATS.bytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1kB` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const kibibytes = getFormatter(SUPPORTED_FORMATS.kibibytes); @@ -262,7 +304,11 @@ export const kibibytes = getFormatter(SUPPORTED_FORMATS.kibibytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1MB` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const mebibytes = getFormatter(SUPPORTED_FORMATS.mebibytes); @@ -272,7 +318,11 @@ export const mebibytes = getFormatter(SUPPORTED_FORMATS.mebibytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1GB` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const gibibytes = getFormatter(SUPPORTED_FORMATS.gibibytes); @@ -282,7 +332,11 @@ export const gibibytes = getFormatter(SUPPORTED_FORMATS.gibibytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1GB` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const tebibytes = getFormatter(SUPPORTED_FORMATS.tebibytes); @@ -292,7 +346,11 @@ export const tebibytes = getFormatter(SUPPORTED_FORMATS.tebibytes); * * @function * @param {Number} value - Number to format, `1` is formatted as `1PB` - * @param {Number} fractionDigits - number of precision decimals + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - number of precision decimals + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const pebibytes = getFormatter(SUPPORTED_FORMATS.pebibytes); @@ -301,6 +359,10 @@ export const pebibytes = getFormatter(SUPPORTED_FORMATS.pebibytes); * * @function * @param {Number} value - Value to format - * @param {Number} fractionDigits - precision decimals - Defaults to 2 + * @param {Object} options - Formatting options + * @param {Number} options.fractionDigits - precision decimals, defaults to 2 + * @param {Number} options.maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + * @param {String} options.unitSeparator - Separator between value and unit */ export const engineering = getFormatter(); diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js index a88f1bd82fc..38d2f3d7551 100644 --- a/app/assets/javascripts/lib/utils/webpack.js +++ b/app/assets/javascripts/lib/utils/webpack.js @@ -10,5 +10,5 @@ export function resetServiceWorkersPublicPath() { // see: https://webpack.js.org/guides/public-path/ const relativeRootPath = (gon && gon.relative_url_root) || ''; const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/'); - __webpack_public_path__ = webpackAssetPath; // eslint-disable-line babel/camelcase + __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase } diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index b0d31ca315e..609592edc3b 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -163,7 +163,7 @@ export default { <gl-sprintf :message=" s__( - 'Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.', + 'Deprecations|The logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.', ) " > diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index b3cb93e74f2..8fc54be9c28 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -127,7 +127,8 @@ function deferredInitialisation() { // In case the user started searching before we bootstrapped, let's pass the search along. const initialSearchValue = searchInputBox.value; await initHeaderSearchApp(initialSearchValue); - searchInputBox.focus(); + // this is new #search input element. We need to re-find it. + document.querySelector('#search').focus(); }) .catch(() => {}); } else { diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue index 00973100e15..112f722c632 100644 --- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue @@ -40,7 +40,7 @@ export default { :title="$options.title" :aria-label="$options.title" icon="check" - variant="success" + variant="confirm" type="submit" /> </gl-form> diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue index ca60f876c6f..cb7b963b698 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -18,6 +18,8 @@ export default { name: 'MembersFilteredSearchBar', components: { FilteredSearchBar }, availableTokens: AVAILABLE_FILTERED_SEARCH_TOKENS, + searchButtonAttributes: { 'data-qa-selector': 'search_button' }, + searchInputAttributes: { 'data-qa-selector': 'search_bar_input' }, inject: { namespace: {}, sourceId: {}, @@ -127,8 +129,9 @@ export default { :recent-searches-storage-key="filteredSearchBar.recentSearchesStorageKey" :search-input-placeholder="filteredSearchBar.placeholder" :initial-filter-value="initialFilterValue" + :search-button-attributes="$options.searchButtonAttributes" + :search-input-attributes="$options.searchInputAttributes" data-testid="members-filtered-search-bar" - data-qa-selector="members_filtered_search_bar_content" @onFilter="handleFilter" /> </template> diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index b4ba9aa36e7..0b97ce7e33e 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -5,6 +5,7 @@ import MembersTableCell from 'ee_else_ce/members/components/table/members_table_ import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import initUserPopovers from '~/user_popovers'; +import UserDate from '~/vue_shared/components/user_date.vue'; import { FIELD_KEY_ACTIONS, FIELDS, @@ -40,6 +41,7 @@ export default { RemoveGroupLinkModal, RemoveMemberModal, ExpirationDatepicker, + UserDate, LdapOverrideConfirmationModal: () => import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), }, @@ -287,6 +289,14 @@ export default { </members-table-cell> </template> + <template #cell(userCreatedAt)="{ item: member }"> + <user-date :date="member.user.createdAt" /> + </template> + + <template #cell(lastActivityOn)="{ item: member }"> + <user-date :date="member.user.lastActivityOn" /> + </template> + <template #cell(actions)="{ item: member }"> <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> <member-action-buttons diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 49ce00a1689..c66a19c4765 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -9,6 +9,8 @@ export const FIELD_KEY_GRANTED = 'granted'; export const FIELD_KEY_INVITED = 'invited'; export const FIELD_KEY_REQUESTED = 'requested'; export const FIELD_KEY_MAX_ROLE = 'maxRole'; +export const FIELD_KEY_USER_CREATED_AT = 'userCreatedAt'; +export const FIELD_KEY_LAST_ACTIVITY_ON = 'lastActivityOn'; export const FIELD_KEY_EXPIRATION = 'expiration'; export const FIELD_KEY_LAST_SIGN_IN = 'lastSignIn'; export const FIELD_KEY_ACTIONS = 'actions'; @@ -67,6 +69,22 @@ export const FIELDS = [ tdClass: 'col-expiration', }, { + key: FIELD_KEY_USER_CREATED_AT, + label: __('Created on'), + sort: { + asc: 'oldest_created_user', + desc: 'recent_created_user', + }, + }, + { + key: FIELD_KEY_LAST_ACTIVITY_ON, + label: __('Last activity'), + sort: { + asc: 'oldest_last_activity', + desc: 'recent_last_activity', + }, + }, + { key: FIELD_KEY_LAST_SIGN_IN, label: __('Last sign-in'), sort: { diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js index 05f086c8f4f..7ec083646e9 100644 --- a/app/assets/javascripts/members/utils.js +++ b/app/assets/javascripts/members/utils.js @@ -32,7 +32,7 @@ export const isGroup = (member) => { }; export const isDirectMember = (member) => { - return isGroup(member) || member.isDirectMember; + return member.isDirectMember; }; export const isCurrentUser = (member, currentUserId) => { diff --git a/app/assets/javascripts/merge_conflicts/utils.js b/app/assets/javascripts/merge_conflicts/utils.js index e42703ef0a5..cf7a7c304e3 100644 --- a/app/assets/javascripts/merge_conflicts/utils.js +++ b/app/assets/javascripts/merge_conflicts/utils.js @@ -9,7 +9,7 @@ import { export const getFilePath = (file) => { const { old_path, new_path } = file; - // eslint-disable-next-line babel/camelcase + // eslint-disable-next-line camelcase return old_path === new_path ? new_path : `${old_path} → ${new_path}`; }; @@ -71,7 +71,7 @@ export const getLineForParallelView = (line, id, lineType, isHead) => { isHead: hasConflict && isHead, isOrigin: hasConflict && !isHead, hasMatch: lineType === 'match', - // eslint-disable-next-line babel/camelcase + // eslint-disable-next-line camelcase lineNumber: isHead ? new_line : old_line, section: isHead ? 'head' : 'origin', richText: rich_text, diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 244cf1e150a..829e2264152 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -72,11 +72,15 @@ MergeRequest.prototype.initMRBtnListeners = function () { const wipEvent = getParameterValues('merge_request[wip_event]', url)[0]; const mobileDropdown = draftToggle.closest('.dropdown.show'); + const loader = document.createElement('span'); + loader.classList.add('gl-spinner', 'gl-mr-3'); + if (mobileDropdown) { $(mobileDropdown.firstElementChild).dropdown('toggle'); } draftToggle.setAttribute('disabled', 'disabled'); + draftToggle.prepend(loader); axios .put(draftToggle.href, null, { params: { format: 'json' } }) @@ -124,7 +128,7 @@ MergeRequest.prototype.submitNoteForm = function (form, $button) { MergeRequest.decreaseCounter = function (by = 1) { const $el = $('.js-merge-counter'); - const count = Math.max(parseInt($el.text().replace(/[^\d]/, ''), 10) - by, 0); + const count = Math.max(parseInt($el.first().text().replace(/[^\d]/, ''), 10) - by, 0); $el.text(addDelimiter(count)); }; diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue index a840e696386..d7ffdfd7c5f 100644 --- a/app/assets/javascripts/milestones/components/milestone_combobox.vue +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -192,10 +192,12 @@ export default { @keydown.enter.prevent="onSearchBoxEnter" /> - <gl-dropdown-item @click="selectNoMilestone()"> - <span :class="{ 'gl-pl-6': true, 'selected-item': selectedMilestones.length === 0 }"> - {{ $options.translations.noMilestone }} - </span> + <gl-dropdown-item + :is-checked="selectedMilestones.length === 0" + is-check-item + @click="selectNoMilestone()" + > + {{ $options.translations.noMilestone }} </gl-dropdown-item> <gl-dropdown-divider /> @@ -241,9 +243,10 @@ export default { v-for="(item, idx) in extraLinks" :key="idx" :href="item.url" + :is-check-item="true" data-testid="milestone-combobox-extra-links" > - <span class="gl-pl-6">{{ item.text }}</span> + {{ item.text }} </gl-dropdown-item> </gl-dropdown> </template> diff --git a/app/assets/javascripts/milestones/components/milestone_results_section.vue b/app/assets/javascripts/milestones/components/milestone_results_section.vue index b866977b974..e3c691b14c7 100644 --- a/app/assets/javascripts/milestones/components/milestone_results_section.vue +++ b/app/assets/javascripts/milestones/components/milestone_results_section.vue @@ -77,10 +77,14 @@ export default { </div> </template> <template v-else> - <gl-dropdown-item v-for="{ title } in items" :key="title" @click="$emit('selected', title)"> - <span class="gl-pl-6" :class="{ 'selected-item': isSelectedMilestone(title) }"> - {{ title }} - </span> + <gl-dropdown-item + v-for="{ title } in items" + :key="title" + :is-checked="isSelectedMilestone(title)" + is-check-item + @click="$emit('selected', title)" + > + {{ title }} </gl-dropdown-item> <gl-dropdown-divider /> </template> diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index 5138c450feb..e375435436e 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -76,7 +76,7 @@ export default class SSHMirror { // Disable button while we make request this.$btnDetectHostKeys.disable(); - $btnLoadSpinner.removeClass('d-none'); + $btnLoadSpinner.removeClass('gl-display-none'); // Make backOff polling to get data backOff((next, stop) => { @@ -101,7 +101,7 @@ export default class SSHMirror { .catch(stop); }) .then((res) => { - $btnLoadSpinner.addClass('d-none'); + $btnLoadSpinner.addClass('gl-display-none'); // Once data is received, we show verification info along with Host keys and fingerprints this.$hostKeysInformation .find('.js-fingerprint-verification') diff --git a/app/assets/javascripts/monitoring/components/charts/bar.vue b/app/assets/javascripts/monitoring/components/charts/bar.vue index 1e0f4b10297..df91bd078d1 100644 --- a/app/assets/javascripts/monitoring/components/charts/bar.vue +++ b/app/assets/javascripts/monitoring/components/charts/bar.vue @@ -36,12 +36,12 @@ export default { return xLabel; }, yAxisTitle() { - const { y_label = '' } = this.graphData; - return y_label; // eslint-disable-line babel/camelcase + const { y_label: yLabel = '' } = this.graphData; + return yLabel; }, xAxisType() { - const { x_type = 'value' } = this.graphData; - return x_type; // eslint-disable-line babel/camelcase + const { x_type: xType = 'value' } = this.graphData; + return xType; }, dataZoomConfig() { const handleIcon = this.svgs['scroll-handle']; diff --git a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue index bfaf8b2bd28..288487d25a5 100644 --- a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue +++ b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue @@ -55,7 +55,7 @@ export default { {{ s__('Metrics|View documentation') }} </gl-button> <gl-button - variant="success" + variant="confirm" data-testid="create-dashboard-modal-repo-button" :href="projectPath" > diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 6467d953500..c4392dd3748 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -409,17 +409,13 @@ export default { <div> <gl-alert v-if="!isDeprecationNoticeDismissed" - :title="__('Feature deprecation and removal')" + :title="__('Feature deprecation')" class="mb-3" - variant="danger" + variant="warning" @dismiss="isDeprecationNoticeDismissed = true" > <gl-sprintf - :message=" - s__( - 'Deprecations|The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.', - ) - " + :message="s__('Deprecations|The metrics feature was deprecated in GitLab 14.7.')" > <template #epic="{ content }"> <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{ diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 1238996154d..568c66cf152 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -48,8 +48,8 @@ export default { }, filteredDashboards() { - return this.allDashboards.filter(({ display_name = '' }) => - display_name.toLowerCase().includes(this.searchTerm.toLowerCase()), + return this.allDashboards.filter(({ display_name: displayName = '' }) => + displayName.toLowerCase().includes(this.searchTerm.toLowerCase()), ); }, shouldShowNoMsgContainer() { diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/get_annotations.query.graphql index 32b982ff195..32b982ff195 100644 --- a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql +++ b/app/assets/javascripts/monitoring/queries/get_annotations.query.graphql diff --git a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql b/app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql index a61d601cd34..a61d601cd34 100644 --- a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql +++ b/app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql diff --git a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql b/app/assets/javascripts/monitoring/queries/get_environments.query.graphql index 48d0a780fc7..48d0a780fc7 100644 --- a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql +++ b/app/assets/javascripts/monitoring/queries/get_environments.query.graphql diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 215b4b7b2d7..5c99dbc0d98 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -2,13 +2,13 @@ import * as Sentry from '@sentry/browser'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; -import { s__, sprintf } from '../../locale'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { s__, sprintf } from '~/locale'; import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants'; import trackDashboardLoad from '../monitoring_tracking_helper'; -import getAnnotations from '../queries/getAnnotations.query.graphql'; -import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql'; -import getEnvironments from '../queries/getEnvironments.query.graphql'; +import getAnnotations from '../queries/get_annotations.query.graphql'; +import getDashboardValidationWarnings from '../queries/get_dashboard_validation_warnings.query.graphql'; +import getEnvironments from '../queries/get_environments.query.graphql'; import { getDashboard, getPrometheusQueryData } from '../requests'; import * as types from './mutation_types'; @@ -385,7 +385,7 @@ export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) = dashboardPath, }, }) - .then((resp) => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard) + .then((resp) => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard || undefined) .then(({ schemaValidationWarnings } = {}) => { const hasWarnings = schemaValidationWarnings && schemaValidationWarnings.length !== 0; /** diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 20f7c5cdb60..7f75a501635 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -29,7 +29,7 @@ export const gqClient = createGqClient( * @param {String} metric.id - User-defined identifier * @returns {Object} - normalized metric with a uniqueID */ -// eslint-disable-next-line babel/camelcase +// eslint-disable-next-line camelcase export const uniqMetricsId = ({ metric_id, id }) => `${metric_id || NOT_IN_DB_PREFIX}_${id}`; /** @@ -45,7 +45,7 @@ export const removeLeadingSlash = (str) => (str || '').replace(/^\/+/, ''); /** * GraphQL environments API returns only id and name. * For the environments dropdown we need metrics_path. - * This method parses the results and add neccessart attrs + * This method parses the results and add necessary attrs * * @param {Array} response Environments API result * @param {String} projectPath Current project path @@ -57,7 +57,7 @@ export const parseEnvironmentsResponse = (response = [], projectPath) => return { ...env, id, - metrics_path: `${projectPath}/environments/${id}/metrics`, + metrics_path: `${projectPath}/-/metrics?environment=${id}`, }; }); @@ -169,10 +169,10 @@ export const mapPanelToViewModel = ({ id = null, title = '', type, - x_axis = {}, + x_axis = {}, // eslint-disable-line camelcase x_label, y_label, - y_axis = {}, + y_axis = {}, // eslint-disable-line camelcase field, metrics = [], links = [], @@ -184,11 +184,11 @@ export const mapPanelToViewModel = ({ }) => { // Both `x_axis.name` and `x_label` are supported for now // https://gitlab.com/gitlab-org/gitlab/issues/210521 - const xAxis = mapXAxisToViewModel({ name: x_label, ...x_axis }); // eslint-disable-line babel/camelcase + const xAxis = mapXAxisToViewModel({ name: x_label, ...x_axis }); // eslint-disable-line camelcase // Both `y_axis.name` and `y_label` are supported for now // https://gitlab.com/gitlab-org/gitlab/issues/208385 - const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line babel/camelcase + const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line camelcase return { id, @@ -295,7 +295,7 @@ export const mapToDashboardViewModel = ({ dashboard = '', templating = {}, links = [], - panel_groups = [], + panel_groups = [], // eslint-disable-line camelcase }) => { return { dashboard, diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 336b613b620..221f28e923b 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -294,7 +294,7 @@ export const expandedPanelPayloadFromUrl = (dashboard, search = window.location. if (params.group || params.title || params.y_label) { const panelGroup = dashboard.panelGroups.find(({ group }) => params.group === group); const panel = panelGroup.panels.find( - // eslint-disable-next-line babel/camelcase + // eslint-disable-next-line camelcase ({ y_label, title }) => y_label === params.y_label && title === params.title, ); diff --git a/app/assets/javascripts/mr_notes/stores/actions.js b/app/assets/javascripts/mr_notes/stores/actions.js index bc66d1dd68f..0200a8aefc8 100644 --- a/app/assets/javascripts/mr_notes/stores/actions.js +++ b/app/assets/javascripts/mr_notes/stores/actions.js @@ -10,23 +10,14 @@ export function setEndpoints({ commit }, endpoints) { commit(types.SET_ENDPOINTS, endpoints); } -export function setMrMetadata({ commit }, metadata) { - commit(types.SET_MR_METADATA, metadata); -} - -export function fetchMrMetadata({ dispatch, state }) { +export async function fetchMrMetadata({ state, commit }) { if (state.endpoints?.metadata) { - axios - .get(state.endpoints.metadata) - .then((response) => { - dispatch('setMrMetadata', response.data); - }) - .catch(() => { - // https://gitlab.com/gitlab-org/gitlab/-/issues/324740 - // We can't even do a simple console warning here because - // the pipeline will fail. However, the issue above will - // eventually handle errors appropriately. - // console.warn('Failed to load MR Metadata for the Overview tab.'); - }); + commit(types.SET_FAILED_TO_LOAD_METADATA, false); + try { + const { data } = await axios.get(state.endpoints.metadata); + commit(types.SET_MR_METADATA, data); + } catch (error) { + commit(types.SET_FAILED_TO_LOAD_METADATA, true); + } } } diff --git a/app/assets/javascripts/mr_notes/stores/modules/index.js b/app/assets/javascripts/mr_notes/stores/modules/index.js index 52e12ba664c..75b2b2f4dc6 100644 --- a/app/assets/javascripts/mr_notes/stores/modules/index.js +++ b/app/assets/javascripts/mr_notes/stores/modules/index.js @@ -7,6 +7,7 @@ export default () => ({ endpoints: {}, activeTab: null, mrMetadata: {}, + failedToLoadMetadata: false, }, actions, getters, diff --git a/app/assets/javascripts/mr_notes/stores/mutation_types.js b/app/assets/javascripts/mr_notes/stores/mutation_types.js index 88cf6e48988..91d75e77a60 100644 --- a/app/assets/javascripts/mr_notes/stores/mutation_types.js +++ b/app/assets/javascripts/mr_notes/stores/mutation_types.js @@ -2,4 +2,5 @@ export default { SET_ACTIVE_TAB: 'SET_ACTIVE_TAB', SET_ENDPOINTS: 'SET_ENDPOINTS', SET_MR_METADATA: 'SET_MR_METADATA', + SET_FAILED_TO_LOAD_METADATA: 'SET_FAILED_TO_LOAD_METADATA', }; diff --git a/app/assets/javascripts/mr_notes/stores/mutations.js b/app/assets/javascripts/mr_notes/stores/mutations.js index 6af6adb4e18..8b17f63cfb1 100644 --- a/app/assets/javascripts/mr_notes/stores/mutations.js +++ b/app/assets/javascripts/mr_notes/stores/mutations.js @@ -10,4 +10,7 @@ export default { [types.SET_MR_METADATA](state, metadata) { Object.assign(state, { mrMetadata: metadata }); }, + [types.SET_FAILED_TO_LOAD_METADATA](state, value) { + Object.assign(state, { failedToLoadMetadata: value }); + }, }; diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue index d99a3adb358..fef75b6d5d0 100644 --- a/app/assets/javascripts/mr_popover/components/mr_popover.vue +++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue @@ -1,8 +1,8 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { GlPopover, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; -import CiIcon from '../../vue_shared/components/ci_icon.vue'; -import timeagoMixin from '../../vue_shared/mixins/timeago'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; import { mrStates, humanMRStates } from '../constants'; import query from '../queries/merge_request.query.graphql'; diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index 71894b4ff3e..5ae68d22667 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -1,8 +1,8 @@ /* eslint-disable func-names, consistent-return */ import $ from 'jquery'; -import axios from '../lib/utils/axios_utils'; -import { __ } from '../locale'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; import Raphael from './raphael'; export default class BranchGraph { diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index 0ce1eb8191a..45d97f278dc 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -6,7 +6,7 @@ import { mapActions } from 'vuex'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; -import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import noteEditedText from './note_edited_text.vue'; import noteHeader from './note_header.vue'; diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index b4f7ba5f960..e2b0c7fee32 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -9,7 +9,7 @@ import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { getDiffMode } from '~/diffs/store/utils'; import { diffViewerModes } from '~/ide/constants'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; -import { isCollapsed } from '../../diffs/utils/diff_file'; +import { isCollapsed } from '~/diffs/utils/diff_file'; const FIRST_CHAR_REGEX = /^(\+|-| )/; diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index d5a7fc36ace..15887c2738d 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; -import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; +import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility'; import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE, diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index e2a2edd7344..1bd2f879e6c 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -9,7 +9,7 @@ import { __, sprintf } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { splitCamelCase } from '../../lib/utils/text_utility'; +import { splitCamelCase } from '~/lib/utils/text_utility'; import ReplyButton from './note_actions/reply_button.vue'; export default { @@ -292,40 +292,18 @@ export default { class="line-resolve-btn note-action-button" @click="onResolve" /> - <template v-if="canAwardEmoji"> - <emoji-picker - v-if="glFeatures.improvedEmojiPicker" - toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-3 gl-p-0! gl-shadow-none! gl-bg-transparent!" - @click="setAwardEmoji" - > - <template #button-content> - <gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" /> - <gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" /> - <gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" /> - </template> - </emoji-picker> - <gl-button - v-else - v-gl-tooltip - :class="{ 'js-user-authored': isAuthoredByCurrentUser }" - class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji" - category="tertiary" - variant="default" - :title="$options.i18n.addReactionLabel" - :aria-label="$options.i18n.addReactionLabel" - data-position="right" - > - <span class="reaction-control-icon reaction-control-icon-neutral"> - <gl-icon name="slight-smile" /> - </span> - <span class="reaction-control-icon reaction-control-icon-positive"> - <gl-icon name="smiley" /> - </span> - <span class="reaction-control-icon reaction-control-icon-super-positive"> - <gl-icon name="smile" /> - </span> - </gl-button> - </template> + <emoji-picker + v-if="canAwardEmoji" + toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-3 gl-p-0! gl-shadow-none! gl-bg-transparent!" + data-testid="note-emoji-button" + @click="setAwardEmoji" + > + <template #button-content> + <gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" /> + <gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" /> + <gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" /> + </template> + </emoji-picker> <reply-button v-if="showReply" ref="replyButton" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index f465ad23a06..fe17a061c0a 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -57,14 +57,15 @@ export default { computed: { ...mapGetters(['getDiscussion', 'suggestionsCount', 'getSuggestionsFilePaths']), ...mapGetters('diffs', ['suggestionCommitMessage']), + ...mapState({ + batchSuggestionsInfo: (state) => state.notes.batchSuggestionsInfo, + failedToLoadMetadata: (state) => state.page.failedToLoadMetadata, + }), discussion() { if (!this.note.isDraft) return {}; return this.getDiscussion(this.note.discussion_id); }, - ...mapState({ - batchSuggestionsInfo: (state) => state.notes.batchSuggestionsInfo, - }), noteBody() { return this.note.note; }, @@ -165,6 +166,7 @@ export default { :line-type="lineType" :help-page-path="helpPagePath" :default-commit-message="commitMessage" + :failed-to-load-metadata="failedToLoadMetadata" @apply="applySuggestion" @applyBatch="applySuggestionBatch" @addToBatch="addSuggestionToBatch" @@ -174,7 +176,6 @@ export default { <note-form v-if="isEditing" ref="noteForm" - :is-editing="isEditing" :note-body="noteBody" :note-id="note.id" :line="line" diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index 7c052320c98..03cbdf45ddd 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ -import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; +import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { name: 'EditedNoteText', diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index ee22c118e11..c1e763d81ee 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -41,10 +41,6 @@ export default { required: false, default: () => ({}), }, - isEditing: { - type: Boolean, - required: true, - }, lineCode: { type: String, required: false, @@ -184,7 +180,7 @@ export default { return this.getNotesDataByProp('markdownDocsPath'); }, quickActionsDocsPath() { - return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined; + return this.getNotesDataByProp('quickActionsDocsPath'); }, currentUserId() { return this.getUserDataByProp('id'); @@ -348,7 +344,7 @@ export default { ref="textarea" v-model="updatedNoteBody" :disabled="isSubmitting" - :data-supports-quick-actions="!isEditing" + data-supports-quick-actions="true" name="note[note]" class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" data-qa-selector="reply_field" diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 71d767c3b95..11b427b9346 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -6,9 +6,11 @@ import { GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; import { mapActions } from 'vuex'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import UserNameWithStatus from '../../sidebar/components/assignees/user_name_with_status.vue'; +import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; + +import { NOTEABLE_TYPE_MAPPING } from '../constants'; export default { safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, @@ -45,6 +47,11 @@ export default { required: false, default: null, }, + noteableType: { + type: String, + required: false, + default: '', + }, includeToggle: { type: Boolean, required: false, @@ -103,6 +110,15 @@ export default { authorName() { return this.author.name; }, + noteConfidentialityTooltip() { + if ( + this.noteableType === NOTEABLE_TYPE_MAPPING.Issue || + this.noteableType === NOTEABLE_TYPE_MAPPING.MergeRequest + ) { + return s__('Notes|This comment is confidential and only visible to project members'); + } + return s__('Notes|This comment is confidential and only visible to group members'); + }, }, mounted() { this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : ''; @@ -226,7 +242,7 @@ export default { data-testid="confidentialIndicator" name="eye-slash" :size="16" - :title="s__('Notes|This comment is confidential and only visible to project members')" + :title="noteConfidentialityTooltip" class="gl-ml-1 gl-text-orange-700 align-middle" /> <slot name="extra-controls"></slot> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index c4602363da1..000eb3bdff3 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -10,7 +10,7 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import { s__, __ } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -307,7 +307,6 @@ export default { v-if="isReplying" ref="noteForm" :discussion="discussion" - :is-editing="false" :line="diffLine" save-button-title="Comment" :autosave-key="autosaveKey" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index a271ac91f6e..a2fbb242222 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -10,8 +10,8 @@ import httpStatusCodes from '~/lib/utils/http_status'; import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import { __, s__, sprintf } from '../../locale'; -import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import { __, s__, sprintf } from '~/locale'; +import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -357,7 +357,13 @@ export default { }) { if (shouldConfirm && isDirty) { const msg = __('Are you sure you want to cancel editing this comment?'); - const confirmed = await confirmAction(msg); + const confirmed = await confirmAction(msg, { + primaryBtnText: __('Cancel editing'), + primaryBtnVariant: 'danger', + secondaryBtnVariant: 'default', + secondaryBtnText: __('Continue editing'), + hideCancel: true, + }); if (!confirmed) return; } this.$refs.noteBody.resetAutoSave(); @@ -432,6 +438,7 @@ export default { :created-at="note.created_at" :note-id="note.id" :is-confidential="note.confidential" + :noteable-type="noteableType" > <template #note-header-info> <slot name="note-header-info"></slot> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index c4924cd41f5..7d8d23335e0 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -7,12 +7,12 @@ import initUserPopovers from '~/user_popovers'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import draftNote from '../../batch_comments/components/draft_note.vue'; -import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; -import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; -import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; -import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; -import systemNote from '../../vue_shared/components/notes/system_note.vue'; +import draftNote from '~/batch_comments/components/draft_note.vue'; +import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility'; +import placeholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; +import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; +import skeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue'; +import systemNote from '~/vue_shared/components/notes/system_note.vue'; import * as constants from '../constants'; import eventHub from '../event_hub'; import commentForm from './comment_form.vue'; diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue index 92c39fbb9f0..bcc5d12b7c8 100644 --- a/app/assets/javascripts/notes/components/sort_discussion.vue +++ b/app/assets/javascripts/notes/components/sort_discussion.vue @@ -57,6 +57,7 @@ export default { :value="sortDirection" :storage-key="storageKey" :persist="persistSortOrder" + as-string @input="setDiscussionSortDirection({ direction: $event })" /> <gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile"> diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue index 87d22e5b986..e4d89f54652 100644 --- a/app/assets/javascripts/notes/components/timeline_toggle.vue +++ b/app/assets/javascripts/notes/components/timeline_toggle.vue @@ -7,8 +7,8 @@ import { COMMENTS_ONLY_FILTER_VALUE, DESC } from '../constants'; import notesEventHub from '../event_hub'; import { trackToggleTimelineView } from '../utils'; -export const timelineEnabledTooltip = s__('Timeline|Turn timeline view off'); -export const timelineDisabledTooltip = s__('Timeline|Turn timeline view on'); +export const timelineEnabledTooltip = s__('Timeline|Turn recent updates view off'); +export const timelineDisabledTooltip = s__('Timeline|Turn recent updates view on'); export default { components: { @@ -49,7 +49,7 @@ export default { <gl-button v-gl-tooltip v-track-event="trackToggleTimelineView(timelineEnabled)" - icon="comments" + icon="history" :selected="timelineEnabled" :title="tooltip" :aria-label="tooltip" diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index 01e3f84d00e..65b3fd6f8b3 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -57,7 +57,7 @@ export default { :link-href="author.path" :img-alt="author.name" :img-src="author.avatar_url" - :img-size="26" + :img-size="24" :tooltip-text="author.name" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index d670d0bd4c5..61cb4ab2a10 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { s__ } from '~/locale'; -import Autosave from '../../autosave'; -import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; +import Autosave from '~/autosave'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; export default { methods: { diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 93236b05100..754a534e055 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,6 +1,6 @@ import { mapGetters, mapActions, mapState } from 'vuex'; import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils'; -import { updateHistory } from '../../lib/utils/url_utility'; +import { updateHistory } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; /** diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 50b05ea9d69..204e704e504 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -9,14 +9,14 @@ import { __, sprintf } from '~/locale'; import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; -import loadAwardsHandler from '../../awards_handler'; -import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; -import Poll from '../../lib/utils/poll'; -import { create } from '../../lib/utils/recurrence'; -import { mergeUrlParams } from '../../lib/utils/url_utility'; -import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; -import TaskList from '../../task_list'; -import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; +import loadAwardsHandler from '~/awards_handler'; +import { isInViewport, scrollToElement, isInMRPage } from '~/lib/utils/common_utils'; +import Poll from '~/lib/utils/poll'; +import { create } from '~/lib/utils/recurrence'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import sidebarTimeTrackingEventHub from '~/sidebar/event_hub'; +import TaskList from '~/task_list'; +import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import * as constants from '../constants'; import eventHub from '../event_hub'; import * as types from './mutation_types'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index ba19ecd0c04..5cc2c673391 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -1,5 +1,5 @@ import { isEqual } from 'lodash'; -import { isInMRPage } from '../../lib/utils/common_utils'; +import { isInMRPage } from '~/lib/utils/common_utils'; import * as constants from '../constants'; import * as types from './mutation_types'; import * as utils from './utils'; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue index e4a1a1a8266..bb1dac40b92 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue @@ -1,13 +1,13 @@ <script> -import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { GlButton, GlLink, GlTooltip, GlSprintf } from '@gitlab/ui'; export default { name: 'DeleteButton', components: { GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, + GlLink, + GlTooltip, + GlSprintf, }, props: { title: { @@ -18,6 +18,11 @@ export default { type: String, required: true, }, + tooltipLink: { + type: String, + default: '', + required: false, + }, disabled: { type: Boolean, default: false, @@ -29,21 +34,12 @@ export default { required: false, }, }, - computed: { - tooltipConfiguration() { - return { - disabled: this.tooltipDisabled, - title: this.tooltipTitle, - }; - }, - }, }; </script> <template> - <div v-gl-tooltip="tooltipConfiguration"> + <div ref="deleteImageButton"> <gl-button - v-gl-tooltip :disabled="disabled" :title="title" :aria-label="title" @@ -52,5 +48,14 @@ export default { icon="remove" @click="$emit('delete')" /> + <gl-tooltip :target="() => $refs.deleteImageButton" :disabled="tooltipDisabled" placement="top"> + <gl-sprintf :message="tooltipTitle"> + <template #docLink="{ content }"> + <gl-link v-if="tooltipLink" :href="tooltipLink" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-tooltip> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue index c1ec523574a..484903354e8 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue @@ -8,11 +8,13 @@ import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { ASYNC_DELETE_IMAGE_ERROR_MESSAGE, LIST_DELETE_BUTTON_DISABLED, + LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION, REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, CLEANUP_TIMED_OUT_ERROR_MESSAGE, IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS, + IMAGE_MIGRATING_STATE, ROOT_IMAGE_TEXT, } from '../../constants/index'; import DeleteButton from '../delete_button.vue'; @@ -32,6 +34,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: ['config'], props: { item: { type: Object, @@ -44,13 +47,12 @@ export default { }, }, i18n: { - LIST_DELETE_BUTTON_DISABLED, REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, }, computed: { disabledDelete() { - return !this.item.canDelete || this.deleting; + return !this.item.canDelete || this.deleting || this.migrating; }, id() { return getIdFromGraphQLId(this.item.id); @@ -58,6 +60,9 @@ export default { deleting() { return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS; }, + migrating() { + return this.item.migrationState === IMAGE_MIGRATING_STATE; + }, failedDelete() { return this.item.status === IMAGE_FAILED_DELETED_STATUS; }, @@ -83,6 +88,11 @@ export default { routerLinkEvent() { return this.deleting ? '' : 'click'; }, + deleteButtonTooltipTitle() { + return this.migrating + ? LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION + : LIST_DELETE_BUTTON_DISABLED; + }, }, }; </script> @@ -144,8 +154,9 @@ export default { <delete-button :title="$options.i18n.REMOVE_REPOSITORY_LABEL" :disabled="disabledDelete" - :tooltip-disabled="item.canDelete" - :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" + :tooltip-disabled="!disabledDelete" + :tooltip-link="config.containerRegistryImportingHelpPagePath" + :tooltip-title="deleteButtonTooltipTitle" @delete="$emit('delete', item)" /> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue index 6d2ff9ea7b6..154e176dc6e 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue @@ -1,4 +1,5 @@ <script> +import { GlLink } from '@gitlab/ui'; import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; import { n__, sprintf } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; @@ -9,6 +10,7 @@ import { LIST_INTRO_TEXT, EXPIRATION_POLICY_WILL_RUN_IN, EXPIRATION_POLICY_DISABLED_TEXT, + SET_UP_CLEANUP, } from '../../constants/index'; export default { @@ -16,6 +18,7 @@ export default { components: { TitleArea, MetadataItem, + GlLink, }, props: { expirationPolicy: { @@ -43,6 +46,16 @@ export default { required: false, default: false, }, + cleanupPoliciesSettingsPath: { + type: String, + default: '', + required: false, + }, + showCleanupPolicyLink: { + type: Boolean, + required: false, + default: false, + }, }, loader: { repeat: 10, @@ -51,6 +64,7 @@ export default { }, i18n: { CONTAINER_REGISTRY_TITLE, + SET_UP_CLEANUP, }, computed: { imagesCountText() { @@ -105,6 +119,9 @@ export default { :text="expirationPolicyText" size="xl" /> + <gl-link v-if="showCleanupPolicyLink" class="gl-ml-2" :href="cleanupPoliciesSettingsPath">{{ + $options.i18n.SET_UP_CLEANUP + }}</gl-link> </template> </title-area> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js index 40f9b09a982..e584da23edb 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js @@ -4,7 +4,7 @@ export const EXPIRATION_POLICY_WILL_RUN_IN = s__( 'ContainerRegistry|Expiration policy will run in %{time}', ); export const EXPIRATION_POLICY_DISABLED_TEXT = s__( - 'ContainerRegistry|Expiration policy is disabled', + 'ContainerRegistry|Expiration policy is disabled.', ); export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted'); export const DELETE_ALERT_LINK_TEXT = s__( @@ -13,3 +13,4 @@ export const DELETE_ALERT_LINK_TEXT = s__( export const CLEANUP_TIMED_OUT_ERROR_MESSAGE = s__( 'ContainerRegistry|Cleanup timed out before it could delete all tags', ); +export const SET_UP_CLEANUP = s__('ContainerRegistry|Set up cleanup'); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js index 7fa950ccfd0..c7022d6070f 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js @@ -14,6 +14,9 @@ export const LIST_INTRO_TEXT = s__( export const LIST_DELETE_BUTTON_DISABLED = s__( 'ContainerRegistry|Missing or insufficient permission, delete button disabled', ); +export const LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION = s__( + `ContainerRegistry|Image repository temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}`, +); export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository'); export const REMOVE_REPOSITORY_MODAL_TEXT = s__( 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', @@ -45,6 +48,7 @@ export const EMPTY_RESULT_MESSAGE = s__( export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED'; export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED'; +export const IMAGE_MIGRATING_STATE = 'importing'; export const GRAPHQL_PAGE_SIZE = 10; export const SORT_FIELDS = [ diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql index d753d33a02c..8c577cc7b17 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getContainerRepositoryTags( $id: ID! diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js index ca5bd8d6964..a558550c91f 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js @@ -35,7 +35,7 @@ export default () => { expirationPolicy, isGroupPage, isAdmin, - showCleanupPolicyOnAlert, + showCleanupPolicyLink, showUnfinishedTagCleanupCallout, connectionError, invalidPathError, @@ -68,7 +68,7 @@ export default () => { expirationPolicy: expirationPolicy ? JSON.parse(expirationPolicy) : undefined, isGroupPage: parseBoolean(isGroupPage), isAdmin: parseBoolean(isAdmin), - showCleanupPolicyOnAlert: parseBoolean(showCleanupPolicyOnAlert), + showCleanupPolicyLink: parseBoolean(showCleanupPolicyLink), showUnfinishedTagCleanupCallout: parseBoolean(showUnfinishedTagCleanupCallout), connectionError: parseBoolean(connectionError), invalidPathError: parseBoolean(invalidPathError), diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index 5f9e614bebb..d1cab406984 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue @@ -11,7 +11,6 @@ import { import { get } from 'lodash'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import createFlash from '~/flash'; -import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import Tracking from '~/tracking'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; @@ -60,7 +59,6 @@ export default { GlSkeletonLoader, RegistryHeader, DeleteImage, - CleanupPolicyEnabledAlert, PersistedSearch, }, directives: { @@ -273,12 +271,6 @@ export default { </gl-sprintf> </gl-alert> - <cleanup-policy-enabled-alert - v-if="config.showCleanupPolicyOnAlert" - :project-path="config.projectPath" - :cleanup-policies-settings-path="config.cleanupPoliciesSettingsPath" - /> - <gl-empty-state v-if="showConnectionError" :title="$options.i18n.CONNECTION_ERROR_TITLE" @@ -304,6 +296,8 @@ export default { :expiration-policy="config.expirationPolicy" :help-page-path="config.helpPagePath" :hide-expiration-policy-data="config.isGroupPage" + :cleanup-policies-settings-path="config.cleanupPoliciesSettingsPath" + :show-cleanup-policy-link="config.showCleanupPolicyLink" > <template #commands> <cli-commands diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index eb112238c11..67c2ca02d20 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -1,13 +1,18 @@ <script> import { GlAlert, + GlDropdown, + GlDropdownItem, GlEmptyState, GlFormGroup, GlFormInputGroup, + GlModal, + GlModalDirective, GlSkeletonLoader, GlSprintf, } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { __, s__, n__, sprintf } from '~/locale'; +import Api from '~/api'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; @@ -22,16 +27,22 @@ import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency export default { components: { GlAlert, + GlDropdown, + GlDropdownItem, GlEmptyState, GlFormGroup, GlFormInputGroup, + GlModal, GlSkeletonLoader, GlSprintf, ClipboardButton, TitleArea, ManifestsList, }, - inject: ['groupPath', 'dependencyProxyAvailable', 'noManifestsIllustration'], + directives: { + GlModalDirective, + }, + inject: ['groupPath', 'groupId', 'dependencyProxyAvailable', 'noManifestsIllustration'], i18n: { proxyNotAvailableText: s__( 'DependencyProxy|Dependency Proxy feature is limited to public groups for now.', @@ -41,6 +52,20 @@ export default { blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'), pageTitle: s__('DependencyProxy|Dependency Proxy'), noManifestTitle: s__('DependencyProxy|There are no images in the cache'), + deleteCacheAlertMessageSuccess: s__( + 'DependencyProxy|All items in the cache are scheduled for removal.', + ), + clearCache: s__('DependencyProxy|Clear cache'), + }, + confirmClearCacheModal: 'confirm-clear-cache-modal', + modalButtons: { + primary: { + text: s__('DependencyProxy|Clear cache'), + attributes: [{ variant: 'danger' }], + }, + secondary: { + text: __('Cancel'), + }, }, links: { DEPENDENCY_PROXY_DOCS_PATH, @@ -48,6 +73,8 @@ export default { data() { return { group: {}, + showDeleteCacheAlert: false, + deleteCacheAlertMessage: '', }; }, apollo: { @@ -80,6 +107,33 @@ export default { manifests() { return this.group.dependencyProxyManifests.nodes; }, + modalTitleWithCount() { + return sprintf( + n__( + 'Clear %{count} image from cache?', + 'Clear %{count} images from cache?', + this.group.dependencyProxyBlobCount, + ), + { + count: this.group.dependencyProxyBlobCount, + }, + ); + }, + modalConfirmationMessageWithCount() { + return sprintf( + n__( + 'You are about to clear %{count} image from the cache. Once you confirm, the next time a pipeline runs it must pull an image or tag from Docker Hub. Are you sure?', + 'You are about to clear %{count} images from the cache. Once you confirm, the next time a pipeline runs it must pull an image or tag from Docker Hub. Are you sure?', + this.group.dependencyProxyBlobCount, + ), + { + count: this.group.dependencyProxyBlobCount, + }, + ); + }, + showDeleteDropdown() { + return this.group.dependencyProxyBlobCount > 0; + }, }, methods: { fetchNextPage() { @@ -103,13 +157,47 @@ export default { }, }); }, + async submit() { + try { + await Api.deleteDependencyProxyCacheList(this.groupId); + + this.deleteCacheAlertMessage = this.$options.i18n.deleteCacheAlertMessageSuccess; + this.showDeleteCacheAlert = true; + } catch (err) { + this.deleteCacheAlertMessage = err; + this.showDeleteCacheAlert = true; + } + }, }, }; </script> <template> <div> - <title-area :title="$options.i18n.pageTitle" :info-messages="infoMessages" /> + <gl-alert + v-if="showDeleteCacheAlert" + data-testid="delete-cache-alert" + @dismiss="showDeleteCacheAlert = false" + > + {{ deleteCacheAlertMessage }} + </gl-alert> + <title-area :title="$options.i18n.pageTitle" :info-messages="infoMessages"> + <template v-if="showDeleteDropdown" #right-actions> + <gl-dropdown + icon="ellipsis_v" + text="More actions" + :text-sr-only="true" + category="tertiary" + no-caret + > + <gl-dropdown-item + v-gl-modal-directive="$options.confirmClearCacheModal" + variant="danger" + >{{ $options.i18n.clearCache }}</gl-dropdown-item + > + </gl-dropdown> + </template> + </title-area> <gl-alert v-if="!dependencyProxyAvailable" :dismissible="false" @@ -159,5 +247,15 @@ export default { :title="$options.i18n.noManifestTitle" /> </div> + + <gl-modal + :modal-id="$options.confirmClearCacheModal" + :title="modalTitleWithCount" + :action-primary="$options.modalButtons.primary" + :action-secondary="$options.modalButtons.secondary" + @primary="submit" + > + {{ modalConfirmationMessageWithCount }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql index 9241dccb2d5..5c43b10a5e3 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getDependencyProxyDetails( $fullPath: ID! diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list.vue new file mode 100644 index 00000000000..c1b5367c96a --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list.vue @@ -0,0 +1,42 @@ +<script> +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import HarborListRow from '~/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue'; + +export default { + name: 'HarborList', + components: { + RegistryList, + HarborListRow, + }, + props: { + images: { + type: Array, + required: true, + }, + metadataLoading: { + type: Boolean, + default: false, + required: false, + }, + pageInfo: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <registry-list + :items="images" + :hidden-delete="true" + :pagination="pageInfo" + id-property="name" + @prev-page="$emit('prev-page')" + @next-page="$emit('next-page')" + > + <template #default="{ item }"> + <harbor-list-row :item="item" :metadata-loading="metadataLoading" /> + </template> + </registry-list> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue new file mode 100644 index 00000000000..086b9c73d75 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue @@ -0,0 +1,67 @@ +<script> +import { sprintf } from '~/locale'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { + HARBOR_REGISTRY_TITLE, + LIST_INTRO_TEXT, + imagesCountInfoText, +} from '~/packages_and_registries/harbor_registry/constants'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; + +export default { + name: 'HarborListHeader', + components: { + TitleArea, + MetadataItem, + }, + props: { + imagesCount: { + type: Number, + default: 0, + required: false, + }, + helpPagePath: { + type: String, + default: '', + required: false, + }, + metadataLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + i18n: { + HARBOR_REGISTRY_TITLE, + }, + computed: { + imagesCountText() { + const pluralisedString = imagesCountInfoText(this.imagesCount); + return sprintf(pluralisedString, { count: this.imagesCount }); + }, + infoMessages() { + return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }]; + }, + }, +}; +</script> + +<template> + <title-area + :title="$options.i18n.HARBOR_REGISTRY_TITLE" + :info-messages="infoMessages" + :metadata-loading="metadataLoading" + > + <template #right-actions> + <slot name="commands"></slot> + </template> + <template #metadata-count> + <metadata-item + v-if="imagesCount" + data-testid="images-count" + icon="container-image" + :text="imagesCountText" + /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue new file mode 100644 index 00000000000..258472fe16e --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue @@ -0,0 +1,84 @@ +<script> +import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; + +export default { + name: 'HarborListRow', + components: { + ClipboardButton, + GlSprintf, + GlIcon, + ListItem, + GlSkeletonLoader, + }, + props: { + item: { + type: Object, + required: true, + }, + metadataLoading: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + id() { + return this.item.id; + }, + artifactCountText() { + return n__( + 'HarborRegistry|%{count} Tag', + 'HarborRegistry|%{count} Tags', + this.item.artifactCount, + ); + }, + imageName() { + return this.item.name; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs"> + <template #left-primary> + <router-link + class="gl-text-body gl-font-weight-bold" + data-testid="details-link" + data-qa-selector="registry_image_content" + :to="{ name: 'details', params: { id } }" + > + {{ imageName }} + </router-link> + <clipboard-button + v-if="item.location" + :text="item.location" + :title="item.location" + category="tertiary" + /> + </template> + <template #left-secondary> + <template v-if="!metadataLoading"> + <span class="gl-display-flex gl-align-items-center" data-testid="tags-count"> + <gl-icon name="tag" class="gl-mr-2" /> + <gl-sprintf :message="artifactCountText"> + <template #count> + {{ item.artifactCount }} + </template> + </gl-sprintf> + </span> + </template> + + <div v-else class="gl-w-full"> + <gl-skeleton-loader :width="900" :height="16" preserve-aspect-ratio="xMinYMax meet"> + <circle cx="6" cy="8" r="6" /> + <rect x="16" y="4" width="100" height="8" rx="4" /> + </gl-skeleton-loader> + </div> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js new file mode 100644 index 00000000000..a7891821755 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js @@ -0,0 +1,29 @@ +import { s__, __ } from '~/locale'; + +export const ROOT_IMAGE_TEXT = s__('HarborRegistry|Root image'); +export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') }; + +export const ASCENDING_ORDER = 'asc'; +export const DESCENDING_ORDER = 'desc'; + +export const NAME_SORT_FIELD_KEY = 'name'; +export const UPDATED_SORT_FIELD_KEY = 'update_time'; +export const CREATED_SORT_FIELD_KEY = 'creation_time'; + +export const SORT_FIELD_MAPPING = { + NAME: NAME_SORT_FIELD_KEY, + UPDATED: UPDATED_SORT_FIELD_KEY, + CREATED: CREATED_SORT_FIELD_KEY, +}; + +/* eslint-disable @gitlab/require-i18n-strings */ +export const dockerBuildCommand = (repositoryUrl) => { + return `docker build -t ${repositoryUrl} .`; +}; +export const dockerPushCommand = (repositoryUrl) => { + return `docker push ${repositoryUrl}`; +}; +export const dockerLoginCommand = (registryHostUrlWithPort) => { + return `docker login ${registryHostUrlWithPort}`; +}; +/* eslint-enable @gitlab/require-i18n-strings */ diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js new file mode 100644 index 00000000000..2519f6b74a2 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js @@ -0,0 +1,39 @@ +import { s__, __ } from '~/locale'; + +export const UPDATED_AT = s__('HarborRegistry|Last updated %{time}'); + +export const MISSING_OR_DELETED_IMAGE_TITLE = s__( + 'HarborRegistry|The image repository could not be found.', +); + +export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__( + 'HarborRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.', +); + +export const NO_TAGS_TITLE = s__('HarborRegistry|This image has no active tags'); + +export const NO_TAGS_MESSAGE = s__( + `HarborRegistry|The last tag related to this image was recently removed. +This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. +If you have any questions, contact your administrator.`, +); + +export const NO_TAGS_MATCHING_FILTERS_TITLE = s__('HarborRegistry|The filter returned no results'); + +export const NO_TAGS_MATCHING_FILTERS_DESCRIPTION = s__( + 'HarborRegistry|Please try different search criteria', +); + +export const DIGEST_LABEL = s__('HarborRegistry|Digest: %{imageId}'); +export const CREATED_AT_LABEL = s__('HarborRegistry|Published %{timeInfo}'); +export const PUBLISHED_DETAILS_ROW_TEXT = s__( + 'HarborRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}', +); +export const MANIFEST_DETAILS_ROW_TEST = s__('HarborRegistry|Manifest digest: %{digest}'); +export const CONFIGURATION_DETAILS_ROW_TEST = s__('HarborRegistry|Configuration digest: %{digest}'); +export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( + 'HarborRegistry|Invalid tag: missing manifest digest', +); + +export const NOT_AVAILABLE_TEXT = __('N/A'); +export const NOT_AVAILABLE_SIZE = __('0 bytes'); diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/index.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/index.js new file mode 100644 index 00000000000..22f462e0b97 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/index.js @@ -0,0 +1,3 @@ +export * from './common'; +export * from './list'; +export * from './details'; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js new file mode 100644 index 00000000000..a6cd59918ff --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js @@ -0,0 +1,33 @@ +import { s__, __, n__ } from '~/locale'; +import { NAME_SORT_FIELD } from './common'; + +// Translations strings + +export const HARBOR_REGISTRY_TITLE = s__('HarborRegistry|Harbor Registry'); + +export const CONNECTION_ERROR_TITLE = s__('HarborRegistry|Harbor connection error'); +export const CONNECTION_ERROR_MESSAGE = s__( + `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`, +); +export const LIST_INTRO_TEXT = s__( + `HarborRegistry|With the Harbor Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`, +); + +export const imagesCountInfoText = (count) => { + return n__( + 'HarborRegistry|%{count} Image repository', + 'HarborRegistry|%{count} Image repositories', + count, + ); +}; + +export const EMPTY_RESULT_TITLE = s__('HarborRegistry|Sorry, your filter produced no results.'); +export const EMPTY_RESULT_MESSAGE = s__( + 'HarborRegistry|To widen your search, change or remove the filters above.', +); + +export const SORT_FIELDS = [ + { orderBy: 'UPDATED', label: __('Updated') }, + { orderBy: 'CREATED', label: __('Created') }, + NAME_SORT_FIELD, +]; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js new file mode 100644 index 00000000000..ecfefead61a --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js @@ -0,0 +1,78 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import PerformancePlugin from '~/performance/vue_performance_plugin'; +import Translate from '~/vue_shared/translate'; +import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; +import { renderBreadcrumb } from '~/packages_and_registries/shared/utils'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { + dockerBuildCommand, + dockerPushCommand, + dockerLoginCommand, +} from '~/packages_and_registries/harbor_registry/constants'; +import createRouter from './router'; +import HarborRegistryExplorer from './pages/index.vue'; + +Vue.use(Translate); +Vue.use(GlToast); + +Vue.use(PerformancePlugin, { + components: [ + 'RegistryListPage', + 'ListHeader', + 'ImageListRow', + 'RegistryDetailsPage', + 'DetailsHeader', + 'TagsList', + ], +}); + +export default (id) => { + const el = document.getElementById(id); + + if (!el) { + return null; + } + + const { endpoint, connectionError, invalidPathError, isGroupPage, ...config } = el.dataset; + + const breadCrumbState = Vue.observable({ + name: '', + updateName(value) { + this.name = value; + }, + }); + + const router = createRouter(endpoint, breadCrumbState); + + const attachMainComponent = () => { + return new Vue({ + el, + router, + provide() { + return { + breadCrumbState, + config: { + ...config, + connectionError: parseBoolean(connectionError), + invalidPathError: parseBoolean(invalidPathError), + isGroupPage: parseBoolean(isGroupPage), + helpPagePath: helpPagePath('user/packages/container_registry/index'), + }, + dockerBuildCommand: dockerBuildCommand(config.repositoryUrl), + dockerPushCommand: dockerPushCommand(config.repositoryUrl), + dockerLoginCommand: dockerLoginCommand(config.registryHostUrlWithPort), + }; + }, + render(createElement) { + return createElement(HarborRegistryExplorer); + }, + }); + }; + + return { + attachBreadcrumb: renderBreadcrumb(router, null, RegistryBreadcrumb), + attachMainComponent, + }; +}; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js b/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js new file mode 100644 index 00000000000..50c7df1483c --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js @@ -0,0 +1,200 @@ +const mockRequestFn = (mockData) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(mockData); + }, 2000); + }); +}; +export const harborListResponse = () => { + const harborListResponseData = { + repositories: [ + { + artifactCount: 1, + creationTime: '2022-03-02T06:35:53.205Z', + id: 25, + name: 'shao/flinkx', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', + }, + { + artifactCount: 1, + creationTime: '2022-03-02T06:35:53.205Z', + id: 26, + name: 'shao/flinkx1', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', + }, + { + artifactCount: 1, + creationTime: '2022-03-02T06:35:53.205Z', + id: 27, + name: 'shao/flinkx2', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', + }, + ], + totalCount: 3, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, + }; + + return mockRequestFn(harborListResponseData); +}; + +export const getHarborRegistryImageDetail = () => { + const harborRegistryImageDetailData = { + artifactCount: 1, + creationTime: '2022-03-02T06:35:53.205Z', + id: 25, + name: 'shao/flinkx', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', + tagsCount: 10, + }; + + return mockRequestFn(harborRegistryImageDetailData); +}; + +export const harborTagsResponse = () => { + const harborTagsResponseData = { + tags: [ + { + digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', + name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', + revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255', + shortRevision: 'f53bde3d4', + createdAt: '2022-03-02T23:59:05+00:00', + totalSize: '6623124', + }, + { + digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', + name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', + revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e', + shortRevision: 'e1fe52d8b', + createdAt: '2022-02-10T01:09:56+00:00', + totalSize: '920760', + }, + { + digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', + name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', + revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f', + shortRevision: 'c72770c6e', + createdAt: '2021-12-22T04:48:48+00:00', + totalSize: '48609053', + }, + { + digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', + name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', + revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a', + shortRevision: '1ac2a4319', + createdAt: '2022-03-09T11:02:27+00:00', + totalSize: '35141894', + }, + { + digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', + name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', + revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c', + shortRevision: 'cf8fee086', + createdAt: '2022-01-21T11:31:43+00:00', + totalSize: '48716070', + }, + { + digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', + name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', + revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15', + shortRevision: '1a4b48198', + createdAt: '2022-01-21T11:31:51+00:00', + totalSize: '6623127', + }, + { + digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', + name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', + revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61', + shortRevision: '03e2e2777', + createdAt: '2022-03-02T23:58:20+00:00', + totalSize: '911377', + }, + { + digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', + name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', + revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012', + shortRevision: '350e78d60', + createdAt: '2022-01-19T13:49:14+00:00', + totalSize: '48710241', + }, + { + digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', + name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', + revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18', + shortRevision: '76038370b', + createdAt: '2022-01-24T12:56:22+00:00', + totalSize: '280065', + }, + { + digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', + name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', + revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f', + shortRevision: '3d4b49a7b', + createdAt: '2022-02-17T17:37:52+00:00', + totalSize: '48655767', + }, + ], + totalCount: 10, + pageInfo: { + hasNextPage: false, + hasPreviousPage: true, + }, + }; + + return mockRequestFn(harborTagsResponseData); +}; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue new file mode 100644 index 00000000000..dca63e1a569 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/index.vue @@ -0,0 +1,5 @@ +<template> + <div> + <router-view ref="router-view" /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue new file mode 100644 index 00000000000..7aaef2ed57a --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue @@ -0,0 +1,177 @@ +<script> +import { GlEmptyState, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { + SORT_FIELDS, + CONNECTION_ERROR_TITLE, + CONNECTION_ERROR_MESSAGE, + EMPTY_RESULT_TITLE, + EMPTY_RESULT_MESSAGE, +} from '~/packages_and_registries/harbor_registry/constants'; +import Tracking from '~/tracking'; +import { harborListResponse } from '../mock_api'; + +export default { + name: 'HarborListPage', + components: { + HarborListHeader, + HarborList, + GlSkeletonLoader, + GlEmptyState, + GlSprintf, + GlLink, + PersistedSearch, + CliCommands: () => + import( + /* webpackChunkName: 'harbor_registry_components' */ '~/packages_and_registries/shared/components/cli_commands.vue' + ), + }, + mixins: [Tracking.mixin()], + inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], + loader: { + repeat: 10, + width: 1000, + height: 40, + }, + i18n: { + CONNECTION_ERROR_TITLE, + CONNECTION_ERROR_MESSAGE, + EMPTY_RESULT_TITLE, + EMPTY_RESULT_MESSAGE, + }, + searchConfig: SORT_FIELDS, + data() { + return { + images: [], + totalCount: 0, + pageInfo: {}, + filter: [], + isLoading: true, + sorting: null, + name: null, + }; + }, + computed: { + showCommands() { + return !this.isLoading && !this.config?.isGroupPage && this.images?.length; + }, + showConnectionError() { + return this.config.connectionError || this.config.invalidPathError; + }, + }, + methods: { + fetchHarborImages() { + // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 + this.isLoading = true; + + harborListResponse() + .then((res) => { + this.images = res?.repositories || []; + this.totalCount = res?.totalCount || 0; + this.pageInfo = res?.pageInfo || {}; + this.isLoading = false; + }) + .catch(() => {}); + }, + handleSearchUpdate({ sort, filters }) { + this.sorting = sort; + + const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM); + this.name = search?.value?.data; + + this.fetchHarborImages(); + }, + fetchPrevPage() { + // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 + this.fetchHarborImages(); + }, + fetchNextPage() { + // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 + this.fetchHarborImages(); + }, + }, +}; +</script> + +<template> + <div> + <gl-empty-state + v-if="showConnectionError" + :title="$options.i18n.CONNECTION_ERROR_TITLE" + :svg-path="config.containersErrorImage" + > + <template #description> + <p> + <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE"> + <template #docLink="{ content }"> + <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </template> + </gl-empty-state> + <template v-else> + <harbor-list-header + :metadata-loading="isLoading" + :images-count="totalCount" + :help-page-path="config.helpPagePath" + > + <template #commands> + <cli-commands + v-if="showCommands" + :docker-build-command="dockerBuildCommand" + :docker-push-command="dockerPushCommand" + :docker-login-command="dockerLoginCommand" + /> + </template> + </harbor-list-header> + <persisted-search + :sortable-fields="$options.searchConfig" + :default-order="$options.searchConfig[0].orderBy" + default-sort="desc" + @update="handleSearchUpdate" + /> + + <div v-if="isLoading" class="gl-mt-5"> + <gl-skeleton-loader + v-for="index in $options.loader.repeat" + :key="index" + :width="$options.loader.width" + :height="$options.loader.height" + preserve-aspect-ratio="xMinYMax meet" + > + <rect width="500" x="10" y="10" height="20" rx="4" /> + <circle cx="525" cy="20" r="10" /> + <rect x="960" y="0" width="40" height="40" rx="4" /> + </gl-skeleton-loader> + </div> + <template v-else> + <template v-if="images.length > 0 || name"> + <harbor-list + v-if="images.length" + :images="images" + :meta-data-loading="isLoading" + :page-info="pageInfo" + @prev-page="fetchPrevPage" + @next-page="fetchNextPage" + /> + <gl-empty-state + v-else + :svg-path="config.noContainersImage" + data-testid="emptySearch" + :title="$options.i18n.EMPTY_RESULT_TITLE" + > + <template #description> + {{ $options.i18n.EMPTY_RESULT_MESSAGE }} + </template> + </gl-empty-state> + </template> + </template> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js new file mode 100644 index 00000000000..572dd382be3 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { HARBOR_REGISTRY_TITLE } from './constants/index'; +import List from './pages/list.vue'; +import Details from './pages/details.vue'; + +Vue.use(VueRouter); + +export default function createRouter(base, breadCrumbState) { + const router = new VueRouter({ + base, + mode: 'history', + routes: [ + { + name: 'list', + path: '/', + component: List, + meta: { + nameGenerator: () => HARBOR_REGISTRY_TITLE, + root: true, + }, + }, + { + name: 'details', + path: '/:id', + component: Details, + meta: { + nameGenerator: () => breadCrumbState.name, + }, + }, + ], + }); + + return router; +} diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js index 488860e5bc2..408d34fbe93 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js @@ -26,6 +26,7 @@ export const receivePackagesListSuccess = ({ commit }, { data, headers }) => { export const requestPackagesList = ({ dispatch, state }, params = {}) => { dispatch('setLoading', true); + // eslint-disable-next-line camelcase const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params; const { sort, orderBy } = state.sorting; const type = state.config.forceTerraform diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue index c27083261b5..7a88e04d1f9 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue @@ -99,7 +99,6 @@ export default { <local-storage-sync storage-key="package_registry_list_sorting" :value="sorting" - as-json @input="updateSorting" > <url-sync> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql index 4b913590949..5bde5f08e56 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql @@ -1,5 +1,5 @@ #import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql" -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getPackages( $fullPath: ID! diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue index 7be3bba7cae..854c88b2ad3 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -9,7 +9,6 @@ import { UNAVAILABLE_ADMIN_FEATURE_TEXT, } from '~/packages_and_registries/settings/project/constants'; import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; -import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; import SettingsForm from './settings_form.vue'; @@ -18,19 +17,11 @@ export default { components: { SettingsBlock, SettingsForm, - CleanupPolicyEnabledAlert, GlAlert, GlSprintf, GlLink, }, - inject: [ - 'projectPath', - 'isAdmin', - 'adminSettingsPath', - 'enableHistoricEntries', - 'helpPagePath', - 'showCleanupPolicyOnAlert', - ], + inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'], i18n: { UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, @@ -87,7 +78,6 @@ export default { <template> <section data-testid="registry-settings-app"> - <cleanup-policy-enabled-alert v-if="showCleanupPolicyOnAlert" :project-path="projectPath" /> <settings-block :collapsible="false"> <template #title> {{ __('Clean up image tags') }}</template> <template #description> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js index 2a3e2c28fa6..17c33073668 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js @@ -20,7 +20,6 @@ export default () => { adminSettingsPath, tagsRegexHelpPagePath, helpPagePath, - showCleanupPolicyOnAlert, } = el.dataset; return new Vue({ el, @@ -35,7 +34,6 @@ export default () => { adminSettingsPath, tagsRegexHelpPagePath, helpPagePath, - showCleanupPolicyOnAlert: parseBoolean(showCleanupPolicyOnAlert), }, render(createElement) { return createElement('registry-settings-app', {}); diff --git a/app/assets/javascripts/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue b/app/assets/javascripts/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue deleted file mode 100644 index d51c62e0623..00000000000 --- a/app/assets/javascripts/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue +++ /dev/null @@ -1,54 +0,0 @@ -<script> -import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; - -export default { - components: { - GlAlert, - GlLink, - GlSprintf, - LocalStorageSync, - }, - props: { - projectPath: { - type: String, - required: true, - }, - cleanupPoliciesSettingsPath: { - type: String, - required: false, - default: '', - }, - }, - data() { - return { - dismissed: false, - }; - }, - computed: { - storageKey() { - return `cleanup_policy_enabled_for_project_${this.projectPath}`; - }, - }, - i18n: { - message: s__( - 'ContainerRegistry|Cleanup policies are now available for this project. %{linkStart}Click here to get started.%{linkEnd}', - ), - }, -}; -</script> - -<template> - <local-storage-sync v-model="dismissed" :storage-key="storageKey"> - <gl-alert v-if="!dismissed" class="gl-mt-2" dismissible @dismiss="dismissed = true"> - <gl-sprintf :message="$options.i18n.message"> - <template #link="{ content }"> - <gl-link v-if="cleanupPoliciesSettingsPath" :href="cleanupPoliciesSettingsPath">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </gl-alert> - </local-storage-sync> -</template> diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue index 79381f82009..cc345fda7e8 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue @@ -13,7 +13,8 @@ export default { props: { title: { type: String, - required: true, + default: '', + required: false, }, isLoading: { type: Boolean, diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index aa2f539b6e2..e15766a7839 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -22,7 +22,10 @@ export default { this.prepareData = prepareData; this.successCallback = successCallback; this.errorCallback = errorCallback; - this.loading = $(`${container} .loading`).first(); + this.$container = $(container); + this.$loading = this.$container.length + ? this.$container.find('.loading').first() + : $('.loading').first(); if (preload) { this.offset = 0; this.getOld(); @@ -31,7 +34,7 @@ export default { }, getOld() { - this.loading.show(); + this.$loading.show(); const url = $('.content_list').data('href') || removeParams(['limit', 'offset']); axios @@ -49,11 +52,11 @@ export default { if (!this.disable && !this.isScrollable()) { this.getOld(); } else { - this.loading.hide(); + this.$loading.hide(); } }) .catch((err) => this.errorCallback(err)) - .finally(() => this.loading.hide()); + .finally(() => this.$loading.hide()); }, append(count, html) { @@ -83,8 +86,12 @@ export default { fireOnce: true, ceaseFire: () => this.disable === true, callback: () => { - if (!this.loading.is(':visible')) { - this.loading.show(); + if (this.$container.length && !this.$container.is(':visible')) { + return; + } + + if (!this.$loading.is(':visible')) { + this.$loading.show(); this.getOld(); } }, diff --git a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js index e78b3f9ec95..29e92a8abad 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { parseBoolean } from '~/lib/utils/common_utils'; -import { truncate } from '../../../lib/utils/text_utility'; +import { truncate } from '~/lib/utils/text_utility'; const MAX_MESSAGE_LENGTH = 500; const MESSAGE_CELL_SELECTOR = '.abuse-reports .message'; diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js index 6b7bfbf217d..e6c1d6d147b 100644 --- a/app/assets/javascripts/pages/admin/admin.js +++ b/app/assets/javascripts/pages/admin/admin.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { refreshCurrentPage } from '../../lib/utils/url_utility'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; export default function adminInit() { $('input#user_force_random_password').on('change', function randomPasswordClick() { diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js index 67eee2c3209..7c81cf80dc6 100644 --- a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js +++ b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js @@ -1,6 +1,6 @@ import createFlash from '~/flash'; -import axios from '../../../lib/utils/axios_utils'; -import { __ } from '../../../locale'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; export default class PayloadDownloader { constructor(trigger) { diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js index c017cf0afa2..ae08806fe4c 100644 --- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js +++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js @@ -1,6 +1,6 @@ import createFlash from '~/flash'; -import axios from '../../../lib/utils/axios_utils'; -import { __ } from '../../../locale'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; export default class PayloadPreviewer { constructor(trigger) { diff --git a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js index 70b896f6372..a50d8de0e88 100644 --- a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js +++ b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js @@ -23,6 +23,7 @@ export default function initSignupRestrictions(elementSelector = '#js-signup-for return new Vue({ el, + name: 'SignupRestrictions', provide: { ...parsedDataset, }, diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js index 2a7e6a45cdd..18ba89f8856 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js @@ -2,52 +2,41 @@ import $ from 'jquery'; import { debounce } from 'lodash'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { textColorForBackground } from '~/lib/utils/color_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __ } from '~/locale'; export default () => { - const $broadcastMessageColor = $('.js-broadcast-message-color'); + const $broadcastMessageTheme = $('.js-broadcast-message-theme'); const $broadcastMessageType = $('.js-broadcast-message-type'); - const $broadcastBannerMessagePreview = $('.js-broadcast-banner-message-preview'); + const $broadcastBannerMessagePreview = $('.js-broadcast-banner-message-preview [role="alert"]'); const $broadcastMessage = $('.js-broadcast-message-message'); - const $jsBroadcastMessagePreview = $('.js-broadcast-message-preview'); + const $jsBroadcastMessagePreview = $('#broadcast-message-preview'); const reloadPreview = function reloadPreview() { const previewPath = $broadcastMessage.data('previewPath'); const message = $broadcastMessage.val(); const type = $broadcastMessageType.val(); - - if (message === '') { - $jsBroadcastMessagePreview.text(__('Your message here')); - } else { - axios - .post(previewPath, { - broadcast_message: { - message, - broadcast_type: type, - }, - }) - .then(({ data }) => { - $jsBroadcastMessagePreview.html(data.message); - }) - .catch(() => - createFlash({ - message: __('An error occurred while rendering preview broadcast message'), - }), - ); - } + const theme = $broadcastMessageTheme.val(); + + axios + .post(previewPath, { + broadcast_message: { + message, + broadcast_type: type, + theme, + }, + }) + .then(({ data }) => { + $jsBroadcastMessagePreview.html(data); + }) + .catch(() => + createFlash({ + message: __('An error occurred while rendering preview broadcast message'), + }), + ); }; - $broadcastMessageColor.on('input', function onMessageColorInput() { - const previewColor = $(this).val(); - $broadcastBannerMessagePreview.css('background-color', previewColor); - }); - - $('input#broadcast_message_font').on('input', function onMessageFontInput() { - const previewColor = $(this).val(); - $broadcastBannerMessagePreview.css('color', previewColor); - }); + $broadcastMessageTheme.on('change', reloadPreview); $broadcastMessageType.on('change', () => { const $broadcastMessageColorFormGroup = $('.js-broadcast-message-background-color-form-group'); @@ -68,37 +57,4 @@ export default () => { reloadPreview(); }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), ); - - const updateColorPreview = () => { - const selectedBackgroundColor = $broadcastMessageColor.val(); - const contrastTextColor = textColorForBackground(selectedBackgroundColor); - - // save contrastTextColor to hidden input field - $('input.text-font-color').val(contrastTextColor); - - // Updates the preview color with the hex-color input - const selectedColorStyle = { - backgroundColor: selectedBackgroundColor, - color: contrastTextColor, - }; - - $('.label-color-preview').css(selectedColorStyle); - - return $jsBroadcastMessagePreview.css(selectedColorStyle); - }; - - const setSuggestedColor = (e) => { - const color = $(e.currentTarget).data('color'); - $broadcastMessageColor - .val(color) - // Notify the form, that color has changed - .trigger('input'); - // Only banner supports colors - if ($broadcastMessageType === 'banner') { - updateColorPreview(); - } - return e.preventDefault(); - }; - - $(document).on('click', '.suggest-colors a', setSuggestedColor); }; diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js index 1630cfb8253..710d2d72f4c 100644 --- a/app/assets/javascripts/pages/admin/groups/new/index.js +++ b/app/assets/javascripts/pages/admin/groups/new/index.js @@ -1,6 +1,6 @@ import initFilePickers from '~/file_pickers'; -import BindInOut from '../../../../behaviors/bind_in_out'; -import Group from '../../../../group'; +import BindInOut from '~/behaviors/bind_in_out'; +import Group from '~/group'; (() => { BindInOut.initAll(); diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js index f0f85b82e2b..a249864fa36 100644 --- a/app/assets/javascripts/pages/admin/index.js +++ b/app/assets/javascripts/pages/admin/index.js @@ -1,6 +1,6 @@ import initGitlabVersionCheck from '~/gitlab_version_check'; -import initAdminStatisticsPanel from '../../admin/statistics_panel/index'; -import initVueAlerts from '../../vue_alerts'; +import initAdminStatisticsPanel from '~/admin/statistics_panel/index'; +import initVueAlerts from '~/vue_alerts'; import initAdmin from './admin'; initVueAlerts(); diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js index a99e0dfa4f0..a1ba920b322 100644 --- a/app/assets/javascripts/pages/groups/clusters/index/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index/index.js @@ -1,8 +1,6 @@ import initClustersListApp from '~/clusters_list'; import PersistentUserCallout from '~/persistent_user_callout'; -document.addEventListener('DOMContentLoaded', () => { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); - initClustersListApp(); -}); +const callout = document.querySelector('.gcp-signup-offer'); +PersistentUserCallout.factory(callout); +initClustersListApp(); diff --git a/app/assets/javascripts/pages/groups/crm/contacts/index.js b/app/assets/javascripts/pages/groups/crm/contacts/index.js index a595246957f..6af47621c1d 100644 --- a/app/assets/javascripts/pages/groups/crm/contacts/index.js +++ b/app/assets/javascripts/pages/groups/crm/contacts/index.js @@ -1,3 +1,3 @@ -import initCrmContactsApp from '~/crm/contacts_bundle'; +import initCrmContactsApp from '~/crm/contacts/bundle'; initCrmContactsApp(); diff --git a/app/assets/javascripts/pages/groups/crm/organizations/index.js b/app/assets/javascripts/pages/groups/crm/organizations/index.js index 16479b43d52..2ad0904688e 100644 --- a/app/assets/javascripts/pages/groups/crm/organizations/index.js +++ b/app/assets/javascripts/pages/groups/crm/organizations/index.js @@ -1,3 +1,3 @@ -import initCrmOrganizationsApp from '~/crm/organizations_bundle'; +import initCrmOrganizationsApp from '~/crm/organizations/bundle'; initCrmOrganizationsApp(); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 96487e14e30..58ca195d7b9 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -10,21 +10,19 @@ import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import initConfirmDanger from '~/init_confirm_danger'; -document.addEventListener('DOMContentLoaded', () => { - initFilePickers(); - initConfirmDanger(); - initSettingsPanels(); - initTransferGroupForm(); - dirtySubmitFactory( - document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'), - ); - mountBadgeSettings(GROUP_BADGE); +initFilePickers(); +initConfirmDanger(); +initSettingsPanels(); +initTransferGroupForm(); +dirtySubmitFactory( + document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'), +); +mountBadgeSettings(GROUP_BADGE); - // Initialize Subgroups selector - groupsSelect(); +// Initialize Subgroups selector +groupsSelect(); - projectSelect(); +projectSelect(); - initSearchSettings(); - initCascadingSettingsLockPopovers(); -}); +initSearchSettings(); +initCascadingSettingsLockPopovers(); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 280b544af3c..79ac31f1659 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -12,9 +12,16 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-group-members-list-app'), { [MEMBER_TYPES.user]: { - tableFields: SHARED_FIELDS.concat(['source', 'granted']), + tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']), tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, - tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], + tableSortableFields: [ + 'account', + 'granted', + 'maxRole', + 'lastSignIn', + 'userCreatedAt', + 'lastActivityOn', + ], requestFormatter: groupMemberRequestFormatter, filteredSearchBar: { show: true, @@ -25,12 +32,25 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), { }, }, [MEMBER_TYPES.group]: { - tableFields: SHARED_FIELDS.concat('granted'), + tableFields: gon?.features?.groupMemberInheritedGroup + ? SHARED_FIELDS.concat(['source', 'granted']) + : SHARED_FIELDS.concat(['granted']), tableAttrs: { table: { 'data-qa-selector': 'groups_list' }, tr: { 'data-qa-selector': 'group_row' }, }, requestFormatter: groupLinkRequestFormatter, + ...(gon?.features?.groupMemberInheritedGroup + ? { + filteredSearchBar: { + show: true, + tokens: ['with_inherited_permissions'], + searchParam: 'search_groups', + placeholder: s__('Members|Filter groups'), + recentSearchesStorageKey: 'group_links_members', + }, + } + : {}), }, [MEMBER_TYPES.invite]: { tableFields: SHARED_FIELDS.concat('invited'), diff --git a/app/assets/javascripts/pages/groups/harbor/repositories/index.js b/app/assets/javascripts/pages/groups/harbor/repositories/index.js new file mode 100644 index 00000000000..0ecce44be54 --- /dev/null +++ b/app/assets/javascripts/pages/groups/harbor/repositories/index.js @@ -0,0 +1,8 @@ +import HarborRegistryExplorer from '~/packages_and_registries/harbor_registry/index'; + +const explorer = HarborRegistryExplorer('js-harbor-registry-list-group'); + +if (explorer) { + explorer.attachBreadcrumb(); + explorer.attachMainComponent(); +} diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index 0ec382983a5..9a4054eb110 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -111,7 +111,7 @@ export default { }, getFullDestinationUrl(params) { - return joinPaths(gon.relative_url_root || '', this.getDestinationUrl(params)); + return joinPaths(gon.relative_url_root || '', '/', this.getDestinationUrl(params)); }, }, @@ -161,7 +161,7 @@ export default { > </template> <template #row-details="{ item }"> - <pre>{{ item.failures }}</pre> + <pre><code>{{ item.failures }}</code></pre> </template> </gl-table> <pagination-bar diff --git a/app/assets/javascripts/pages/import/history/components/import_error_details.vue b/app/assets/javascripts/pages/import/history/components/import_error_details.vue new file mode 100644 index 00000000000..33ba73317f8 --- /dev/null +++ b/app/assets/javascripts/pages/import/history/components/import_error_details.vue @@ -0,0 +1,43 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import API from '~/api'; +import { createAlert } from '~/flash'; +import { DEFAULT_ERROR } from '../utils/error_messages'; + +export default { + components: { + GlLoadingIcon, + }, + props: { + id: { + type: Number, + required: true, + }, + }, + data() { + return { + loading: true, + error: null, + }; + }, + async mounted() { + try { + const { + data: { import_error: importError }, + } = await API.project(this.id); + this.error = importError; + } catch (e) { + createAlert({ message: DEFAULT_ERROR }); + this.error = null; + } finally { + this.loading = false; + } + }, +}; +</script> +<template> + <gl-loading-icon v-if="loading" size="md" /> + <pre + v-else + ><code>{{ error || s__('BulkImport|No additional information provided.') }}</code></pre> +</template> diff --git a/app/assets/javascripts/pages/import/history/components/import_history_app.vue b/app/assets/javascripts/pages/import/history/components/import_history_app.vue new file mode 100644 index 00000000000..557e25f66e2 --- /dev/null +++ b/app/assets/javascripts/pages/import/history/components/import_history_app.vue @@ -0,0 +1,199 @@ +<script> +import { GlButton, GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import createFlash from '~/flash'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { getProjects } from '~/rest_api'; +import ImportStatus from '~/import_entities/components/import_status.vue'; +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { DEFAULT_ERROR } from '../utils/error_messages'; +import ImportErrorDetails from './import_error_details.vue'; + +const DEFAULT_PER_PAGE = 20; +const DEFAULT_TH_CLASSES = + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!'; + +const tableCell = (config) => ({ + thClass: DEFAULT_TH_CLASSES, + tdClass: (value, key, item) => { + return { + // eslint-disable-next-line no-underscore-dangle + 'gl-border-b-0!': item._showDetails, + }; + }, + ...config, +}); + +export default { + components: { + GlButton, + GlEmptyState, + GlIcon, + GlLink, + GlLoadingIcon, + GlTable, + PaginationBar, + ImportStatus, + ImportErrorDetails, + TimeAgo, + }, + + inject: ['assets'], + + data() { + return { + loading: true, + historyItems: [], + paginationConfig: { + page: 1, + perPage: DEFAULT_PER_PAGE, + }, + pageInfo: {}, + }; + }, + + fields: [ + tableCell({ + key: 'source', + label: s__('BulkImport|Source'), + thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`, + }), + tableCell({ + key: 'destination', + label: s__('BulkImport|Destination'), + thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`, + }), + tableCell({ + key: 'created_at', + label: __('Date'), + }), + tableCell({ + key: 'status', + label: __('Status'), + tdAttr: { 'data-qa-selector': 'import_status_indicator' }, + }), + ], + + computed: { + hasHistoryItems() { + return this.historyItems.length > 0; + }, + }, + + watch: { + paginationConfig: { + handler() { + this.loadHistoryItems(); + }, + deep: true, + immediate: true, + }, + }, + + methods: { + async loadHistoryItems() { + try { + this.loading = true; + const { data: historyItems, headers } = await getProjects(undefined, { + imported: true, + simple: false, + page: this.paginationConfig.page, + per_page: this.paginationConfig.perPage, + }); + this.pageInfo = parseIntPagination(normalizeHeaders(headers)); + this.historyItems = historyItems; + } catch (e) { + createFlash({ message: DEFAULT_ERROR, captureError: true, error: e }); + } finally { + this.loading = false; + } + }, + + hasHttpProtocol(url) { + try { + const parsedUrl = new URL(url); + return ['http:', 'https:'].includes(parsedUrl.protocol); + } catch (e) { + return false; + } + }, + + setPageSize(size) { + this.paginationConfig.perPage = size; + this.paginationConfig.page = 1; + }, + }, +}; +</script> + +<template> + <div> + <div + class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center" + > + <h1 class="gl-my-0 gl-py-4 gl-font-size-h1"> + <img :src="assets.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> + {{ s__('BulkImport|Project import history') }} + </h1> + </div> + <gl-loading-icon v-if="loading" size="md" class="gl-mt-5" /> + <gl-empty-state + v-else-if="!hasHistoryItems" + :title="s__('BulkImport|No history is available')" + :description="s__('BulkImport|Your imported projects will appear here.')" + /> + <template v-else> + <gl-table + :fields="$options.fields" + :items="historyItems" + data-qa-selector="import_history_table" + class="gl-w-full" + > + <template #cell(source)="{ item }"> + <template v-if="item.import_url"> + <gl-link + v-if="hasHttpProtocol(item.import_url)" + :href="item.import_url" + target="_blank" + > + {{ item.import_url }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + <span v-else>{{ item.import_url }}</span> + </template> + <span v-else>{{ + s__('BulkImport|Template / File-based import / GitLab Migration') + }}</span> + </template> + <template #cell(destination)="{ item }"> + <gl-link :href="item.http_url_to_repo"> + {{ item.path_with_namespace }} + </gl-link> + </template> + <template #cell(created_at)="{ value }"> + <time-ago :time="value" /> + </template> + <template #cell(status)="{ item, toggleDetails, detailsShowing }"> + <import-status :status="item.import_status" class="gl-display-inline-block gl-w-13" /> + <gl-button + v-if="item.import_status === 'failed'" + class="gl-ml-3" + :selected="detailsShowing" + @click="toggleDetails" + >{{ __('Details') }}</gl-button + > + </template> + <template #row-details="{ item }"> + <import-error-details :id="item.id" /> + </template> + </gl-table> + <pagination-bar + :page-info="pageInfo" + class="gl-m-0 gl-mt-3" + @set-page="paginationConfig.page = $event" + @set-page-size="setPageSize" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/pages/import/history/index.js b/app/assets/javascripts/pages/import/history/index.js new file mode 100644 index 00000000000..d540272c266 --- /dev/null +++ b/app/assets/javascripts/pages/import/history/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import ImportHistoryApp from './components/import_history_app.vue'; + +function mountImportHistoryApp(mountElement) { + if (!mountElement) return undefined; + + return new Vue({ + el: mountElement, + name: 'ImportHistoryRoot', + provide: { + assets: { + gitlabLogo: mountElement.dataset.logo, + }, + }, + render(createElement) { + return createElement(ImportHistoryApp); + }, + }); +} + +mountImportHistoryApp(document.querySelector('#import-history-mount-element')); diff --git a/app/assets/javascripts/pages/import/history/utils/error_messages.js b/app/assets/javascripts/pages/import/history/utils/error_messages.js new file mode 100644 index 00000000000..24669e22ade --- /dev/null +++ b/app/assets/javascripts/pages/import/history/utils/error_messages.js @@ -0,0 +1,3 @@ +import { __ } from '~/locale'; + +export const DEFAULT_ERROR = __('Something went wrong on our end.'); diff --git a/app/assets/javascripts/pages/profiles/preferences/show/index.js b/app/assets/javascripts/pages/profiles/preferences/show/index.js index d489ed80f46..76939434680 100644 --- a/app/assets/javascripts/pages/profiles/preferences/show/index.js +++ b/app/assets/javascripts/pages/profiles/preferences/show/index.js @@ -1,3 +1,5 @@ import initProfilePreferences from '~/profile/preferences/profile_preferences_bundle'; +import initProfilePreferencesDiffsColors from '~/profile/preferences/profile_preferences_diffs_colors'; -document.addEventListener('DOMContentLoaded', initProfilePreferences); +initProfilePreferences(); +initProfilePreferencesDiffsColors(); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index c6a76df7bde..eca3cf7ab13 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -1,5 +1,6 @@ /* eslint-disable no-new */ import $ from 'jquery'; +import Vue from 'vue'; import loadAwardsHandler from '~/awards_handler'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import Diff from '~/diff'; @@ -14,6 +15,7 @@ import { initCommitBoxInfo } from '~/projects/commit_box/info'; import syntaxHighlight from '~/syntax_highlight'; import ZenMode from '~/zen_mode'; import '~/sourcegraph/load'; +import DiffStats from '~/diffs/components/diff_stats.vue'; const hasPerfBar = document.querySelector('.with-performance-bar'); const performanceHeight = hasPerfBar ? 35 : 0; @@ -25,6 +27,33 @@ initCommitBoxInfo(); initDeprecatedNotes(); +const loadDiffStats = () => { + const diffStatsElements = document.querySelectorAll('#js-diff-stats'); + + if (diffStatsElements.length) { + diffStatsElements.forEach((diffStatsEl) => { + const { addedLines, removedLines, oldSize, newSize, viewerName } = diffStatsEl.dataset; + + new Vue({ + el: diffStatsEl, + render(createElement) { + return createElement(DiffStats, { + props: { + diffFile: { + old_size: oldSize, + new_size: newSize, + viewer: { name: viewerName }, + }, + addedLines: Number(addedLines), + removedLines: Number(removedLines), + }, + }); + }, + }); + }); + } +}; + const filesContainer = $('.js-diffs-batch'); if (filesContainer.length) { @@ -37,12 +66,15 @@ if (filesContainer.length) { syntaxHighlight(filesContainer); handleLocationHash(); new Diff(); + loadDiffStats(); }) .catch(() => { createFlash({ message: __('An error occurred while retrieving diff files') }); }); } else { new Diff(); + loadDiffStats(); } + loadAwardsHandler(); initCommitActions(); diff --git a/app/assets/javascripts/pages/projects/harbor/repositories/index.js b/app/assets/javascripts/pages/projects/harbor/repositories/index.js new file mode 100644 index 00000000000..efbe24ac346 --- /dev/null +++ b/app/assets/javascripts/pages/projects/harbor/repositories/index.js @@ -0,0 +1,8 @@ +import HarborRegistryExplorer from '~/packages_and_registries/harbor_registry/index'; + +const explorer = HarborRegistryExplorer('js-harbor-registry-list-project'); + +if (explorer) { + explorer.attachBreadcrumb(); + explorer.attachMainComponent(); +} diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 8ec6e5e66b3..7380055cbbf 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,5 +1,5 @@ -import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; -import initTerraformNotification from '../../projects/terraform_notification'; +import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; +import initTerraformNotification from '~/projects/terraform_notification'; import { initSidebarTracking } from '../shared/nav/sidebar_tracking'; import Project from './project'; diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue index 67962d69fa5..db9ef4df8af 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue @@ -127,8 +127,12 @@ export default { </p> <gl-progress-bar :value="progressValue" :max="$options.maxValue" /> </div> - <div class="row row-cols-1 row-cols-md-3 gl-mt-5"> - <div v-for="section in $options.actionSections" :key="section" class="col gl-mb-6"> + <div class="row"> + <div + v-for="section in $options.actionSections" + :key="section" + class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4" + > <learn-gitlab-section-card :section="section" :svg="svgFor(section)" diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue index 6a196687a76..e8f0e6c47ee 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue @@ -34,17 +34,23 @@ export default { }; </script> <template> - <gl-card class="gl-pt-0 learn-gitlab-section-card"> - <div class="learn-gitlab-section-card-header"> + <gl-card + class="gl-pt-0 h-100" + header-class="gl-bg-white gl-border-0 gl-pb-0" + body-class="gl-pt-0" + > + <template #header> <img :src="svg" /> <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n[section].title }}</h2> <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n[section].description }}</p> - </div> - <learn-gitlab-section-link - v-for="[action, value] in sortedActions" - :key="action" - :action="action" - :value="value" - /> + </template> + <template #default> + <learn-gitlab-section-link + v-for="[action, value] in sortedActions" + :key="action" + :action="action" + :value="value" + /> + </template> </gl-card> </template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue index 573f996a254..1667f2c3576 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -1,16 +1,25 @@ <script> -import { GlLink, GlIcon } from '@gitlab/ui'; +import { GlLink, GlIcon, GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import { isExperimentVariant } from '~/experimentation/utils'; import eventHub from '~/invite_members/event_hub'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import { ACTION_LABELS } from '../constants'; export default { name: 'LearnGitlabSectionLink', - components: { GlLink, GlIcon }, + components: { + GlLink, + GlIcon, + GlButton, + GitlabExperiment, + }, + directives: { + GlTooltip, + }, i18n: { - ACTION_LABELS, trialOnly: s__('LearnGitlab|Trial only'), + watchHow: __('Watch how'), }, props: { action: { @@ -23,6 +32,9 @@ export default { }, }, computed: { + linkTitle() { + return ACTION_LABELS[this.action].title; + }, trialOnly() { return ACTION_LABELS[this.action].trialRequired; }, @@ -34,6 +46,9 @@ export default { openInNewTab() { return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true; }, + linkToVideoTutorial() { + return ACTION_LABELS[this.action].videoTutorial; + }, }, methods: { openModal() { @@ -44,32 +59,55 @@ export default { </script> <template> <div class="gl-mb-4"> - <span v-if="value.completed" class="gl-text-green-500"> - <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" /> - {{ $options.i18n.ACTION_LABELS[action].title }} - </span> - <gl-link - v-else-if="showInviteModalLink" - data-track-action="click_link" - :data-track-label="$options.i18n.ACTION_LABELS[action].title" - data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding" - data-testid="invite-for-help-continuous-onboarding-experiment-link" - @click="openModal" - > - {{ $options.i18n.ACTION_LABELS[action].title }} - </gl-link> - <gl-link - v-else - :target="openInNewTab ? '_blank' : '_self'" - :href="value.url" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - :data-track-label="$options.i18n.ACTION_LABELS[action].title" - > - {{ $options.i18n.ACTION_LABELS[action].title }} - </gl-link> - <span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> - - {{ $options.i18n.trialOnly }} - </span> + <div v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> + {{ $options.i18n.trialOnly }} + </div> + <div class="flex align-items-center"> + <span v-if="value.completed" class="gl-text-green-500"> + <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" /> + {{ linkTitle }} + </span> + <gl-link + v-else-if="showInviteModalLink" + data-track-action="click_link" + :data-track-label="linkTitle" + data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding" + data-testid="invite-for-help-continuous-onboarding-experiment-link" + @click="openModal" + > + {{ linkTitle }} + </gl-link> + <gl-link + v-else + :target="openInNewTab ? '_blank' : '_self'" + :href="value.url" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + :data-track-label="linkTitle" + > + {{ linkTitle }} + </gl-link> + <gitlab-experiment name="video_tutorials_continuous_onboarding"> + <template #control></template> + <template #candidate> + <gl-button + v-if="linkToVideoTutorial" + v-gl-tooltip + category="tertiary" + icon="live-preview" + :title="$options.i18n.watchHow" + :aria-label="$options.i18n.watchHow" + :href="linkToVideoTutorial" + target="_blank" + class="ml-auto" + data-testid="video-tutorial-link" + data-track-action="click_video_link" + :data-track-label="linkTitle" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" + data-track-experiment="video_tutorials_continuous_onboarding" + /> + </template> + </gitlab-experiment> + </div> </div> </template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js index 1887c48dd1b..9ba5e17237a 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js @@ -40,6 +40,7 @@ export const ACTION_LABELS = { trialRequired: true, section: 'workspace', position: 4, + videoTutorial: 'https://vimeo.com/670896787', }, requiredMrApprovalsEnabled: { title: s__('LearnGitLab|Add merge request approval'), @@ -48,6 +49,7 @@ export const ACTION_LABELS = { trialRequired: true, section: 'workspace', position: 5, + videoTutorial: 'https://vimeo.com/670904904', }, mergeRequestCreated: { title: s__('LearnGitLab|Submit a merge request'), diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js index 63357ea9c72..af4a6f8a0c9 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import LearnGitlab from '../components/learn_gitlab.vue'; @@ -24,5 +25,7 @@ function initLearnGitlab() { }); } -initLearnGitlab(); initInviteMembersModal(); +initInviteMembersTrigger(); + +initLearnGitlab(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index c548ea9bb80..0e0c1475eda 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -1,6 +1,5 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { initPipelineCountListener } from '~/commit/pipelines/utils'; import { initIssuableSidebar } from '~/issuable'; @@ -8,23 +7,16 @@ import StatusBox from '~/issuable/components/status_box.vue'; import createDefaultClient from '~/lib/graphql'; import initSourcegraph from '~/sourcegraph'; import ZenMode from '~/zen_mode'; +import initAwardsApp from '~/emoji/awards_app'; import getStateQuery from './queries/get_state.query.graphql'; export default function initMergeRequestShow() { - const awardEmojiEl = document.getElementById('js-vue-awards-block'); - new ZenMode(); // eslint-disable-line no-new initPipelineCountListener(document.querySelector('#commit-pipeline-table-view')); new ShortcutsIssuable(true); // eslint-disable-line no-new initSourcegraph(); initIssuableSidebar(); - if (awardEmojiEl) { - import('~/emoji/awards_app') - .then((m) => m.default(awardEmojiEl)) - .catch(() => {}); - } else { - loadAwardsHandler(); - } + initAwardsApp(document.getElementById('js-vue-awards-block')); const el = document.querySelector('.js-mr-status-box'); const apolloProvider = new VueApollo({ diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js index 5f2014f1631..b88127384dc 100644 --- a/app/assets/javascripts/pages/projects/network/network.js +++ b/app/assets/javascripts/pages/projects/network/network.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import BranchGraph from '../../../network/branch_graph'; +import BranchGraph from '~/network/branch_graph'; const vph = $(window).height() - 250; diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue index ee70ff858be..37e8a316ee4 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue @@ -2,8 +2,7 @@ import { GlButton } from '@gitlab/ui'; import Vue from 'vue'; import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; - -import Translate from '../../../../../vue_shared/translate'; +import Translate from '~/vue_shared/translate'; Vue.use(Translate); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js index 9c039a6be81..5dae812bbcb 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js @@ -3,9 +3,9 @@ import Vue from 'vue'; import { __ } from '~/locale'; import RefSelector from '~/ref/components/ref_selector.vue'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; -import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list'; -import GlFieldErrors from '../../../../gl_field_errors'; -import Translate from '../../../../vue_shared/translate'; +import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; +import GlFieldErrors from '~/gl_field_errors'; +import Translate from '~/vue_shared/translate'; import intervalPatternInput from './components/interval_pattern_input.vue'; import TimezoneDropdown from './components/timezone_dropdown'; @@ -33,13 +33,7 @@ function initIntervalPatternInput() { } function getEnabledRefTypes() { - const refTypes = [REF_TYPE_BRANCHES]; - - if (gon.features.pipelineSchedulesWithTags) { - refTypes.push(REF_TYPE_TAGS); - } - - return refTypes; + return [REF_TYPE_BRANCHES, REF_TYPE_TAGS]; } function initTargetRefDropdown() { @@ -61,9 +55,7 @@ function initTargetRefDropdown() { value: $refField.value, useSymbolicRefNames: true, translations: { - dropdownHeader: gon.features.pipelineSchedulesWithTags - ? __('Select target branch or tag') - : __('Select target branch'), + dropdownHeader: __('Select target branch or tag'), }, }, class: 'gl-w-full', diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 0c17bf2f344..4f57e1308df 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -9,7 +9,7 @@ import axios from '~/lib/utils/axios_utils'; import { serializeForm } from '~/lib/utils/forms'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import projectSelect from '../../project_select'; +import projectSelect from '~/project_select'; export default class Project { constructor() { diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 2c0394dc12c..bf4fb5f3b7e 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -18,9 +18,16 @@ initInviteGroupTrigger(); const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-project-members-list-app'), { [MEMBER_TYPES.user]: { - tableFields: SHARED_FIELDS.concat(['source', 'granted']), + tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']), tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, - tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], + tableSortableFields: [ + 'account', + 'granted', + 'maxRole', + 'lastSignIn', + 'userCreatedAt', + 'lastActivityOn', + ], requestFormatter: projectMemberRequestFormatter, filteredSearchBar: { show: true, diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index e88dbf20e1b..43ab829f5f9 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -10,36 +10,34 @@ import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_to import initSettingsPanels from '~/settings_panels'; import { initTokenAccess } from '~/token_access'; -document.addEventListener('DOMContentLoaded', () => { - // Initialize expandable settings panels - initSettingsPanels(); +// Initialize expandable settings panels +initSettingsPanels(); - const runnerToken = document.querySelector('.js-secret-runner-token'); - if (runnerToken) { - const runnerTokenSecretValue = new SecretValues({ - container: runnerToken, - }); - runnerTokenSecretValue.init(); - } - - initVariableList(); - - // hide extra auto devops settings based checkbox state - const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings'); - const instanceDefaultBadge = document.querySelector('.js-instance-default-badge'); - document.querySelector('.js-toggle-extra-settings').addEventListener('click', (event) => { - const { target } = event; - if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none'; - autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked); +const runnerToken = document.querySelector('.js-secret-runner-token'); +if (runnerToken) { + const runnerTokenSecretValue = new SecretValues({ + container: runnerToken, }); + runnerTokenSecretValue.init(); +} - registrySettingsApp(); - initDeployFreeze(); +initVariableList(); - initSettingsPipelinesTriggers(); - initArtifactsSettings(); - initSharedRunnersToggle(); - initInstallRunner(); - initRunnerAwsDeployments(); - initTokenAccess(); +// hide extra auto devops settings based checkbox state +const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings'); +const instanceDefaultBadge = document.querySelector('.js-instance-default-badge'); +document.querySelector('.js-toggle-extra-settings').addEventListener('click', (event) => { + const { target } = event; + if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none'; + autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked); }); + +registrySettingsApp(); +initDeployFreeze(); + +initSettingsPipelinesTriggers(); +initArtifactsSettings(); +initSharedRunnersToggle(); +initInstallRunner(); +initRunnerAwsDeployments(); +initTokenAccess(); diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js index e90954c14c5..d45052d76f4 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,9 +1,7 @@ import MirrorRepos from '~/mirrors/mirror_repos'; import initForm from '../form'; -document.addEventListener('DOMContentLoaded', () => { - initForm(); +initForm(); - const mirrorReposContainer = document.querySelector('.js-mirror-settings'); - if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init(); -}); +const mirrorReposContainer = document.querySelector('.js-mirror-settings'); +if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index 9fb8be3fdb9..b2d32c2c943 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -44,6 +44,15 @@ export default { }, }, computed: { + internalValue: { + get() { + return this.value; + }, + set(value) { + this.$emit('change', value); + }, + }, + featureEnabled() { return this.value !== 0; }, @@ -68,10 +77,6 @@ export default { this.$emit('change', firstOptionValue); } }, - - selectOption(e) { - this.$emit('change', Number(e.target.value)); - }, }, }; </script> @@ -93,15 +98,14 @@ export default { /> <div class="select-wrapper gl-flex-grow-1"> <select + v-model="internalValue" :disabled="displaySelectInput" class="form-control project-repo-select select-control" - @change="selectOption" > <option v-for="[optionValue, optionName] in displayOptions" :key="optionValue" :value="optionValue" - :selected="optionValue === value" > {{ optionName }} </option> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 184bda4410f..03bab0fa773 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -9,7 +9,6 @@ import { featureAccessLevelMembers, featureAccessLevelEveryone, featureAccessLevel, - featureAccessLevelNone, CVE_ID_REQUEST_BUTTON_I18N, featureAccessLevelDescriptions, } from '../constants'; @@ -225,8 +224,6 @@ export default { }, operationsFeatureAccessLevelOptions() { - if (!this.operationsEnabled) return [featureAccessLevelNone]; - return this.featureAccessLevelOptions.filter( ([value]) => value <= this.operationsAccessLevel, ); @@ -251,10 +248,6 @@ export default { return options; }, - metricsOptionsDropdownDisabled() { - return this.operationsFeatureAccessLevelOptions.length < 2 || !this.operationsEnabled; - }, - operationsEnabled() { return this.operationsAccessLevel > featureAccessLevel.NOT_ENABLED; }, @@ -392,6 +385,15 @@ export default { else if (oldValue === featureAccessLevel.NOT_ENABLED) toggleHiddenClassBySelector('.merge-requests-feature', false); }, + + operationsAccessLevel(value, oldValue) { + if (value < oldValue) { + // sub-features cannot have more permissive access level + this.metricsDashboardAccessLevel = Math.min(this.metricsDashboardAccessLevel, value); + } else if (oldValue === 0) { + this.metricsDashboardAccessLevel = value; + } + }, }, methods: { @@ -590,7 +592,9 @@ export default { :help-path="packagesHelpPath" :label="$options.i18n.packagesLabel" :help-text=" - s__('ProjectSettings|Every project can have its own space to store its packages.') + s__( + 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.', + ) " > <gl-toggle diff --git a/app/assets/javascripts/pages/projects/snippets/show/index.js b/app/assets/javascripts/pages/projects/snippets/show/index.js index c719601ee0b..77baa6d77a5 100644 --- a/app/assets/javascripts/pages/projects/snippets/show/index.js +++ b/app/assets/javascripts/pages/projects/snippets/show/index.js @@ -1,9 +1,4 @@ import '~/snippet/snippet_show'; +import initAwardsApp from '~/emoji/awards_app'; -const awardEmojiEl = document.getElementById('js-vue-awards-block'); - -if (awardEmojiEl) { - import('~/emoji/awards_app') - .then((m) => m.default(awardEmojiEl)) - .catch(() => {}); -} +initAwardsApp(document.getElementById('js-vue-awards-block')); diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js index b071e7a45fc..9ef1017f9f2 100644 --- a/app/assets/javascripts/pages/projects/tags/new/index.js +++ b/app/assets/javascripts/pages/projects/tags/new/index.js @@ -1,7 +1,7 @@ import $ from 'jquery'; -import GLForm from '../../../../gl_form'; -import RefSelectDropdown from '../../../../ref_select_dropdown'; -import ZenMode from '../../../../zen_mode'; +import GLForm from '~/gl_form'; +import RefSelectDropdown from '~/ref_select_dropdown'; +import ZenMode from '~/zen_mode'; new ZenMode(); // eslint-disable-line no-new new GLForm($('.tag-form')); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index 4bb461aadad..cf7162f477d 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import initTree from 'ee_else_ce/repository'; import initBlob from '~/blob_edit/blob_bundle'; -import ShortcutsNavigation from '../../../../behaviors/shortcuts/shortcuts_navigation'; -import NewCommitForm from '../../../../new_commit_form'; +import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; +import NewCommitForm from '~/new_commit_form'; new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new initBlob(); diff --git a/app/assets/javascripts/pages/projects/wikis/show/index.js b/app/assets/javascripts/pages/projects/wikis/show/index.js index c08a10122b6..7ca5f6964cd 100644 --- a/app/assets/javascripts/pages/projects/wikis/show/index.js +++ b/app/assets/javascripts/pages/projects/wikis/show/index.js @@ -1,3 +1,5 @@ +import { mountApplications } from '~/pages/shared/wikis/show'; import { mountApplications as mountEditApplications } from '~/pages/shared/wikis/async_edit'; +mountApplications(); mountEditApplications(); diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 8c2fd624a83..b62417cf595 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import initVueAlerts from '~/vue_alerts'; -import NoEmojiValidator from '../../../emoji/no_emoji_validator'; +import NoEmojiValidator from '~/emoji/no_emoji_validator'; import LengthValidator from './length_validator'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; diff --git a/app/assets/javascripts/pages/sessions/new/length_validator.js b/app/assets/javascripts/pages/sessions/new/length_validator.js index 17acad10bc1..b2074fb1e39 100644 --- a/app/assets/javascripts/pages/sessions/new/length_validator.js +++ b/app/assets/javascripts/pages/sessions/new/length_validator.js @@ -1,4 +1,4 @@ -import InputValidator from '../../../validators/input_validator'; +import InputValidator from '~/validators/input_validator'; const errorMessageClass = 'gl-field-error'; diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue new file mode 100644 index 00000000000..7c23f60954a --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue @@ -0,0 +1,92 @@ +<script> +import { GlSkeletonLoader, GlSafeHtmlDirective, GlAlert } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import { renderGFM } from '../render_gfm_facade'; + +export default { + components: { + GlSkeletonLoader, + GlAlert, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + getWikiContentUrl: { + type: String, + required: true, + }, + }, + data() { + return { + isLoadingContent: false, + loadingContentFailed: false, + content: null, + }; + }, + mounted() { + this.loadWikiContent(); + }, + methods: { + async loadWikiContent() { + this.loadingContentFailed = false; + this.isLoadingContent = true; + + try { + const { + data: { content }, + } = await axios.get(this.getWikiContentUrl, { params: { render_html: true } }); + this.content = content; + + this.$nextTick() + .then(() => { + renderGFM(this.$refs.content); + }) + .catch(() => + createFlash({ + message: this.$options.i18n.renderingContentFailed, + }), + ); + } catch (e) { + this.loadingContentFailed = true; + } finally { + this.isLoadingContent = false; + } + }, + }, + i18n: { + loadingContentFailed: __( + 'The content for this wiki page failed to load. To fix this error, reload the page.', + ), + retryLoadingContent: __('Retry'), + renderingContentFailed: __('The content for this wiki page failed to render.'), + }, +}; +</script> +<template> + <gl-skeleton-loader v-if="isLoadingContent" :width="830" :height="113"> + <rect width="540" height="16" rx="4" /> + <rect y="49" width="701" height="16" rx="4" /> + <rect y="24" width="830" height="16" rx="4" /> + <rect y="73" width="540" height="16" rx="4" /> + </gl-skeleton-loader> + <gl-alert + v-else-if="loadingContentFailed" + :dismissible="false" + variant="danger" + :primary-button-text="$options.i18n.retryLoadingContent" + @primaryAction="loadWikiContent" + > + {{ $options.i18n.loadingContentFailed }} + </gl-alert> + <div + v-else-if="!loadingContentFailed && !isLoadingContent" + ref="content" + data-qa-selector="wiki_page_content" + data-testid="wiki_page_content" + class="js-wiki-page-content md" + v-html="content /* eslint-disable-line vue/no-v-html */" + ></div> +</template> diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 8ef31b9b983..024b3bc9595 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -1,21 +1,11 @@ <script> -import { - GlForm, - GlIcon, - GlLink, - GlButton, - GlSprintf, - GlAlert, - GlModal, - GlModalDirective, -} from '@gitlab/ui'; +import { GlForm, GlIcon, GlLink, GlButton, GlSprintf, GlAlert } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { setUrlFragment } from '~/lib/utils/url_utility'; -import { __, s__, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { CONTENT_EDITOR_LOADED_ACTION, SAVED_USING_CONTENT_EDITOR_ACTION, @@ -64,31 +54,6 @@ export default { ), primaryAction: s__('WikiPage|Retry'), }, - useNewEditor: { - primaryLabel: s__('WikiPage|Use the new editor'), - secondaryLabel: s__('WikiPage|Try this later'), - title: s__('WikiPage|Get a richer editing experience'), - text: s__( - "WikiPage|Try the new visual Markdown editor. Read the %{linkStart}documentation%{linkEnd} to learn what's currently supported.", - ), - }, - switchToOldEditor: { - label: s__('WikiPage|Switch me back to the classic editor.'), - helpText: s__( - "WikiPage|This editor is in beta and may not display the page's contents properly. Switching back to the classic editor will discard changes you've made in the new editor.", - ), - modal: { - title: s__('WikiPage|Are you sure you want to switch back to the classic editor?'), - primary: s__('WikiPage|Switch to classic editor'), - cancel: s__('WikiPage|Keep editing'), - text: s__( - "WikiPage|Switching to the classic editor will discard any changes you've made in the new editor.", - ), - }, - }, - feedbackTip: __( - 'Tell us your experiences with the new Markdown editor %{linkStart}in this feedback issue%{linkEnd}.', - ), }, linksHelpText: s__( 'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.', @@ -108,7 +73,6 @@ export default { editSourceButtonText: s__('WikiPage|Edit source'), editRichTextButtonText: s__('WikiPage|Edit rich text'), }, - contentEditorFeedbackIssue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332629', components: { GlAlert, GlForm, @@ -116,24 +80,19 @@ export default { GlIcon, GlLink, GlButton, - GlModal, MarkdownField, ContentEditor: () => import( /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue' ), }, - directives: { - GlModalDirective, - }, - mixins: [trackingMixin, glFeatureFlagMixin()], + mixins: [trackingMixin], inject: ['formatOptions', 'pageInfo'], data() { return { title: this.pageInfo.title?.trim() || '', format: this.pageInfo.format || 'markdown', content: this.pageInfo.content || '', - isContentEditorAlertDismissed: false, useContentEditor: false, commitMessage: '', isDirty: false, @@ -194,25 +153,9 @@ export default { isMarkdownFormat() { return this.format === 'markdown'; }, - showContentEditorAlert() { - return ( - !this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && - this.isMarkdownFormat && - !this.useContentEditor && - !this.isContentEditorAlertDismissed - ); - }, - showSwitchEditingModeButton() { - return this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isMarkdownFormat; - }, displayWikiSpecificMarkdownHelp() { return !this.isContentEditorActive; }, - displaySwitchBackToClassicEditorMessage() { - return ( - !this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isContentEditorActive - ); - }, disableSubmitButton() { return this.noContent || !this.title || this.contentEditorRenderFailed; }, @@ -312,23 +255,6 @@ export default { this.commitMessage = newCommitMessage; }, - initContentEditor() { - this.useContentEditor = true; - }, - - switchToOldEditor() { - this.useContentEditor = false; - }, - - confirmSwitchToOldEditor() { - if (this.contentEditorRenderFailed) { - this.contentEditorRenderFailed = false; - this.switchToOldEditor(); - } else { - this.$refs.confirmSwitchToOldEditorModal.show(); - } - }, - trackContentEditorLoaded() { this.track(CONTENT_EDITOR_LOADED_ACTION); }, @@ -349,10 +275,6 @@ export default { }, }); }, - - dismissContentEditorAlert() { - this.isContentEditorAlertDismissed = true; - }, }, }; </script> @@ -438,10 +360,7 @@ export default { }}</label> </div> <div class="col-sm-10"> - <div - v-if="showSwitchEditingModeButton" - class="gl-display-flex gl-justify-content-end gl-mb-3" - > + <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-end gl-mb-3"> <gl-button data-testid="toggle-editing-mode-button" data-qa-selector="editing_mode_button" @@ -451,42 +370,6 @@ export default { >{{ toggleEditingModeButtonText }}</gl-button > </div> - <gl-alert - v-if="showContentEditorAlert" - class="gl-mb-6" - variant="info" - data-qa-selector="try_new_editor_container" - :primary-button-text="$options.i18n.contentEditor.useNewEditor.primaryLabel" - :secondary-button-text="$options.i18n.contentEditor.useNewEditor.secondaryLabel" - :dismiss-label="$options.i18n.contentEditor.useNewEditor.secondaryLabel" - :title="$options.i18n.contentEditor.useNewEditor.title" - @primaryAction="initContentEditor" - @secondaryAction="dismissContentEditorAlert" - @dismiss="dismissContentEditorAlert" - > - <gl-sprintf :message="$options.i18n.contentEditor.useNewEditor.text"> - <template - #link="// eslint-disable-next-line vue/no-template-shadow - { content }" - ><gl-link - :href="contentEditorHelpPath" - target="_blank" - data-testid="content-editor-help-link" - >{{ content }}</gl-link - ></template - > - </gl-sprintf> - </gl-alert> - <gl-modal - ref="confirmSwitchToOldEditorModal" - modal-id="confirm-switch-to-old-editor" - :title="$options.i18n.contentEditor.switchToOldEditor.modal.title" - :action-primary="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.primary }" - :action-cancel="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.cancel }" - @primary="switchToOldEditor" - > - {{ $options.i18n.contentEditor.switchToOldEditor.modal.text }} - </gl-modal> <markdown-field v-if="!isContentEditorActive" :markdown-preview-path="pageInfo.markdownPreviewPath" @@ -516,22 +399,7 @@ export default { </textarea> </template> </markdown-field> - <div v-if="isContentEditorActive"> - <gl-alert class="gl-mb-6" variant="tip" :dismissible="false"> - <gl-sprintf :message="$options.i18n.contentEditor.feedbackTip"> - <template - #link="// eslint-disable-next-line vue/no-template-shadow - { content }" - ><gl-link - :href="$options.contentEditorFeedbackIssue" - target="_blank" - data-testid="wiki-markdown-help-link" - >{{ content }}</gl-link - ></template - > - </gl-sprintf> - </gl-alert> <content-editor :render-markdown="renderMarkdown" :uploads-path="pageInfo.uploadsPath" @@ -560,12 +428,6 @@ export default { ></template > </gl-sprintf> - <span v-if="displaySwitchBackToClassicEditorMessage"> - {{ $options.i18n.contentEditor.switchToOldEditor.helpText }} - <gl-button variant="link" @click="confirmSwitchToOldEditor">{{ - $options.i18n.contentEditor.switchToOldEditor.label - }}</gl-button> - </span> </div> </div> </div> diff --git a/app/assets/javascripts/pages/shared/wikis/edit.js b/app/assets/javascripts/pages/shared/wikis/edit.js index beeabfde1a6..02878633916 100644 --- a/app/assets/javascripts/pages/shared/wikis/edit.js +++ b/app/assets/javascripts/pages/shared/wikis/edit.js @@ -3,8 +3,8 @@ import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import csrf from '~/lib/utils/csrf'; import Translate from '~/vue_shared/translate'; -import GLForm from '../../../gl_form'; -import ZenMode from '../../../zen_mode'; +import GLForm from '~/gl_form'; +import ZenMode from '~/zen_mode'; import deleteWikiModal from './components/delete_wiki_modal.vue'; import wikiAlert from './components/wiki_alert.vue'; import wikiForm from './components/wiki_form.vue'; diff --git a/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js b/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js new file mode 100644 index 00000000000..90cc2983153 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js @@ -0,0 +1,5 @@ +import $ from 'jquery'; + +export const renderGFM = (el) => { + return $(el).renderGFM(); +}; diff --git a/app/assets/javascripts/pages/shared/wikis/show.js b/app/assets/javascripts/pages/shared/wikis/show.js new file mode 100644 index 00000000000..9906cb595f8 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/show.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import Wikis from './wikis'; +import WikiContent from './components/wiki_content.vue'; + +const mountWikiContentApp = () => { + const el = document.querySelector('.js-async-wiki-page-content'); + + if (el) { + const { getWikiContentUrl } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + render(createElement) { + return createElement(WikiContent, { + props: { getWikiContentUrl }, + }); + }, + }); + } +}; + +export const mountApplications = () => { + // eslint-disable-next-line no-new + new Wikis(); + mountWikiContentApp(); +}; diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js index a614342c858..4c0293f5b78 100644 --- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js +++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js @@ -1,5 +1,5 @@ import { parseBoolean } from '~/lib/utils/common_utils'; -import axios from '../../lib/utils/axios_utils'; +import axios from '~/lib/utils/axios_utils'; export default class PerformanceBarService { static interceptor = null; diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index f6de21ec0c5..dee832c01d5 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -12,6 +12,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-security-newsletter-callout', '.js-approaching-seats-count-threshold', '.js-storage-enforcement-banner', + '.js-user-over-limit-free-plan-alert', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue index a8ad56ab6a5..897bd2dcccf 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue @@ -1,5 +1,5 @@ <script> -import { GlCard, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; export default { @@ -22,7 +22,6 @@ export default { ), }, components: { - GlCard, GlLink, GlSprintf, }, @@ -30,22 +29,20 @@ export default { }; </script> <template> - <gl-card> - <template #default> - <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> - <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> - <ol class="gl-mb-3"> - <li v-for="(item, i) in $options.i18n.listItems" :key="`li-${i}`">{{ item }}</li> - </ol> - <p class="gl-mb-0"> - <gl-sprintf :message="$options.i18n.note"> - <template #link="{ content }"> - <gl-link :href="runnerHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </template> - </gl-card> + <div> + <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <ol class="gl-mb-3"> + <li v-for="(item, i) in $options.i18n.listItems" :key="`li-${i}`">{{ item }}</li> + </ol> + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.note"> + <template #link="{ content }"> + <gl-link :href="runnerHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue index 3da535f5f94..d2682cf6326 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue @@ -1,5 +1,5 @@ <script> -import { GlCard, GlSprintf } from '@gitlab/ui'; +import { GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; export default { @@ -13,23 +13,20 @@ export default { ), }, components: { - GlCard, GlSprintf, }, }; </script> <template> - <gl-card> - <template #default> - <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> - <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> - <p class="gl-mb-0"> - <gl-sprintf :message="$options.i18n.secondParagraph"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - </template> - </gl-card> + <div> + <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.secondParagraph"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue index f714f6411f1..04140434af2 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue @@ -1,5 +1,5 @@ <script> -import { GlCard, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; export default { @@ -20,7 +20,6 @@ export default { ), }, components: { - GlCard, GlLink, GlSprintf, }, @@ -28,48 +27,46 @@ export default { }; </script> <template> - <gl-card> - <template #default> - <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> - <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> - <ul> - <li> - <gl-sprintf :message="$options.i18n.browseExamples"> - <template #link="{ content }"> - <gl-link :href="ciExamplesHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </li> - <li> - <gl-sprintf :message="$options.i18n.viewSyntaxRef"> - <template #link="{ content }"> - <gl-link :href="ymlHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </li> - <li> - <gl-sprintf :message="$options.i18n.learnMore"> - <template #link="{ content }"> - <gl-link :href="ciHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </li> - <li> - <gl-sprintf :message="$options.i18n.needs"> - <template #link="{ content }"> - <gl-link :href="needsHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </li> - </ul> - </template> - </gl-card> + <div> + <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <ul> + <li> + <gl-sprintf :message="$options.i18n.browseExamples"> + <template #link="{ content }"> + <gl-link :href="ciExamplesHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.viewSyntaxRef"> + <template #link="{ content }"> + <gl-link :href="ymlHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.learnMore"> + <template #link="{ content }"> + <gl-link :href="ciHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.needs"> + <template #link="{ content }"> + <gl-link :href="needsHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + </ul> + </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue index 512414f0246..aeeb52319d2 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue @@ -1,5 +1,4 @@ <script> -import { GlCard } from '@gitlab/ui'; import { s__ } from '~/locale'; export default { @@ -9,16 +8,11 @@ export default { 'PipelineEditorTutorial|Use the Visualize and Lint tabs in the Pipeline Editor to visualize your pipeline and check for any errors or warnings before committing your changes.', ), }, - components: { - GlCard, - }, }; </script> <template> - <gl-card> - <template #default> - <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> - <p class="gl-mb-0">{{ $options.i18n.firstParagraph }}</p> - </template> - </gl-card> + <div> + <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3> + <p class="gl-mb-0">{{ $options.i18n.firstParagraph }}</p> + </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index 9cb070a5517..375db7f3054 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -1,101 +1,61 @@ <script> -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlDrawer } from '@gitlab/ui'; import { __ } from '~/locale'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { DRAWER_EXPANDED_KEY } from '../../constants'; import FirstPipelineCard from './cards/first_pipeline_card.vue'; import GettingStartedCard from './cards/getting_started_card.vue'; import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue'; import VisualizeAndLintCard from './cards/visualize_and_lint_card.vue'; +const DRAWER_CARD_STYLES = ['gl-border-bottom-0', 'gl-pt-6!', 'gl-pb-0!', 'gl-line-height-20']; + export default { - width: { - expanded: '482px', - collapsed: '58px', - }, + DRAWER_CARD_STYLES, i18n: { - toggleTxt: __('Collapse'), + title: __('Help'), }, - localDrawerKey: DRAWER_EXPANDED_KEY, components: { FirstPipelineCard, GettingStartedCard, - GlButton, - GlIcon, - LocalStorageSync, + GlDrawer, PipelineConfigReferenceCard, VisualizeAndLintCard, }, - data() { - return { - isExpanded: false, - topPosition: 0, - }; + props: { + isVisible: { + type: Boolean, + required: false, + default: false, + }, }, computed: { - buttonIconName() { - return this.isExpanded ? 'chevron-double-lg-right' : 'chevron-double-lg-left'; - }, - buttonClass() { - return this.isExpanded ? 'gl-justify-content-end!' : ''; + drawerCardStyles() { + return ''; }, - rootStyle() { - const { expanded, collapsed } = this.$options.width; - const top = this.topPosition; - const style = { top: `${top}px` }; - - return this.isExpanded ? { ...style, width: expanded } : { ...style, width: collapsed }; + drawerHeightOffset() { + const wrapperEl = document.querySelector('.content-wrapper'); + return wrapperEl ? `${wrapperEl.offsetTop}px` : ''; }, }, - mounted() { - this.setTopPosition(); - }, methods: { - setTopPosition() { - const navbarEl = document.querySelector('.js-navbar'); - - if (navbarEl) { - this.topPosition = navbarEl.getBoundingClientRect().bottom; - } - }, - toggleDrawer() { - this.isExpanded = !this.isExpanded; + closeDrawer() { + this.$emit('close-drawer'); }, }, }; </script> <template> - <local-storage-sync v-model="isExpanded" :storage-key="$options.localDrawerKey" as-json> - <aside - aria-live="polite" - class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-property-width gl-transition-duration-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-200 gl-overflow-y-auto" - :style="rootStyle" - > - <gl-button - category="tertiary" - class="gl-w-full gl-h-9 gl-rounded-0! gl-border-none! gl-border-b-solid! gl-border-1! gl-border-gray-100 gl-text-decoration-none! gl-outline-0! gl-display-flex" - :class="buttonClass" - :title="__('Toggle sidebar')" - data-qa-selector="toggle_sidebar_collapse_button" - @click="toggleDrawer" - > - <span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text"> - {{ __('Collapse') }} - </span> - <gl-icon data-testid="toggle-icon" :name="buttonIconName" /> - </gl-button> - <div - v-if="isExpanded" - class="gl-h-full gl-p-5" - data-testid="drawer-content" - data-qa-selector="drawer_content" - > - <getting-started-card class="gl-mb-4" /> - <first-pipeline-card class="gl-mb-4" /> - <visualize-and-lint-card class="gl-mb-4" /> - <pipeline-config-reference-card /> - <div class="gl-h-13"></div> - </div> - </aside> - </local-storage-sync> + <gl-drawer + :header-height="drawerHeightOffset" + :open="isVisible" + :z-index="200" + @close="closeDrawer" + > + <template #title> + <h2 class="gl-m-0 gl-font-lg">{{ $options.i18n.title }}</h2> + </template> + <getting-started-card :class="$options.DRAWER_CARD_STYLES" /> + <first-pipeline-card :class="$options.DRAWER_CARD_STYLES" /> + <visualize-and-lint-card :class="$options.DRAWER_CARD_STYLES" /> + <pipeline-config-reference-card :class="$options.DRAWER_CARD_STYLES" /> + </gl-drawer> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue index b4e9ab81d38..9765d669fc1 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue @@ -7,13 +7,23 @@ import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../co export default { i18n: { browseTemplates: __('Browse templates'), + help: __('Help'), }, TEMPLATE_REPOSITORY_URL, components: { GlButton, }, mixins: [Tracking.mixin()], + props: { + showDrawer: { + type: Boolean, + required: true, + }, + }, methods: { + toggleDrawer() { + this.$emit(this.showDrawer ? 'close-drawer' : 'open-drawer'); + }, trackTemplateBrowsing() { const { label, actions } = pipelineEditorTrackingOptions; @@ -30,9 +40,20 @@ export default { size="small" icon="external-link" target="_blank" + data-testid="template-repo-link" + data-qa-selector="template_repo_link" @click="trackTemplateBrowsing" > {{ $options.i18n.browseTemplates }} </gl-button> + <gl-button + icon="information-o" + size="small" + data-testid="drawer-toggle" + data-qa-selector="drawer_toggle" + @click="toggleDrawer" + > + {{ $options.i18n.help }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 5cff93c884f..d50e6f9a623 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -86,6 +86,10 @@ export default { type: Boolean, required: true, }, + showDrawer: { + type: Boolean, + required: true, + }, }, apollo: { appStatus: { @@ -157,7 +161,7 @@ export default { @click="setCurrentTab($options.tabConstants.CREATE_TAB)" > <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> - <ci-editor-header /> + <ci-editor-header :show-drawer="showDrawer" v-on="$listeners" /> <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" /> </editor-tab> <editor-tab diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue index aee71999373..3e87088e77e 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -14,7 +14,7 @@ export default { body: __( 'Create a new %{codeStart}.gitlab-ci.yml%{codeEnd} file at the root of the repository to get started.', ), - btnText: __('Create new CI/CD pipeline'), + btnText: __('Configure pipeline'), }, inject: { emptyStateIllustrationPath: { diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 2ebc4306405..9b4732b26d2 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -45,8 +45,6 @@ export const TAB_QUERY_PARAM = 'tab'; export const COMMIT_ACTION_CREATE = 'CREATE'; export const COMMIT_ACTION_UPDATE = 'UPDATE'; -export const DRAWER_EXPANDED_KEY = 'pipeline_editor_drawer_expanded'; - export const BRANCH_PAGINATION_LIMIT = 20; export const BRANCH_SEARCH_DEBOUNCE = '500'; export const SOURCE_EDITOR_DEBOUNCE = 500; @@ -102,7 +100,5 @@ export const I18N = { subtitle: s__( "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.", ), - description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'), - cta: s__('Pipelines|Use template'), }, }; diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index a5436ca63cb..4e6a4ffa6d2 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -388,7 +388,7 @@ export default { @createEmptyConfigFile="setNewEmptyCiConfigFile" @refetchContent="refetchContent" /> - <div v-else class="gl-pr-10"> + <div v-else> <pipeline-editor-messages :failure-type="failureType" :failure-reasons="failureReasons" diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index 631dd8a2c00..23e3ce10d5a 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -60,6 +60,7 @@ export default { currentTab: CREATE_TAB, scrollToCommitForm: false, shouldLoadNewBranch: false, + showDrawer: false, showSwitchBranchModal: false, }; }, @@ -72,9 +73,15 @@ export default { closeBranchModal() { this.showSwitchBranchModal = false; }, + closeDrawer() { + this.showDrawer = false; + }, handleConfirmSwitchBranch() { this.showSwitchBranchModal = true; }, + openDrawer() { + this.showDrawer = true; + }, switchBranch() { this.showSwitchBranchModal = false; this.shouldLoadNewBranch = true; @@ -122,7 +129,10 @@ export default { :ci-file-content="ciFileContent" :commit-sha="commitSha" :is-new-ci-config-file="isNewCiConfigFile" + :show-drawer="showDrawer" v-on="$listeners" + @open-drawer="openDrawer" + @close-drawer="closeDrawer" @set-current-tab="setCurrentTab" @walkthrough-popover-cta-clicked="setScrollToCommitForm" /> @@ -137,6 +147,10 @@ export default { @scrolled-to-commit-form="setScrollToCommitForm(false)" v-on="$listeners" /> - <pipeline-editor-drawer /> + <pipeline-editor-drawer + :is-visible="showDrawer" + v-on="$listeners" + @close-drawer="closeDrawer" + /> </div> </template> diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index d74b6e8edf6..32e1e18b684 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -396,6 +396,7 @@ export default { :key="variable.uniqueId" class="gl-mb-3 gl-ml-n3 gl-pb-2" data-testid="ci-variable-row" + data-qa-selector="ci_variable_row_container" > <div class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" @@ -411,6 +412,7 @@ export default { :placeholder="s__('CiVariables|Input variable key')" :class="$options.formElementClasses" data-testid="pipeline-form-ci-variable-key" + data-qa-selector="ci_variable_key_field" @change="addEmptyVariable(refFullName)" /> <gl-form-textarea @@ -420,6 +422,7 @@ export default { :style="$options.textAreaStyle" :no-resize="false" data-testid="pipeline-form-ci-variable-value" + data-qa-selector="ci_variable_value_field" /> <template v-if="variables.length > 1"> diff --git a/app/assets/javascripts/pipeline_wizard/components/input.vue b/app/assets/javascripts/pipeline_wizard/components/input.vue index 9a0c8026648..5efae2471e5 100644 --- a/app/assets/javascripts/pipeline_wizard/components/input.vue +++ b/app/assets/javascripts/pipeline_wizard/components/input.vue @@ -92,6 +92,7 @@ export default { ref="widget" :validate="validate" v-bind="$attrs" + :data-input-target="target" @input="onModelChange" @update:valid="onValidationStateChange" /> diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue index b7207576ddc..f50cd175510 100644 --- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue +++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue @@ -1,6 +1,7 @@ <script> import { GlProgressBar } from '@gitlab/ui'; import { Document } from 'yaml'; +import { uniqueId } from 'lodash'; import { merge } from '~/lib/utils/yaml'; import { __ } from '~/locale'; import { isValidStepSeq } from '~/pipeline_wizard/validators'; @@ -57,15 +58,6 @@ export default { }; }, computed: { - currentStepConfig() { - return this.steps.get(this.currentStepIndex); - }, - currentStepInputs() { - return this.currentStepConfig.get('inputs').toJSON(); - }, - currentStepTemplate() { - return this.currentStepConfig.get('template', true); - }, currentStep() { return this.currentStepIndex + 1; }, @@ -78,6 +70,13 @@ export default { isLastStep() { return this.currentStep === this.stepCount; }, + stepList() { + return this.steps.items.map((_, i) => ({ + id: uniqueId(), + inputs: this.steps.get(i).get('inputs').toJSON(), + template: this.steps.get(i).get('template', true), + })); + }, }, watch: { isLastStep(value) { @@ -85,6 +84,9 @@ export default { }, }, methods: { + getStep(index) { + return this.steps.get(index); + }, resetHighlight() { this.highlightPath = null; }, @@ -119,8 +121,8 @@ export default { </header> <section class="gl-mb-4"> <commit-step - v-if="isLastStep" - ref="step" + v-show="isLastStep" + data-testid="step" :default-branch="defaultBranch" :file-content="pipelineBlob" :filename="filename" @@ -128,15 +130,16 @@ export default { @back="currentStepIndex--" /> <wizard-step - v-else - :key="currentStepIndex" - ref="step" + v-for="(step, i) in stepList" + v-show="i === currentStepIndex" + :key="step.id" + data-testid="step" :compiled.sync="compiled" - :has-next-step="currentStepIndex < steps.items.length" - :has-previous-step="currentStepIndex > 0" + :has-next-step="i < steps.items.length" + :has-previous-step="i > 0" :highlight.sync="highlightPath" - :inputs="currentStepInputs" - :template="currentStepTemplate" + :inputs="step.inputs" + :template="step.template" @back="currentStepIndex--" @next="currentStepIndex++" @update:compiled="onUpdate" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index e995d400907..534ad25a35d 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -273,6 +273,7 @@ export default { <local-storage-sync :storage-key="$options.viewTypeKey" :value="currentViewType" + as-string @input="updateViewType" > <graph-view-selector diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 795b95421c7..f69b25dfa7c 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -234,8 +234,9 @@ export default { :title="tooltipText" :class="jobClasses" :href="detailsPath" - class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full" + class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full" :data-testid="testId" + data-qa-selector="job_link" @click="jobItemClick" @mouseout="hideTooltips" > diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index c59f56fc68f..d59802196af 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -133,7 +133,7 @@ export default { class="gl-h-full gl-display-flex! gl-border-solid gl-border-gray-100 gl-border-1" :class="flexDirection" :title="tooltipText" - data-qa-selector="child_pipeline" + data-qa-selector="linked_pipeline_container" @mouseover="onDownstreamHovered" @mouseleave="onDownstreamHoverLeave" > @@ -171,7 +171,7 @@ export default { :icon="expandedIcon" :aria-label="__('Expand pipeline')" data-testid="expand-pipeline-button" - data-qa-selector="expand_pipeline_button" + data-qa-selector="expand_linked_pipeline_button" @click="onClickLinkedPipeline" /> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index b0f375c9aeb..6ab4eb58977 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -141,7 +141,9 @@ export default { class="gl-display-flex gl-justify-content-space-between gl-relative" :class="$options.titleClasses" > - <div>{{ formattedTitle }}</div> + <span :title="formattedTitle" class="gl-text-truncate gl-pr-3 gl-w-85p"> + {{ formattedTitle }} + </span> <action-component v-if="hasAction && canUpdatePipeline" :action-icon="action.icon" diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index ac97c9d2743..04b78b8aa23 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -249,8 +249,7 @@ export default { :title="$options.BUTTON_TOOLTIP_RETRY" :loading="isRetrying" :disabled="isRetrying" - category="secondary" - variant="info" + variant="confirm" data-testid="retryPipeline" class="js-retry-button" @click="retryPipeline()" @@ -262,7 +261,6 @@ export default { v-if="canCancelPipeline" :loading="isCanceling" :disabled="isCanceling" - class="gl-ml-3" variant="danger" data-testid="cancelPipeline" @click="cancelPipeline()" diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue index fffd8e1818a..70d1a5c08cc 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue @@ -1,5 +1,5 @@ <script> -import ciIcon from '../../../vue_shared/components/ci_icon.vue'; +import ciIcon from '~/vue_shared/components/ci_icon.vue'; /** * Component that renders both the CI icon status and the job name. diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue new file mode 100644 index 00000000000..62c785d7ad2 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue @@ -0,0 +1,50 @@ +<script> +import { GlTabs, GlTab } from '@gitlab/ui'; +import { __ } from '~/locale'; +import PipelineGraphWrapper from './graph/graph_component_wrapper.vue'; +import Dag from './dag/dag.vue'; +import JobsApp from './jobs/jobs_app.vue'; +import TestReports from './test_reports/test_reports.vue'; + +export default { + i18n: { + tabs: { + failedJobsTitle: __('Failed Jobs'), + jobsTitle: __('Jobs'), + needsTitle: __('Needs'), + pipelineTitle: __('Pipeline'), + testsTitle: __('Tests'), + }, + }, + components: { + Dag, + GlTab, + GlTabs, + JobsApp, + FailedJobsApp: JobsApp, + PipelineGraphWrapper, + TestReports, + }, +}; +</script> + +<template> + <gl-tabs> + <gl-tab :title="$options.i18n.tabs.pipelineTitle" data-testid="pipeline-tab"> + <pipeline-graph-wrapper /> + </gl-tab> + <gl-tab :title="$options.i18n.tabs.needsTitle" data-testid="dag-tab"> + <dag /> + </gl-tab> + <gl-tab :title="$options.i18n.tabs.jobsTitle" data-testid="jobs-tab"> + <jobs-app /> + </gl-tab> + <gl-tab :title="$options.i18n.tabs.failedJobsTitle" data-testid="failed-jobs-tab"> + <failed-jobs-app /> + </gl-tab> + <gl-tab :title="$options.i18n.tabs.testsTitle" data-testid="tests-tab"> + <test-reports /> + </gl-tab> + <slot></slot> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index 0380ba646cc..5a9c85a0f10 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,7 +1,7 @@ <script> import { GlEmptyState } from '@gitlab/ui'; import { s__ } from '~/locale'; -import PipelinesCiTemplates from './pipelines_ci_templates.vue'; +import PipelinesCiTemplates from './empty_state/pipelines_ci_templates.vue'; export default { i18n: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue new file mode 100644 index 00000000000..3b312e78d11 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue @@ -0,0 +1,81 @@ +<script> +import { GlAvatar, GlButton } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; +import Tracking from '~/tracking'; + +export default { + components: { + GlAvatar, + GlButton, + }, + mixins: [Tracking.mixin()], + inject: ['pipelineEditorPath', 'suggestedCiTemplates'], + data() { + const templates = this.suggestedCiTemplates.map(({ name, logo }) => { + return { + name, + logo, + link: mergeUrlParams({ template: name }, this.pipelineEditorPath), + description: sprintf(this.$options.i18n.description, { name }), + }; + }); + + return { + templates, + }; + }, + methods: { + trackEvent(template) { + this.track('template_clicked', { + label: template, + }); + }, + }, + i18n: { + description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'), + cta: s__('Pipelines|Use template'), + }, + AVATAR_SHAPE_OPTION_RECT, +}; +</script> +<template> + <ul class="gl-list-style-none gl-pl-0"> + <li v-for="template in templates" :key="template.name"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3" + > + <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> + <gl-avatar + :src="template.logo" + :size="48" + class="gl-mr-5 gl-bg-white dark-mode-override" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :alt="template.name" + data-testid="template-logo" + /> + <div class="gl-flex-direction-row"> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800" data-testid="template-name"> + {{ template.name }} + </strong> + </div> + <p class="gl-mb-0 gl-font-sm" data-testid="template-description"> + {{ template.description }} + </p> + </div> + </div> + <gl-button + category="primary" + variant="confirm" + :href="template.link" + data-testid="template-link" + @click="trackEvent(template.name)" + > + {{ $options.i18n.cta }} + </gl-button> + </div> + </li> + </ul> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue index d50229e47c4..be46a7f5cec 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue @@ -1,7 +1,6 @@ <script> -import { GlAvatar, GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui'; +import { GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { sprintf } from '~/locale'; import { STARTER_TEMPLATE_NAME, RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, @@ -10,21 +9,22 @@ import { RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, I18N, } from '~/pipeline_editor/constants'; +import Tracking from '~/tracking'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { isExperimentVariant } from '~/experimentation/utils'; import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import ExperimentTracking from '~/experimentation/experiment_tracking'; -import { isExperimentVariant } from '~/experimentation/utils'; -import Tracking from '~/tracking'; +import CiTemplates from './ci_templates.vue'; export default { components: { - GlAvatar, GlButton, GlCard, GlSprintf, GlIcon, GlLink, GitlabExperiment, + CiTemplates, }, mixins: [Tracking.mixin()], STARTER_TEMPLATE_NAME, @@ -33,7 +33,7 @@ export default { RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, I18N, - inject: ['pipelineEditorPath', 'suggestedCiTemplates'], + inject: ['pipelineEditorPath'], props: { ciRunnerSettingsPath: { type: String, @@ -47,17 +47,7 @@ export default { }, }, data() { - const templates = this.suggestedCiTemplates.map(({ name, logo }) => { - return { - name, - logo, - link: mergeUrlParams({ template: name }, this.pipelineEditorPath), - description: sprintf(this.$options.I18N.templates.description, { name }), - }; - }); - return { - templates, gettingStartedTemplateUrl: mergeUrlParams( { template: STARTER_TEMPLATE_NAME }, this.pipelineEditorPath, @@ -177,43 +167,7 @@ export default { <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2> <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p> - <ul class="gl-list-style-none gl-pl-0"> - <li v-for="template in templates" :key="template.name"> - <div - class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3" - > - <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> - <gl-avatar - :src="template.logo" - :size="48" - class="gl-mr-5 gl-bg-white dark-mode-override" - shape="rect" - :alt="template.name" - data-testid="template-logo" - /> - <div class="gl-flex-direction-row"> - <div class="gl-mb-3"> - <strong class="gl-text-gray-800" data-testid="template-name"> - {{ template.name }} - </strong> - </div> - <p class="gl-mb-0 gl-font-sm" data-testid="template-description"> - {{ template.description }} - </p> - </div> - </div> - <gl-button - category="primary" - variant="confirm" - :href="template.link" - data-testid="template-link" - @click="trackEvent(template.name)" - > - {{ $options.I18N.templates.cta }} - </gl-button> - </div> - </li> - </ul> + <ci-templates /> </template> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue index b29c8426301..2a73795db0a 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue @@ -1,10 +1,14 @@ <script> -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { GlAvatarLink, GlAvatar, GlTooltipDirective } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { - UserAvatarLink, + GlAvatarLink, + GlAvatar, + }, + directives: { + GlTooltip: GlTooltipDirective, }, mixins: [glFeatureFlagMixin()], props: { @@ -22,15 +26,11 @@ export default { </script> <template> <div class="pipeline-triggerer" data-testid="pipeline-triggerer"> - <user-avatar-link - v-if="user" - :link-href="user.path" - :img-src="user.avatar_url" - :img-size="32" - :tooltip-text="user.name" - class="gl-ml-3 js-pipeline-url-user" - /> - <span v-else class="gl-ml-3 js-pipeline-url-api api"> + <gl-avatar-link v-if="user" v-gl-tooltip :href="user.path" :title="user.name" class="gl-ml-3"> + <gl-avatar :size="32" :src="user.avatar_url" /> + </gl-avatar-link> + + <span v-else class="gl-ml-3"> {{ s__('Pipelines|API') }} </span> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index 1dcbd77a92d..63c492c8bcd 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -2,6 +2,7 @@ import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { ICONS } from '../../constants'; import PipelineLabels from './pipeline_labels.vue'; @@ -11,6 +12,7 @@ export default { GlLink, PipelineLabels, TooltipOnTruncate, + UserAvatarLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -169,6 +171,15 @@ export default { <gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{ commitShortSha }}</gl-link> + <user-avatar-link + v-if="commitAuthor" + :link-href="commitAuthor.path" + :img-src="commitAuthor.avatar_url" + :img-size="16" + :img-alt="commitAuthor.name" + :tooltip-text="commitAuthor.name" + class="gl-ml-1" + /> <!--End of commit row--> </div> <pipeline-labels :pipeline-schedule-url="pipelineScheduleUrl" :pipeline="pipeline" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 6f0e67e1ae0..77b9c2b5203 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -127,6 +127,10 @@ export default { eventHub.$emit('refreshPipelinesTable'); }, }, + TBODY_TR_ATTR: { + 'data-testid': 'pipeline-table-row', + 'data-qa-selector': 'pipeline_row_container', + }, }; </script> <template> @@ -135,7 +139,7 @@ export default { :fields="$options.tableFields" :items="pipelines" tbody-tr-class="commit" - :tbody-tr-attr="{ 'data-testid': 'pipeline-table-row' }" + :tbody-tr-attr="$options.TBODY_TR_ATTR" stacked="lg" fixed > diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index cde963e4051..387438fb726 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -1,13 +1,12 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { formatDate, getTimeago, durationTimeFormatted } from '~/lib/utils/datetime_utility'; export default { directives: { GlTooltip: GlTooltipDirective, }, components: { GlIcon }, - mixins: [timeagoMixin], props: { pipeline: { type: Object, @@ -28,24 +27,7 @@ export default { return this.pipeline.flags.stuck; }, durationFormatted() { - const date = new Date(this.duration * 1000); - - let hh = date.getUTCHours(); - let mm = date.getUTCMinutes(); - let ss = date.getSeconds(); - - // left pad - if (hh < 10) { - hh = `0${hh}`; - } - if (mm < 10) { - mm = `0${mm}`; - } - if (ss < 10) { - ss = `0${ss}`; - } - - return `${hh}:${mm}:${ss}`; + return durationTimeFormatted(this.duration); }, showInProgress() { return !this.duration && !this.finishedTime && !this.skipped; @@ -53,6 +35,12 @@ export default { showSkipped() { return !this.duration && !this.finishedTime && this.skipped; }, + timeFormatted() { + return getTimeago().format(this.finishedTime); + }, + tooltipTitle() { + return formatDate(this.finishedTime); + }, }, }; </script> @@ -85,12 +73,12 @@ export default { <time v-gl-tooltip - :title="tooltipTitle(finishedTime)" + :title="tooltipTitle" :datetime="finishedTime" data-placement="top" data-container="body" > - {{ timeFormatted(finishedTime) }} + {{ timeFormatted }} </time> </p> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue index 33115d72b9c..746cf238646 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue @@ -83,13 +83,7 @@ export default { @input="searchAuthors" > <template #view="{ inputValue }"> - <gl-avatar - v-if="activeUser" - :size="16" - :src="activeUser.avatar_url" - shape="circle" - class="gl-mr-2" - /> + <gl-avatar v-if="activeUser" :size="16" :src="activeUser.avatar_url" class="gl-mr-2" /> <span>{{ activeUser ? activeUser.name : inputValue }}</span> </template> <template #suggestions> diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql index 5fe47e09d9c..641ec7a3cf6 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) { project(fullPath: $fullPath) { diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 801f71cb364..338de65e795 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -13,6 +13,7 @@ const SELECTORS = { PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', PIPELINE_NOTIFICATION: '#js-pipeline-notification', + PIPELINE_TABS: '#js-pipeline-tabs', PIPELINE_TESTS: '#js-pipeline-tests-detail', PIPELINE_JOBS: '#js-pipeline-jobs-vue', }; @@ -29,22 +30,6 @@ export default async function initPipelineDetailsBundle() { } try { - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); - } catch { - createFlash({ - message: __('An error occurred while loading the pipeline.'), - }); - } - - try { - createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag); - } catch { - createFlash({ - message: __('An error occurred while loading a section of this page.'), - }); - } - - try { createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider); } catch { createFlash({ @@ -52,27 +37,47 @@ export default async function initPipelineDetailsBundle() { }); } - try { - createDagApp(apolloProvider); - } catch { - createFlash({ - message: __('An error occurred while loading the Needs tab.'), - }); - } + if (gon.features?.pipelineTabsVue) { + const { createPipelineTabs } = await import('./pipeline_tabs'); - try { - createTestDetails(SELECTORS.PIPELINE_TESTS); - } catch { - createFlash({ - message: __('An error occurred while loading the Test Reports tab.'), - }); - } + try { + createPipelineTabs(SELECTORS.PIPELINE_TABS, apolloProvider); + } catch { + createFlash({ + message: __('An error occurred while loading a section of this page.'), + }); + } + } else { + try { + createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); + } catch { + createFlash({ + message: __('An error occurred while loading the pipeline.'), + }); + } - try { - createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); - } catch { - createFlash({ - message: __('An error occurred while loading the Jobs tab.'), - }); + try { + createDagApp(apolloProvider); + } catch { + createFlash({ + message: __('An error occurred while loading the Needs tab.'), + }); + } + + try { + createTestDetails(SELECTORS.PIPELINE_TESTS); + } catch { + createFlash({ + message: __('An error occurred while loading the Test Reports tab.'), + }); + } + + try { + createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); + } catch { + createFlash({ + message: __('An error occurred while loading the Jobs tab.'), + }); + } } } diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js new file mode 100644 index 00000000000..ff88c6215e5 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_tabs.js @@ -0,0 +1,44 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue'; +import { reportToSentry } from './utils'; + +Vue.use(VueApollo); + +const createPipelineTabs = (selector, apolloProvider) => { + const el = document.querySelector(selector); + + if (!el) return; + + const { dataset } = document.querySelector(selector); + const { + canGenerateCodequalityReports, + codequalityReportDownloadPath, + downloadablePathForReportType, + exposeSecurityDashboard, + exposeLicenseScanningData, + } = dataset; + // eslint-disable-next-line no-new + new Vue({ + el: selector, + components: { + PipelineTabs, + }, + apolloProvider, + provide: { + canGenerateCodequalityReports: JSON.parse(canGenerateCodequalityReports), + codequalityReportDownloadPath, + downloadablePathForReportType, + exposeSecurityDashboard: JSON.parse(exposeSecurityDashboard), + exposeLicenseScanningData: JSON.parse(exposeLicenseScanningData), + }, + errorCaptured(err, _vm, info) { + reportToSentry('pipeline_tabs', `error: ${err}, info: ${info}`); + }, + render(createElement) { + return createElement(PipelineTabs); + }, + }); +}; + +export { createPipelineTabs }; diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index 523ca13b6c3..3ec563c95bb 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import axios from '../../lib/utils/axios_utils'; +import axios from '~/lib/utils/axios_utils'; import { validateParams } from '../utils'; export default class PipelinesService { diff --git a/app/assets/javascripts/pipelines/stores/pipelines_store.js b/app/assets/javascripts/pipelines/stores/pipelines_store.js index a4bbada89c8..765441560d8 100644 --- a/app/assets/javascripts/pipelines/stores/pipelines_store.js +++ b/app/assets/javascripts/pipelines/stores/pipelines_store.js @@ -1,4 +1,4 @@ -import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; export default class PipelinesStore { constructor() { diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index 7b28d48b5b6..b7f590a7b3c 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -30,6 +30,7 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => { dispatch('toggleLoading'); + // eslint-disable-next-line camelcase const { build_ids = [] } = state.testReports?.test_suites?.[index] || {}; // Replacing `/:suite_name.json` with the name of the suite. Including the extra characters // to ensure that we replace exactly the template part of the URL string diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js index 63a58798958..6b616601bc5 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -1,4 +1,4 @@ -import { __, sprintf } from '../../../locale'; +import { __, sprintf } from '~/locale'; import { TestStatus } from '../../constants'; /** diff --git a/app/assets/javascripts/profile/preferences/components/diffs_colors.vue b/app/assets/javascripts/profile/preferences/components/diffs_colors.vue new file mode 100644 index 00000000000..1992819ab82 --- /dev/null +++ b/app/assets/javascripts/profile/preferences/components/diffs_colors.vue @@ -0,0 +1,107 @@ +<script> +import { validateHexColor, hexToRgb } from '~/lib/utils/color_utils'; +import { s__ } from '~/locale'; +import { getCssVariable } from '~/lib/utils/css_utils'; +import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue'; +import DiffsColorsPreview from './diffs_colors_preview.vue'; + +export default { + components: { + ColorPicker, + DiffsColorsPreview, + }, + inject: ['deletion', 'addition'], + data() { + return { + deletionColor: this.deletion || '', + additionColor: this.addition || '', + defaultDeletionColor: getCssVariable('--default-diff-color-deletion'), + defaultAdditionColor: getCssVariable('--default-diff-color-addition'), + }; + }, + computed: { + suggestedColors() { + const colors = { + '#d99530': s__('SuggestedColors|Orange'), + '#1f75cb': s__('SuggestedColors|Blue'), + }; + if (this.isValidColor(this.deletion)) { + colors[this.deletion] = s__('SuggestedColors|Current removal color'); + } + if (this.isValidColor(this.addition)) { + colors[this.addition] = s__('SuggestedColors|Current addition color'); + } + if (this.isValidColor(this.defaultDeletionColor)) { + colors[this.defaultDeletionColor] = s__('SuggestedColors|Default removal color'); + } + if (this.isValidColor(this.defaultAdditionColor)) { + colors[this.defaultAdditionColor] = s__('SuggestedColors|Default addition color'); + } + return colors; + }, + previewClasses() { + return { + 'diff-custom-addition-color': this.isValidColor(this.additionColor), + 'diff-custom-deletion-color': this.isValidColor(this.deletionColor), + }; + }, + previewStyle() { + let style = {}; + if (this.isValidColor(this.deletionColor)) { + const colorRgb = hexToRgb(this.deletionColor).join(); + style = { + ...style, + '--diff-deletion-color': `rgba(${colorRgb},0.2)`, + }; + } + if (this.isValidColor(this.additionColor)) { + const colorRgb = hexToRgb(this.additionColor).join(); + style = { + ...style, + '--diff-addition-color': `rgba(${colorRgb},0.2)`, + }; + } + return style; + }, + }, + methods: { + isValidColor(color) { + return validateHexColor(color); + }, + }, + i18n: { + colorDeletionInputLabel: s__('Preferences|Color for removed lines'), + colorAdditionInputLabel: s__('Preferences|Color for added lines'), + previewLabel: s__('Preferences|Preview'), + }, +}; +</script> +<template> + <div :style="previewStyle" :class="previewClasses"> + <diffs-colors-preview /> + <color-picker + v-model="deletionColor" + :label="$options.i18n.colorDeletionInputLabel" + :state="isValidColor(deletionColor)" + :suggested-colors="suggestedColors" + /> + <input + id="user_diffs_deletion_color" + v-model="deletionColor" + name="user[diffs_deletion_color]" + type="hidden" + /> + <color-picker + v-model="additionColor" + :label="$options.i18n.colorAdditionInputLabel" + :state="isValidColor(additionColor)" + :suggested-colors="suggestedColors" + /> + <input + id="user_diffs_addition_color" + v-model="additionColor" + name="user[diffs_addition_color]" + type="hidden" + /> + </div> +</template> diff --git a/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue b/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue new file mode 100644 index 00000000000..74dd2d5628a --- /dev/null +++ b/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue @@ -0,0 +1,231 @@ +<script> +import { s__ } from '~/locale'; + +export default { + computed: { + themeClass() { + return window.gon?.user_color_scheme; + }, + }, + i18n: { + previewLabel: s__('Preferences|Preview'), + }, +}; +</script> +<template> + <div class="form-group"> + <label>{{ $options.i18n.previewLabel }}</label> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <table :class="themeClass" class="code"> + <tbody> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="1"></a> + </td> + <td class="line_content parallel left-side old"> + <span + ><span class="c1"># <span class="idiff deletion">Removed</span> content</span></span + > + </td> + <td class="new_line diff-line-num new"> + <a data-linenumber="1"></a> + </td> + <td class="line_content parallel right-side new"> + <span + ><span class="c1"># <span class="idiff addition">Added</span> content</span></span + > + </td> + </tr> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="2"></a> + </td> + <td class="line_content parallel left-side old"> + <span><span class="n">v</span> <span class="o">=</span> <span class="mi">1</span></span> + </td> + <td class="new_line diff-line-num new"> + <a data-linenumber="2"></a> + </td> + <td class="line_content parallel right-side new"> + <span><span class="n">v</span> <span class="o">=</span> <span class="mi">1</span></span> + </td> + </tr> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="3"></a> + </td> + <td class="line_content parallel left-side old"> + <span + ><span class="n">s</span> <span class="o">=</span> + <span class="s">"string"</span></span + > + </td> + <td class="new_line diff-line-num new"> + <a data-linenumber="3"></a> + </td> + <td class="line_content parallel right-side new"> + <span + ><span class="n">s</span> <span class="o">=</span> + <span class="s">"string"</span></span + > + </td> + </tr> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="4"></a> + </td> + <td class="line_content parallel left-side old"><span></span></td> + <td class="new_line diff-line-num new"> + <a data-linenumber="4"></a> + </td> + <td class="line_content parallel right-side new"><span></span></td> + </tr> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="5"></a> + </td> + <td class="line_content parallel left-side old"> + <span + ><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> + <span class="nb">range</span><span class="p">(</span><span class="o">-</span + ><span class="mi">10</span><span class="p">,</span> <span class="mi">10</span + ><span class="p">):</span></span + > + </td> + <td class="new_line diff-line-num new"> + <a data-linenumber="5"></a> + </td> + <td class="line_content parallel right-side new"> + <span + ><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> + <span class="nb">range</span><span class="p">(</span><span class="o">-</span + ><span class="mi">10</span><span class="p">,</span> <span class="mi">10</span + ><span class="p">):</span></span + > + </td> + </tr> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="6"></a> + </td> + <td class="line_content parallel left-side old"> + <span> + <span>{{ ' ' }}</span> + <span class="k">print</span><span class="p">(</span><span class="n">i</span> + <span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span + > + </td> + <td class="new_line diff-line-num new"> + <a data-linenumber="6"></a> + </td> + <td class="line_content parallel right-side new"> + <span> + <span>{{ ' ' }}</span> + <span class="k">print</span><span class="p">(</span><span class="n">i</span> + <span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span + > + </td> + </tr> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="7"></a> + </td> + <td class="line_content parallel left-side old"><span></span></td> + <td class="new_line diff-line-num new"> + <a data-linenumber="7"></a> + </td> + <td class="line_content parallel right-side new"><span></span></td> + </tr> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="8"></a> + </td> + <td class="line_content parallel left-side old"> + <span + ><span class="k">class</span> <span class="nc">LinkedList</span + ><span class="p">(</span><span class="nb">object</span><span class="p">):</span></span + > + </td> + <td class="new_line diff-line-num new"> + <a data-linenumber="8"></a> + </td> + <td class="line_content parallel right-side new"> + <span + ><span class="k">class</span> <span class="nc">LinkedList</span + ><span class="p">(</span><span class="nb">object</span><span class="p">):</span></span + > + </td> + </tr> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="9"></a> + </td> + <td class="line_content parallel left-side old"> + <span> + <span>{{ ' ' }}</span> + <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span + ><span class="bp">self</span><span class="p">,</span> <span class="n">x</span + ><span class="p">):</span></span + > + </td> + <td class="new_line diff-line-num new"> + <a data-linenumber="9"></a> + </td> + <td class="line_content parallel right-side new"> + <span> + <span>{{ ' ' }}</span> + <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span + ><span class="bp">self</span><span class="p">,</span> <span class="n">x</span + ><span class="p">):</span></span + > + </td> + </tr> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="10"></a> + </td> + <td class="line_content parallel left-side old"> + <span> + <span>{{ ' ' }}</span> + <span class="bp">self</span><span class="p">.</span><span class="n">val</span> + <span class="o">=</span> <span class="n">x</span></span + > + </td> + <td class="new_line diff-line-num new"> + <a data-linenumber="10"></a> + </td> + <td class="line_content parallel right-side new"> + <span> + <span>{{ ' ' }}</span> + <span class="bp">self</span><span class="p">.</span><span class="n">val</span> + <span class="o">=</span> <span class="n">x</span></span + > + </td> + </tr> + <tr class="line_holder parallel"> + <td class="old_line diff-line-num old"> + <a data-linenumber="11"></a> + </td> + <td class="line_content parallel left-side old"> + <span> + <span>{{ ' ' }}</span> + <span class="bp">self</span><span class="p">.</span><span class="nb">next</span> + <span class="o">=</span> <span class="bp">None</span></span + > + </td> + <td class="new_line diff-line-num new"> + <a data-linenumber="11"></a> + </td> + <td class="line_content parallel right-side new"> + <span> + <span>{{ ' ' }}</span> + <span class="bp">self</span><span class="p">.</span><span class="nb">next</span> + <span class="o">=</span> <span class="bp">None</span></span + > + </td> + </tr> + </tbody> + </table> + <!-- eslint-enable @gitlab/vue-require-i18n-strings --> + </div> +</template> diff --git a/app/assets/javascripts/profile/preferences/components/integration_view.vue b/app/assets/javascripts/profile/preferences/components/integration_view.vue index c2952629a5d..9924f248b89 100644 --- a/app/assets/javascripts/profile/preferences/components/integration_view.vue +++ b/app/assets/javascripts/profile/preferences/components/integration_view.vue @@ -1,13 +1,14 @@ <script> -import { GlFormText, GlIcon, GlLink } from '@gitlab/ui'; +import { GlIcon, GlLink, GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; export default { name: 'IntegrationView', components: { - GlFormText, GlIcon, GlLink, + GlFormGroup, + GlFormCheckbox, IntegrationHelpText, }, inject: ['userFields'], @@ -31,7 +32,7 @@ export default { }, data() { return { - isEnabled: this.userFields[this.config.formName], + isEnabled: this.userFields[this.config.formName] ? '1' : '0', }; }, computed: { @@ -46,36 +47,25 @@ export default { </script> <template> - <div> - <label class="label-bold"> + <gl-form-group> + <template #label> {{ config.title }} - </label> - <gl-link class="has-tooltip" title="More information" :href="helpLink"> - <gl-icon name="question-o" class="vertical-align-middle" /> - </gl-link> - <div class="form-group form-check" data-testid="profile-preferences-integration-form-group"> - <!-- Necessary for Rails to receive the value when not checked --> - <input - :name="formName" - type="hidden" - value="0" - data-testid="profile-preferences-integration-hidden-field" - /> - <input - :id="formId" - v-model="isEnabled" - type="checkbox" - class="form-check-input" - :name="formName" - value="1" - data-testid="profile-preferences-integration-checkbox" - /> - <label class="form-check-label" :for="formId"> - {{ config.label }} - </label> - <gl-form-text tag="div"> + <gl-link class="has-tooltip" title="More information" :href="helpLink"> + <gl-icon name="question-o" class="vertical-align-middle" /> + </gl-link> + </template> + <!-- Necessary for Rails to receive the value when not checked --> + <input + :name="formName" + type="hidden" + value="0" + data-testid="profile-preferences-integration-hidden-field" + /> + <gl-form-checkbox :id="formId" :checked="isEnabled" :name="formName" value="1" + >{{ config.label }} + <template #help> <integration-help-text :message="message" :message-url="messageUrl" /> - </gl-form-text> - </div> - </div> + </template> + </gl-form-checkbox> + </gl-form-group> </template> diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index 757a66ef148..7542f81a361 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -45,7 +45,7 @@ export default { return { isSubmitEnabled: true, darkModeOnCreate: null, - darkModeOnSubmit: null, + schemeOnCreate: null, }; }, computed: { @@ -61,6 +61,7 @@ export default { this.formEl.addEventListener('ajax:success', this.handleSuccess); this.formEl.addEventListener('ajax:error', this.handleError); this.darkModeOnCreate = this.darkModeSelected(); + this.schemeOnCreate = this.getSelectedScheme(); }, beforeDestroy() { this.formEl.removeEventListener('ajax:beforeSend', this.handleLoading); @@ -76,15 +77,19 @@ export default { const themeId = new FormData(this.formEl).get('user[theme_id]'); return this.applicationThemes[themeId] ?? null; }, + getSelectedScheme() { + return new FormData(this.formEl).get('user[color_scheme_id]'); + }, handleLoading() { this.isSubmitEnabled = false; - this.darkModeOnSubmit = this.darkModeSelected(); }, handleSuccess(customEvent) { // Reload the page if the theme has changed from light to dark mode or vice versa - // to correctly load all required styles. - const modeChanged = this.darkModeOnCreate ? !this.darkModeOnSubmit : this.darkModeOnSubmit; - if (modeChanged) { + // or if color scheme has changed to correctly load all required styles. + if ( + this.darkModeOnCreate !== this.darkModeSelected() || + this.schemeOnCreate !== this.getSelectedScheme() + ) { window.location.reload(); return; } diff --git a/app/assets/javascripts/profile/preferences/profile_preferences_diffs_colors.js b/app/assets/javascripts/profile/preferences/profile_preferences_diffs_colors.js new file mode 100644 index 00000000000..1b200187610 --- /dev/null +++ b/app/assets/javascripts/profile/preferences/profile_preferences_diffs_colors.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import DiffsColors from './components/diffs_colors.vue'; + +export default () => { + const el = document.querySelector('#js-profile-preferences-diffs-colors-app'); + + if (!el) return false; + + const { deletion, addition } = el.dataset; + + return new Vue({ + el, + provide: { + deletion, + addition, + }, + render(createElement) { + return createElement(DiffsColors); + }, + }); +}; diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue index da14b1e8470..8511f9bdb0f 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue @@ -3,11 +3,20 @@ import { GlLoadingIcon } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import { + getQueryHeaders, + toggleQueryPollingByVisibility, +} from '~/pipelines/components/graph/utils'; +import { formatStages } from '../utils'; import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '../graphql/queries/get_pipeline_stages.query.graphql'; +import { COMMIT_BOX_POLL_INTERVAL } from '../constants'; export default { i18n: { linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'), + stageConversionError: __('There was a problem handling the pipeline data.'), + stagesFetchError: __('There was a problem fetching the pipeline stages.'), }, components: { GlLoadingIcon, @@ -22,6 +31,9 @@ export default { iid: { default: '', }, + graphqlResourceEtag: { + default: '', + }, }, props: { stages: { @@ -48,10 +60,31 @@ export default { createFlash({ message: this.$options.i18n.linkedPipelinesFetchError }); }, }, + pipelineStages: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, + query: getPipelineStagesQuery, + pollInterval: COMMIT_BOX_POLL_INTERVAL, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update({ project }) { + return project?.pipeline?.stages?.nodes || []; + }, + error() { + createFlash({ message: this.$options.i18n.stagesFetchError }); + }, + }, }, data() { return { + formattedStages: [], pipeline: null, + pipelineStages: [], }; }, computed: { @@ -65,6 +98,25 @@ export default { return this.pipeline?.upstream; }, }, + watch: { + pipelineStages() { + // pipelineStages are from GraphQL + // stages are from REST + // we do this to use dropdown_path for fetching jobs on stage click + try { + this.formattedStages = formatStages(this.pipelineStages, this.stages); + } catch (error) { + createFlash({ + message: this.$options.i18n.stageConversionError, + captureError: true, + error, + }); + } + }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages); + }, }; </script> @@ -79,7 +131,7 @@ export default { /> <pipeline-mini-graph - :stages="stages" + :stages="formattedStages" class="gl-display-inline" data-testid="commit-box-mini-graph" /> diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue new file mode 100644 index 00000000000..5a9d3129809 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue @@ -0,0 +1,74 @@ +<script> +import { GlLoadingIcon, GlLink } from '@gitlab/ui'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import createFlash from '~/flash'; +import { + getQueryHeaders, + toggleQueryPollingByVisibility, +} from '~/pipelines/components/graph/utils'; +import getLatestPipelineStatusQuery from '../graphql/queries/get_latest_pipeline_status.query.graphql'; +import { COMMIT_BOX_POLL_INTERVAL, PIPELINE_STATUS_FETCH_ERROR } from '../constants'; + +export default { + PIPELINE_STATUS_FETCH_ERROR, + components: { + CiIcon, + GlLoadingIcon, + GlLink, + }, + inject: { + fullPath: { + default: '', + }, + iid: { + default: '', + }, + graphqlResourceEtag: { + default: '', + }, + }, + apollo: { + pipelineStatus: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, + query: getLatestPipelineStatusQuery, + pollInterval: COMMIT_BOX_POLL_INTERVAL, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update({ project }) { + return project?.pipeline?.detailedStatus || {}; + }, + error() { + createFlash({ message: this.$options.PIPELINE_STATUS_FETCH_ERROR }); + }, + }, + }, + data() { + return { + pipelineStatus: {}, + }; + }, + computed: { + loading() { + return this.$apollo.queries.pipelineStatus.loading; + }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStatus); + }, +}; +</script> + +<template> + <div class="gl-display-inline-block gl-vertical-align-middle gl-mr-2"> + <gl-loading-icon v-if="loading" /> + <gl-link v-else :href="pipelineStatus.detailsPath"> + <ci-icon :status="pipelineStatus" :size="24" /> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/projects/commit_box/info/constants.js b/app/assets/javascripts/projects/commit_box/info/constants.js new file mode 100644 index 00000000000..be0bf715314 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/constants.js @@ -0,0 +1,7 @@ +import { __ } from '~/locale'; + +export const COMMIT_BOX_POLL_INTERVAL = 10000; + +export const PIPELINE_STATUS_FETCH_ERROR = __( + 'There was a problem fetching the latest pipeline status.', +); diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql new file mode 100644 index 00000000000..cec96f82336 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql @@ -0,0 +1,14 @@ +query getLatestPipelineStatus($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + detailedStatus { + id + detailsPath + icon + group + } + } + } +} diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql new file mode 100644 index 00000000000..69a29947b16 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql @@ -0,0 +1,19 @@ +query getPipelineStages($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + stages { + nodes { + id + name + detailedStatus { + id + icon + group + } + } + } + } + } +} diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js index 69fe2d30489..7500c152b6a 100644 --- a/app/assets/javascripts/projects/commit_box/info/index.js +++ b/app/assets/javascripts/projects/commit_box/info/index.js @@ -2,6 +2,7 @@ import { fetchCommitMergeRequests } from '~/commit_merge_requests'; import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph'; import { initDetailsButton } from './init_details_button'; import { loadBranches } from './load_branches'; +import initCommitPipelineStatus from './init_commit_pipeline_status'; export const initCommitBoxInfo = () => { // Display commit related branches @@ -14,4 +15,6 @@ export const initCommitBoxInfo = () => { initCommitPipelineMiniGraph(); initDetailsButton(); + + initCommitPipelineStatus(); }; diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js index 1d4ec4c110b..c206e648561 100644 --- a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js +++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js @@ -5,7 +5,7 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { useGet: true }), }); export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => { @@ -15,7 +15,7 @@ export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipelin return; } - const { stages, fullPath, iid } = el.dataset; + const { stages, fullPath, iid, graphqlResourceEtag } = el.dataset; // Some commits have no pipeline, code splitting to load the pipeline optionally const { default: CommitBoxPipelineMiniGraph } = await import( @@ -30,6 +30,7 @@ export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipelin fullPath, iid, dataMethod: 'graphql', + graphqlResourceEtag, }, render(createElement) { return createElement(CommitBoxPipelineMiniGraph, { diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js new file mode 100644 index 00000000000..d5e62531283 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import CommitBoxPipelineStatus from './components/commit_box_pipeline_status.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient({}, { useGet: true }), +}); + +export default (selector = '.js-commit-pipeline-status') => { + const el = document.querySelector(selector); + + if (!el) { + return; + } + + const { fullPath, iid, graphqlResourceEtag } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + provide: { + fullPath, + iid, + graphqlResourceEtag, + }, + render(createElement) { + return createElement(CommitBoxPipelineStatus); + }, + }); +}; diff --git a/app/assets/javascripts/projects/commit_box/info/utils.js b/app/assets/javascripts/projects/commit_box/info/utils.js new file mode 100644 index 00000000000..ea7eb35cbaf --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/utils.js @@ -0,0 +1,14 @@ +export const formatStages = (graphQLStages = [], restStages = []) => { + if (graphQLStages.length !== restStages.length) { + throw new Error('Rest stages and graphQl stages must be the same length'); + } + + return graphQLStages.map((stage, index) => { + return { + name: stage.name, + status: stage.detailedStatus, + dropdown_path: restStages[index]?.dropdown_path || '', + title: restStages[index].title || '', + }; + }); +}; diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue index fd71a246a26..277af2f281e 100644 --- a/app/assets/javascripts/projects/components/shared/delete_button.vue +++ b/app/assets/javascripts/projects/components/shared/delete_button.vue @@ -104,7 +104,6 @@ export default { <gl-modal ref="removeModal" :modal-id="modalId" - size="sm" ok-variant="danger" footer-class="gl-bg-gray-10 gl-p-5" title-class="gl-text-red-500" diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index 0393d82ca36..6708b7bd9e2 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -57,9 +57,9 @@ export default { text: s__('ProjectTemplates|Pages/Hexo'), icon: '.template-option .icon-hexo', }, - sse_middleman: { - text: s__('ProjectTemplates|Static Site Editor/Middleman'), - icon: '.template-option .icon-sse_middleman', + middleman: { + text: s__('ProjectTemplates|Pages/Middleman'), + icon: '.template-option .icon-middleman', }, gitpod_spring_petclinic: { text: s__('ProjectTemplates|Gitpod/Spring Petclinic'), diff --git a/app/assets/javascripts/projects/new/components/deployment_target_select.vue b/app/assets/javascripts/projects/new/components/deployment_target_select.vue index f3b7e39f148..0003134f15c 100644 --- a/app/assets/javascripts/projects/new/components/deployment_target_select.vue +++ b/app/assets/javascripts/projects/new/components/deployment_target_select.vue @@ -1,12 +1,15 @@ <script> -import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { GlFormGroup, GlFormSelect, GlFormText, GlSprintf, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; import { DEPLOYMENT_TARGET_SELECTIONS, DEPLOYMENT_TARGET_LABEL, DEPLOYMENT_TARGET_EVENT, + VISIT_DOCS_EVENT, NEW_PROJECT_FORM, + K8S_OPTION, } from '../constants'; const trackingMixin = Tracking.mixin({ label: DEPLOYMENT_TARGET_LABEL }); @@ -15,12 +18,21 @@ export default { i18n: { deploymentTargetLabel: s__('Deployment Target|Project deployment target (optional)'), defaultOption: s__('Deployment Target|Select the deployment target'), + k8sEducationText: s__( + 'Deployment Target|%{linkStart}How to provision or deploy to Kubernetes clusters from GitLab?%{linkEnd}', + ), }, deploymentTargets: DEPLOYMENT_TARGET_SELECTIONS, + VISIT_DOCS_EVENT, + DEPLOYMENT_TARGET_LABEL, selectId: 'deployment-target-select', + helpPageUrl: helpPagePath('user/clusters/agent/index'), components: { GlFormGroup, GlFormSelect, + GlFormText, + GlSprintf, + GlLink, }, mixins: [trackingMixin], data() { @@ -29,6 +41,11 @@ export default { formSubmitted: false, }; }, + computed: { + isK8sOptionSelected() { + return this.selectedTarget === K8S_OPTION; + }, + }, mounted() { const form = document.getElementById(NEW_PROJECT_FORM); form.addEventListener('submit', () => { @@ -52,10 +69,24 @@ export default { :id="$options.selectId" v-model="selectedTarget" :options="$options.deploymentTargets" + class="input-lg" > <template #first> <option :value="null" disabled>{{ $options.i18n.defaultOption }}</option> </template> </gl-form-select> + + <gl-form-text v-if="isK8sOptionSelected"> + <gl-sprintf :message="$options.i18n.k8sEducationText"> + <template #link="{ content }"> + <gl-link + :href="$options.helpPageUrl" + :data-track-action="$options.VISIT_DOCS_EVENT" + :data-track-label="$options.DEPLOYMENT_TARGET_LABEL" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </gl-form-text> </gl-form-group> </template> diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue index f4a21c6057c..506f1ec5ffd 100644 --- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -13,6 +13,7 @@ import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { s__ } from '~/locale'; import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql'; import eventHub from '../event_hub'; @@ -43,14 +44,7 @@ export default { debounce: DEBOUNCE_DELAY, }, }, - inject: [ - 'namespaceFullPath', - 'namespaceId', - 'rootUrl', - 'trackLabel', - 'userNamespaceFullPath', - 'userNamespaceId', - ], + inject: ['namespaceFullPath', 'namespaceId', 'rootUrl', 'trackLabel', 'userNamespaceId'], data() { return { currentUser: {}, @@ -62,10 +56,11 @@ export default { fullPath: this.namespaceFullPath, } : { - id: this.userNamespaceId, - fullPath: this.userNamespaceFullPath, + id: undefined, + fullPath: s__('ProjectsNew|Pick a group or namespace'), }, shouldSkipQuery: true, + userNamespaceId: this.userNamespaceId, }; }, computed: { @@ -92,6 +87,9 @@ export default { hasNoMatches() { return !this.hasGroupMatches && !this.hasNamespaceMatches; }, + dropdownPlaceholderClass() { + return this.selectedNamespace.id ? '' : 'gl-text-gray-500!'; + }, }, created() { eventHub.$on('select-template', this.handleSelectTemplate); @@ -130,11 +128,18 @@ export default { </script> <template> - <gl-button-group class="input-lg"> - <gl-button class="gl-text-truncate" label :title="rootUrl">{{ rootUrl }}</gl-button> + <gl-button-group class="gl-w-full"> + <gl-button + class="js-group-namespace-button gl-text-truncate gl-flex-grow-0!" + label + :title="rootUrl" + >{{ rootUrl }}</gl-button + > + <gl-dropdown :text="selectedNamespace.fullPath" - toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20" + class="js-group-namespace-dropdown gl-flex-grow-1" + :toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`" data-qa-selector="select_namespace_dropdown" @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })" @shown="handleDropdownShown" @@ -166,11 +171,13 @@ export default { </template> </gl-dropdown> + <input type="hidden" name="project[selected_namespace_id]" :value="selectedNamespace.id" /> + <input id="project_namespace_id" type="hidden" name="project[namespace_id]" - :value="selectedNamespace.id" + :value="selectedNamespace.id || userNamespaceId" /> </gl-button-group> </template> diff --git a/app/assets/javascripts/projects/new/constants.js b/app/assets/javascripts/projects/new/constants.js index c5e6722981b..e52a84dc07e 100644 --- a/app/assets/javascripts/projects/new/constants.js +++ b/app/assets/javascripts/projects/new/constants.js @@ -1,7 +1,9 @@ import { s__ } from '~/locale'; +export const K8S_OPTION = s__('DeploymentTarget|Kubernetes (GKE, EKS, OpenShift, and so on)'); + export const DEPLOYMENT_TARGET_SELECTIONS = [ - s__('DeploymentTarget|Kubernetes (GKE, EKS, OpenShift, and so on)'), + K8S_OPTION, s__('DeploymentTarget|Managed container runtime (Fargate, Cloud Run, DigitalOcean App)'), s__('DeploymentTarget|Self-managed container runtime (Podman, Docker Swarm, Docker Compose)'), s__('DeploymentTarget|Heroku'), @@ -18,3 +20,4 @@ export const DEPLOYMENT_TARGET_SELECTIONS = [ export const NEW_PROJECT_FORM = 'new_project'; export const DEPLOYMENT_TARGET_LABEL = 'new_project_deployment_target'; export const DEPLOYMENT_TARGET_EVENT = 'select_deployment_target'; +export const VISIT_DOCS_EVENT = 'visit_docs'; diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js index 4de9b8a6f47..a72172a4f5e 100644 --- a/app/assets/javascripts/projects/new/index.js +++ b/app/assets/javascripts/projects/new/index.js @@ -58,7 +58,6 @@ export function initNewProjectUrlSelect() { namespaceId: el.dataset.namespaceId, rootUrl: el.dataset.rootUrl, trackLabel: el.dataset.trackLabel, - userNamespaceFullPath: el.dataset.userNamespaceFullPath, userNamespaceId: el.dataset.userNamespaceId, }, render: (createElement) => createElement(NewProjectUrlSelect), diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index f1b7e3df7d6..3e1c471f015 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -3,6 +3,7 @@ import { debounce } from 'lodash'; import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants'; +import { ENTER_KEY } from '../lib/utils/keys'; import axios from '../lib/utils/axios_utils'; import { convertToTitleCase, @@ -14,6 +15,7 @@ import { let hasUserDefinedProjectPath = false; let hasUserDefinedProjectName = false; const invalidInputClass = 'gl-field-error-outline'; +const invalidDropdownClass = 'gl-inset-border-1-red-400!'; const cancelSource = axios.CancelToken.source(); const endpoint = `${gon.relative_url_root}/import/url/validate`; @@ -50,6 +52,25 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr } }; +const selectedNamespaceId = () => document.querySelector('[name="project[selected_namespace_id]"]'); +const dropdownButton = () => document.querySelector('.js-group-namespace-dropdown > button'); +const namespaceButton = () => document.querySelector('.js-group-namespace-button'); +const namespaceError = () => document.querySelector('.js-group-namespace-error'); + +const validateGroupNamespaceDropdown = (e) => { + if (selectedNamespaceId() && !selectedNamespaceId().attributes.value) { + document.querySelector('input[data-qa-selector="project_name"]').reportValidity(); + e.preventDefault(); + dropdownButton().classList.add(invalidDropdownClass); + namespaceButton().classList.add(invalidDropdownClass); + namespaceError().classList.remove('gl-display-none'); + } else { + dropdownButton().classList.remove(invalidDropdownClass); + namespaceButton().classList.remove(invalidDropdownClass); + namespaceError().classList.add('gl-display-none'); + } +}; + const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { const specialRepo = document.querySelector('.js-user-readme-repo'); @@ -70,6 +91,10 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { $projectPathInput.val() !== $projectPathInput.data('username'), ); }); + + document.querySelector('.js-create-project-button').addEventListener('click', (e) => { + validateGroupNamespaceDropdown(e); + }); }; const deriveProjectPathFromUrl = ($projectImportUrl) => { @@ -158,7 +183,11 @@ const bindEvents = () => { $projectTemplateButtons.addClass('hidden'); $projectFieldsForm.addClass('selected'); $selectedIcon.empty(); - const value = $(this).val(); + + const $selectedTemplate = $(this); + $selectedTemplate.prop('checked', true); + + const value = $selectedTemplate.val(); const selectedTemplate = DEFAULT_PROJECT_TEMPLATES[value]; $selectedTemplateText.text(selectedTemplate.text); @@ -170,7 +199,21 @@ const bindEvents = () => { setProjectNamePathHandlers($activeTabProjectName, $activeTabProjectPath); } - $useTemplateBtn.on('change', chooseTemplate); + function toggleActiveClassOnLabel(event) { + const $label = $(event.target).parent(); + $label.toggleClass('active'); + } + + function chooseTemplateOnEnter(event) { + if (event.code === ENTER_KEY) { + chooseTemplate.call(this); + } + } + + $useTemplateBtn.on('click', chooseTemplate); + + $useTemplateBtn.on('focus focusout', toggleActiveClassOnLabel); + $useTemplateBtn.on('keypress', chooseTemplateOnEnter); $changeTemplateBtn.on('click', () => { $projectTemplateButtons.removeClass('hidden'); diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue index e8b0e95b142..d4c97cbf038 100644 --- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue +++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue @@ -1,6 +1,7 @@ <script> import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import searchProjectTopics from '../queries/project_topics_search.query.graphql'; export default { @@ -65,6 +66,7 @@ export default { this.$emit('update', tokens); }, }, + AVATAR_SHAPE_OPTION_RECT, }; </script> <template> @@ -85,7 +87,7 @@ export default { :entity-name="dropdownItem.name" :label="dropdownItem.name" :size="32" - shape="rect" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> </template> </gl-token-selector> diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue index 174049b15fe..9ed895e90fb 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -1,8 +1,8 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import Sortable from 'sortablejs'; -import sortableConfig from '~/sortable/sortable_config'; import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; +import { defaultSortableOptions } from '~/sortable/constants'; export default { name: 'RelatedIssuesList', @@ -53,7 +53,7 @@ export default { mounted() { if (this.canReorder) { this.sortable = Sortable.create(this.$refs.list, { - ...sortableConfig, + ...defaultSortableOptions, onStart: this.addDraggingCursor, onEnd: this.reordered, }); diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 39140216bc5..8365e6a5ab0 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -185,7 +185,7 @@ export default { <gl-button class="mr-auto js-no-auto-disable" category="primary" - variant="success" + variant="confirm" type="submit" :disabled="isFormSubmissionDisabled" data-testid="submit-button" diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index e53bfea7389..59fa2fca736 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -1,67 +1,237 @@ <script> -import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import { getParameterByName } from '~/lib/utils/url_utility'; +import { GlButton } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants'; +import { convertAllReleasesGraphQLResponse } from '~/releases/util'; +import allReleasesQuery from '../graphql/queries/all_releases.query.graphql'; import ReleaseBlock from './release_block.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; +import ReleasesEmptyState from './releases_empty_state.vue'; import ReleasesPagination from './releases_pagination.vue'; import ReleasesSort from './releases_sort.vue'; export default { - name: 'ReleasesApp', + name: 'ReleasesIndexApp', components: { - GlEmptyState, - GlLink, GlButton, ReleaseBlock, - ReleasesPagination, ReleaseSkeletonLoader, + ReleasesEmptyState, + ReleasesPagination, ReleasesSort, }, + inject: { + projectPath: { + default: '', + }, + newReleasePath: { + default: '', + }, + }, + apollo: { + /** + * The same query as `fullGraphqlResponse`, except that it limits its + * results to a single item. This causes this request to complete much more + * quickly than `fullGraphqlResponse`, which allows the page to show + * meaningful content to the user much earlier. + */ + singleGraphqlResponse: { + query: allReleasesQuery, + // This trick only works when paginating _forward_. + // When paginating backwards, limiting the query to a single item loads + // the _last_ item in the page, which is not useful for our purposes. + skip() { + return !this.includeSingleQuery; + }, + variables() { + return { + ...this.queryVariables, + first: 1, + }; + }, + update(data) { + return { data }; + }, + error() { + this.singleRequestError = true; + }, + }, + fullGraphqlResponse: { + query: allReleasesQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return { data }; + }, + error(error) { + this.fullRequestError = true; + + createFlash({ + message: this.$options.i18n.errorMessage, + captureError: true, + error, + }); + }, + }, + }, + data() { + return { + singleRequestError: false, + fullRequestError: false, + cursors: { + before: getParameterByName('before'), + after: getParameterByName('after'), + }, + sort: DEFAULT_SORT, + }; + }, computed: { - ...mapState('index', [ - 'documentationPath', - 'illustrationPath', - 'newReleasePath', - 'isLoading', - 'releases', - 'hasError', - ]), - shouldRenderEmptyState() { - return !this.releases.length && !this.hasError && !this.isLoading; + queryVariables() { + let paginationParams = { first: PAGE_SIZE }; + if (this.cursors.after) { + paginationParams = { + after: this.cursors.after, + first: PAGE_SIZE, + }; + } else if (this.cursors.before) { + paginationParams = { + before: this.cursors.before, + last: PAGE_SIZE, + }; + } + + return { + fullPath: this.projectPath, + ...paginationParams, + sort: this.sort, + }; + }, + /** + * @returns {Boolean} Whether or not to request/include + * the results of the single-item query + */ + includeSingleQuery() { + return Boolean(!this.cursors.before || this.cursors.after); + }, + isSingleRequestLoading() { + return this.$apollo.queries.singleGraphqlResponse.loading; }, - shouldRenderSuccessState() { - return this.releases.length && !this.isLoading && !this.hasError; + isFullRequestLoading() { + return this.$apollo.queries.fullGraphqlResponse.loading; + }, + /** + * @returns {Boolean} `true` if the `singleGraphqlResponse` + * query has finished loading without errors + */ + isSingleRequestLoaded() { + return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project); + }, + /** + * @returns {Boolean} `true` if the `fullGraphqlResponse` + * query has finished loading without errors + */ + isFullRequestLoaded() { + return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project); + }, + releases() { + if (this.isFullRequestLoaded) { + return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data; + } + + if (this.isSingleRequestLoaded && this.includeSingleQuery) { + return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data; + } + + return []; }, - emptyStateText() { - return __( - "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.", + pageInfo() { + if (!this.isFullRequestLoaded) { + return { + hasPreviousPage: false, + hasNextPage: false, + }; + } + + return this.fullGraphqlResponse.data.project.releases.pageInfo; + }, + shouldRenderEmptyState() { + return this.isFullRequestLoaded && this.releases.length === 0; + }, + shouldRenderLoadingIndicator() { + return ( + (this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) || + (this.isFullRequestLoading && !this.fullRequestError) ); }, + shouldRenderPagination() { + return this.isFullRequestLoaded && !this.shouldRenderEmptyState; + }, }, created() { - this.fetchReleases(); + this.updateQueryParamsFromUrl(); - window.addEventListener('popstate', this.fetchReleases); + window.addEventListener('popstate', this.updateQueryParamsFromUrl); + }, + destroyed() { + window.removeEventListener('popstate', this.updateQueryParamsFromUrl); }, methods: { - ...mapActions('index', { - fetchReleasesStoreAction: 'fetchReleases', - }), - fetchReleases() { - this.fetchReleasesStoreAction({ - before: getParameterByName('before'), - after: getParameterByName('after'), - }); + getReleaseKey(release, index) { + return [release.tagName, release.name, index].join('|'); + }, + updateQueryParamsFromUrl() { + this.cursors.before = getParameterByName('before'); + this.cursors.after = getParameterByName('after'); + }, + onPaginationButtonPress() { + this.updateQueryParamsFromUrl(); + + // In some cases, Apollo Client is able to pull its results from the cache instead of making + // a new network request. In these cases, the page's content gets swapped out immediately without + // changing the page's scroll, leaving the user looking at the bottom of the new page. + // To make the experience consistent, regardless of how the data is sourced, we manually + // scroll to the top of the page every time a pagination button is pressed. + scrollUp(); + }, + onSortChanged(newSort) { + if (this.sort === newSort) { + return; + } + + // Remove the "before" and "after" query parameters from the URL, + // effectively placing the user back on page 1 of the results. + // This prevents the frontend from requesting the results sorted + // by one field (e.g. `released_at`) while using a pagination cursor + // intended for a different field (e.g.) `created_at`). + // For more details, see the MR that introduced this change: + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63434 + historyPushState( + setUrlParams({ + before: null, + after: null, + }), + ); + + this.updateQueryParamsFromUrl(); + + this.sort = newSort; }, }, + i18n: { + newRelease: __('New release'), + errorMessage: __('An error occurred while fetching the releases. Please try again.'), + }, }; </script> <template> <div class="flex flex-column mt-2"> <div class="gl-align-self-end gl-mb-3"> - <releases-sort class="gl-mr-2" @sort:changed="fetchReleases" /> + <releases-sort :value="sort" class="gl-mr-2" @input="onSortChanged" /> <gl-button v-if="newReleasePath" @@ -69,44 +239,27 @@ export default { :aria-describedby="shouldRenderEmptyState && 'releases-description'" category="primary" variant="confirm" - data-testid="new-release-button" + >{{ $options.i18n.newRelease }}</gl-button > - {{ __('New release') }} - </gl-button> </div> - <release-skeleton-loader v-if="isLoading" /> - - <gl-empty-state - v-else-if="shouldRenderEmptyState" - data-testid="empty-state" - :title="__('Getting started with releases')" - :svg-path="illustrationPath" - > - <template #description> - <span id="releases-description"> - {{ emptyStateText }} - <gl-link - :href="documentationPath" - :aria-label="__('Releases documentation')" - target="_blank" - > - {{ __('More information') }} - </gl-link> - </span> - </template> - </gl-empty-state> - - <div v-else-if="shouldRenderSuccessState" data-testid="success-state"> - <release-block - v-for="(release, index) in releases" - :key="index" - :release="release" - :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" - /> - </div> + <releases-empty-state v-if="shouldRenderEmptyState" /> + + <release-block + v-for="(release, index) in releases" + :key="getReleaseKey(release, index)" + :release="release" + :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" + /> + + <release-skeleton-loader v-if="shouldRenderLoadingIndicator" /> - <releases-pagination v-if="!isLoading" /> + <releases-pagination + v-if="shouldRenderPagination" + :page-info="pageInfo" + @prev="onPaginationButtonPress" + @next="onPaginationButtonPress" + /> </div> </template> <style> diff --git a/app/assets/javascripts/releases/components/app_index_apollo_client.vue b/app/assets/javascripts/releases/components/app_index_apollo_client.vue deleted file mode 100644 index f49c44a399f..00000000000 --- a/app/assets/javascripts/releases/components/app_index_apollo_client.vue +++ /dev/null @@ -1,275 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql'; -import createFlash from '~/flash'; -import { historyPushState } from '~/lib/utils/common_utils'; -import { scrollUp } from '~/lib/utils/scroll_utils'; -import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; -import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants'; -import { convertAllReleasesGraphQLResponse } from '~/releases/util'; -import ReleaseBlock from './release_block.vue'; -import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; -import ReleasesEmptyState from './releases_empty_state.vue'; -import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue'; -import ReleasesSortApolloClient from './releases_sort_apollo_client.vue'; - -export default { - name: 'ReleasesIndexApolloClientApp', - components: { - GlButton, - ReleaseBlock, - ReleaseSkeletonLoader, - ReleasesEmptyState, - ReleasesPaginationApolloClient, - ReleasesSortApolloClient, - }, - inject: { - projectPath: { - default: '', - }, - newReleasePath: { - default: '', - }, - }, - apollo: { - /** - * The same query as `fullGraphqlResponse`, except that it limits its - * results to a single item. This causes this request to complete much more - * quickly than `fullGraphqlResponse`, which allows the page to show - * meaningful content to the user much earlier. - */ - singleGraphqlResponse: { - query: allReleasesQuery, - // This trick only works when paginating _forward_. - // When paginating backwards, limiting the query to a single item loads - // the _last_ item in the page, which is not useful for our purposes. - skip() { - return !this.includeSingleQuery; - }, - variables() { - return { - ...this.queryVariables, - first: 1, - }; - }, - update(data) { - return { data }; - }, - error() { - this.singleRequestError = true; - }, - }, - fullGraphqlResponse: { - query: allReleasesQuery, - variables() { - return this.queryVariables; - }, - update(data) { - return { data }; - }, - error(error) { - this.fullRequestError = true; - - createFlash({ - message: this.$options.i18n.errorMessage, - captureError: true, - error, - }); - }, - }, - }, - data() { - return { - singleRequestError: false, - fullRequestError: false, - cursors: { - before: getParameterByName('before'), - after: getParameterByName('after'), - }, - sort: DEFAULT_SORT, - }; - }, - computed: { - queryVariables() { - let paginationParams = { first: PAGE_SIZE }; - if (this.cursors.after) { - paginationParams = { - after: this.cursors.after, - first: PAGE_SIZE, - }; - } else if (this.cursors.before) { - paginationParams = { - before: this.cursors.before, - last: PAGE_SIZE, - }; - } - - return { - fullPath: this.projectPath, - ...paginationParams, - sort: this.sort, - }; - }, - /** - * @returns {Boolean} Whether or not to request/include - * the results of the single-item query - */ - includeSingleQuery() { - return Boolean(!this.cursors.before || this.cursors.after); - }, - isSingleRequestLoading() { - return this.$apollo.queries.singleGraphqlResponse.loading; - }, - isFullRequestLoading() { - return this.$apollo.queries.fullGraphqlResponse.loading; - }, - /** - * @returns {Boolean} `true` if the `singleGraphqlResponse` - * query has finished loading without errors - */ - isSingleRequestLoaded() { - return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project); - }, - /** - * @returns {Boolean} `true` if the `fullGraphqlResponse` - * query has finished loading without errors - */ - isFullRequestLoaded() { - return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project); - }, - releases() { - if (this.isFullRequestLoaded) { - return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data; - } - - if (this.isSingleRequestLoaded && this.includeSingleQuery) { - return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data; - } - - return []; - }, - pageInfo() { - if (!this.isFullRequestLoaded) { - return { - hasPreviousPage: false, - hasNextPage: false, - }; - } - - return this.fullGraphqlResponse.data.project.releases.pageInfo; - }, - shouldRenderEmptyState() { - return this.isFullRequestLoaded && this.releases.length === 0; - }, - shouldRenderLoadingIndicator() { - return ( - (this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) || - (this.isFullRequestLoading && !this.fullRequestError) - ); - }, - shouldRenderPagination() { - return this.isFullRequestLoaded && !this.shouldRenderEmptyState; - }, - }, - created() { - this.updateQueryParamsFromUrl(); - - window.addEventListener('popstate', this.updateQueryParamsFromUrl); - }, - destroyed() { - window.removeEventListener('popstate', this.updateQueryParamsFromUrl); - }, - methods: { - getReleaseKey(release, index) { - return [release.tagName, release.name, index].join('|'); - }, - updateQueryParamsFromUrl() { - this.cursors.before = getParameterByName('before'); - this.cursors.after = getParameterByName('after'); - }, - onPaginationButtonPress() { - this.updateQueryParamsFromUrl(); - - // In some cases, Apollo Client is able to pull its results from the cache instead of making - // a new network request. In these cases, the page's content gets swapped out immediately without - // changing the page's scroll, leaving the user looking at the bottom of the new page. - // To make the experience consistent, regardless of how the data is sourced, we manually - // scroll to the top of the page every time a pagination button is pressed. - scrollUp(); - }, - onSortChanged(newSort) { - if (this.sort === newSort) { - return; - } - - // Remove the "before" and "after" query parameters from the URL, - // effectively placing the user back on page 1 of the results. - // This prevents the frontend from requesting the results sorted - // by one field (e.g. `released_at`) while using a pagination cursor - // intended for a different field (e.g.) `created_at`). - // For more details, see the MR that introduced this change: - // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63434 - historyPushState( - setUrlParams({ - before: null, - after: null, - }), - ); - - this.updateQueryParamsFromUrl(); - - this.sort = newSort; - }, - }, - i18n: { - newRelease: __('New release'), - errorMessage: __('An error occurred while fetching the releases. Please try again.'), - }, -}; -</script> -<template> - <div class="flex flex-column mt-2"> - <div class="gl-align-self-end gl-mb-3"> - <releases-sort-apollo-client :value="sort" class="gl-mr-2" @input="onSortChanged" /> - - <gl-button - v-if="newReleasePath" - :href="newReleasePath" - :aria-describedby="shouldRenderEmptyState && 'releases-description'" - category="primary" - variant="success" - >{{ $options.i18n.newRelease }}</gl-button - > - </div> - - <releases-empty-state v-if="shouldRenderEmptyState" /> - - <release-block - v-for="(release, index) in releases" - :key="getReleaseKey(release, index)" - :release="release" - :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" - /> - - <release-skeleton-loader v-if="shouldRenderLoadingIndicator" /> - - <releases-pagination-apollo-client - v-if="shouldRenderPagination" - :page-info="pageInfo" - @prev="onPaginationButtonPress" - @next="onPaginationButtonPress" - /> - </div> -</template> -<style> -.linked-card::after { - width: 1px; - content: ' '; - border: 1px solid #e5e5e5; - height: 17px; - top: 100%; - position: absolute; - left: 32px; -} -</style> diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue index cb795b3cba7..91d6d0911a4 100644 --- a/app/assets/javascripts/releases/components/release_block_footer.vue +++ b/app/assets/javascripts/releases/components/release_block_footer.vue @@ -104,9 +104,11 @@ export default { <div v-if="author" class="d-flex"> <span class="text-secondary">{{ __('by') }} </span> <user-avatar-link + class="gl-my-n1" :link-href="author.webUrl" :img-src="author.avatarUrl" :img-alt="userImageAltDescription" + :img-size="24" :tooltip-text="author.username" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/releases/components/releases_pagination.vue b/app/assets/javascripts/releases/components/releases_pagination.vue index fddf85ead1e..52ad991d61a 100644 --- a/app/assets/javascripts/releases/components/releases_pagination.vue +++ b/app/assets/javascripts/releases/components/releases_pagination.vue @@ -1,26 +1,24 @@ <script> import { GlKeysetPagination } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; +import { isBoolean } from 'lodash'; import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; export default { - name: 'ReleasesPaginationGraphql', + name: 'ReleasesPagination', components: { GlKeysetPagination }, - computed: { - ...mapState('index', ['pageInfo']), - showPagination() { - return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; + props: { + pageInfo: { + type: Object, + required: true, + validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage), }, }, methods: { - ...mapActions('index', ['fetchReleases']), onPrev(before) { historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); - this.fetchReleases({ before }); }, onNext(after) { historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); - this.fetchReleases({ after }); }, }, }; @@ -28,8 +26,10 @@ export default { <template> <div class="gl-display-flex gl-justify-content-center"> <gl-keyset-pagination - v-if="showPagination" v-bind="pageInfo" + :prev-text="__('Prev')" + :next-text="__('Next')" + v-on="$listeners" @prev="onPrev($event)" @next="onNext($event)" /> diff --git a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue b/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue deleted file mode 100644 index 73339677a4b..00000000000 --- a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue +++ /dev/null @@ -1,37 +0,0 @@ -<script> -import { GlKeysetPagination } from '@gitlab/ui'; -import { isBoolean } from 'lodash'; -import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; - -export default { - name: 'ReleasesPaginationApolloClient', - components: { GlKeysetPagination }, - props: { - pageInfo: { - type: Object, - required: true, - validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage), - }, - }, - methods: { - onPrev(before) { - historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); - }, - onNext(after) { - historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); - }, - }, -}; -</script> -<template> - <div class="gl-display-flex gl-justify-content-center"> - <gl-keyset-pagination - v-bind="pageInfo" - :prev-text="__('Prev')" - :next-text="__('Next')" - v-on="$listeners" - @prev="onPrev($event)" - @next="onNext($event)" - /> - </div> -</template> diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue index d4210dad19c..0f14b579da0 100644 --- a/app/assets/javascripts/releases/components/releases_sort.vue +++ b/app/assets/javascripts/releases/components/releases_sort.vue @@ -1,7 +1,17 @@ <script> import { GlSorting, GlSortingItem } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import { ASCENDING_ORDER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants'; +import { + ASCENDING_ORDER, + DESCENDING_ORDER, + SORT_OPTIONS, + RELEASED_AT, + CREATED_AT, + RELEASED_AT_ASC, + RELEASED_AT_DESC, + CREATED_ASC, + ALL_SORTS, + SORT_MAP, +} from '../constants'; export default { name: 'ReleasesSort', @@ -9,35 +19,54 @@ export default { GlSorting, GlSortingItem, }, + props: { + value: { + type: String, + required: true, + validator: (sort) => ALL_SORTS.includes(sort), + }, + }, computed: { - ...mapState('index', { - orderBy: (state) => state.sorting.orderBy, - sort: (state) => state.sorting.sort, - }), + orderBy() { + if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) { + return RELEASED_AT; + } + + return CREATED_AT; + }, + direction() { + if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) { + return ASCENDING_ORDER; + } + + return DESCENDING_ORDER; + }, sortOptions() { return SORT_OPTIONS; }, sortText() { - const option = this.sortOptions.find((s) => s.orderBy === this.orderBy); - return option.label; + return this.sortOptions.find((s) => s.orderBy === this.orderBy).label; }, - isSortAscending() { - return this.sort === ASCENDING_ORDER; + isDirectionAscending() { + return this.direction === ASCENDING_ORDER; }, }, methods: { - ...mapActions('index', ['setSorting']), onDirectionChange() { - const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER; - this.setSorting({ sort }); - this.$emit('sort:changed'); + const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER; + this.emitInputEventIfChanged(this.orderBy, direction); }, onSortItemClick(item) { - this.setSorting({ orderBy: item }); - this.$emit('sort:changed'); + this.emitInputEventIfChanged(item.orderBy, this.direction); }, isActiveSortItem(item) { - return this.orderBy === item; + return this.orderBy === item.orderBy; + }, + emitInputEventIfChanged(orderBy, direction) { + const newSort = SORT_MAP[orderBy][direction]; + if (newSort !== this.value) { + this.$emit('input', SORT_MAP[orderBy][direction]); + } }, }, }; @@ -46,15 +75,15 @@ export default { <template> <gl-sorting :text="sortText" - :is-ascending="isSortAscending" + :is-ascending="isDirectionAscending" data-testid="releases-sort" @sortDirectionChange="onDirectionChange" > <gl-sorting-item - v-for="item in sortOptions" + v-for="item of sortOptions" :key="item.orderBy" - :active="isActiveSortItem(item.orderBy)" - @click="onSortItemClick(item.orderBy)" + :active="isActiveSortItem(item)" + @click="onSortItemClick(item)" > {{ item.label }} </gl-sorting-item> diff --git a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue b/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue deleted file mode 100644 index 7257b34bbf6..00000000000 --- a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue +++ /dev/null @@ -1,91 +0,0 @@ -<script> -import { GlSorting, GlSortingItem } from '@gitlab/ui'; -import { - ASCENDING_ORDER, - DESCENDING_ORDER, - SORT_OPTIONS, - RELEASED_AT, - CREATED_AT, - RELEASED_AT_ASC, - RELEASED_AT_DESC, - CREATED_ASC, - ALL_SORTS, - SORT_MAP, -} from '../constants'; - -export default { - name: 'ReleasesSortApolloclient', - components: { - GlSorting, - GlSortingItem, - }, - props: { - value: { - type: String, - required: true, - validator: (sort) => ALL_SORTS.includes(sort), - }, - }, - computed: { - orderBy() { - if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) { - return RELEASED_AT; - } - - return CREATED_AT; - }, - direction() { - if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) { - return ASCENDING_ORDER; - } - - return DESCENDING_ORDER; - }, - sortOptions() { - return SORT_OPTIONS; - }, - sortText() { - return this.sortOptions.find((s) => s.orderBy === this.orderBy).label; - }, - isDirectionAscending() { - return this.direction === ASCENDING_ORDER; - }, - }, - methods: { - onDirectionChange() { - const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER; - this.emitInputEventIfChanged(this.orderBy, direction); - }, - onSortItemClick(item) { - this.emitInputEventIfChanged(item.orderBy, this.direction); - }, - isActiveSortItem(item) { - return this.orderBy === item.orderBy; - }, - emitInputEventIfChanged(orderBy, direction) { - const newSort = SORT_MAP[orderBy][direction]; - if (newSort !== this.value) { - this.$emit('input', SORT_MAP[orderBy][direction]); - } - }, - }, -}; -</script> - -<template> - <gl-sorting - :text="sortText" - :is-ascending="isDirectionAscending" - data-testid="releases-sort" - @sortDirectionChange="onDirectionChange" - > - <gl-sorting-item - v-for="item of sortOptions" - :key="item.orderBy" - :active="isActiveSortItem(item)" - @click="onSortItemClick(item)" - > - {{ item.label }} - </gl-sorting-item> - </gl-sorting> -</template> diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql index 7f67f7d11a3..bda7ac52a47 100644 --- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql @@ -1,12 +1,4 @@ -#import "../fragments/release.fragment.graphql" - -# This query is identical to -# `app/graphql/queries/releases/all_releases.query.graphql`. -# These two queries should be kept in sync. -# When the `releases_index_apollo_client` feature flag is -# removed, this query should be removed entirely. - -query allReleasesDeprecated( +query allReleases( $fullPath: ID! $first: Int $last: Int @@ -20,7 +12,87 @@ query allReleasesDeprecated( releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) { __typename nodes { - ...Release + __typename + name + tagName + tagPath + descriptionHtml + releasedAt + createdAt + upcomingRelease + assets { + __typename + count + sources { + __typename + nodes { + __typename + format + url + } + } + links { + __typename + nodes { + __typename + id + name + url + directAssetUrl + linkType + external + } + } + } + evidences { + __typename + nodes { + __typename + id + filepath + collectedAt + sha + } + } + links { + __typename + editUrl + selfUrl + openedIssuesUrl + closedIssuesUrl + openedMergeRequestsUrl + mergedMergeRequestsUrl + closedMergeRequestsUrl + } + commit { + __typename + id + sha + webUrl + title + } + author { + __typename + id + webUrl + avatarUrl + username + } + milestones { + __typename + nodes { + __typename + id + title + description + webPath + stats { + __typename + totalIssuesCount + closedIssuesCount + } + } + } } pageInfo { __typename diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js index 86fa72d1496..afb8ab461cd 100644 --- a/app/assets/javascripts/releases/mount_index.js +++ b/app/assets/javascripts/releases/mount_index.js @@ -1,50 +1,32 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import Vuex from 'vuex'; import createDefaultClient from '~/lib/graphql'; import ReleaseIndexApp from './components/app_index.vue'; -import ReleaseIndexApollopClientApp from './components/app_index_apollo_client.vue'; -import createStore from './stores'; -import createIndexModule from './stores/modules/index'; export default () => { const el = document.getElementById('js-releases-page'); - if (window.gon?.features?.releasesIndexApolloClient) { - Vue.use(VueApollo); + Vue.use(VueApollo); - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - // This page attempts to decrease the perceived loading time - // by sending two requests: one request for the first item only (which - // completes relatively quickly), and one for all the items (which is slower). - // By default, Apollo Client batches these requests together, which defeats - // the purpose of making separate requests. So we explicitly - // disable batching on this page. - batchMax: 1, - }, - ), - }); - - return new Vue({ - el, - apolloProvider, - provide: { ...el.dataset }, - render: (h) => h(ReleaseIndexApollopClientApp), - }); - } - - Vue.use(Vuex); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + // This page attempts to decrease the perceived loading time + // by sending two requests: one request for the first item only (which + // completes relatively quickly), and one for all the items (which is slower). + // By default, Apollo Client batches these requests together, which defeats + // the purpose of making separate requests. So we explicitly + // disable batching on this page. + batchMax: 1, + }, + ), + }); return new Vue({ el, - store: createStore({ - modules: { - index: createIndexModule(el.dataset), - }, - }), + apolloProvider, + provide: { ...el.dataset }, render: (h) => h(ReleaseIndexApp), }); }; diff --git a/app/assets/javascripts/releases/stores/modules/index/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js deleted file mode 100644 index d3bb11cab30..00000000000 --- a/app/assets/javascripts/releases/stores/modules/index/actions.js +++ /dev/null @@ -1,65 +0,0 @@ -import createFlash from '~/flash'; -import { __ } from '~/locale'; -import { PAGE_SIZE } from '~/releases/constants'; -import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; -import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; -import * as types from './mutation_types'; - -/** - * Gets a paginated list of releases from the GraphQL endpoint - * - * @param {Object} vuexParams - * @param {Object} actionParams - * @param {String} [actionParams.before] A GraphQL cursor. If provided, - * the items returned will proceed the provided cursor. - * @param {String} [actionParams.after] A GraphQL cursor. If provided, - * the items returned will follow the provided cursor. - */ -export const fetchReleases = ({ dispatch, commit, state }, { before, after }) => { - commit(types.REQUEST_RELEASES); - - const { sort, orderBy } = state.sorting; - const orderByParam = orderBy === 'created_at' ? 'created' : orderBy; - const sortParams = `${orderByParam}_${sort}`.toUpperCase(); - - let paginationParams; - if (!before && !after) { - paginationParams = { first: PAGE_SIZE }; - } else if (before && !after) { - paginationParams = { last: PAGE_SIZE, before }; - } else if (!before && after) { - paginationParams = { first: PAGE_SIZE, after }; - } else { - throw new Error( - 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.', - ); - } - - gqClient - .query({ - query: allReleasesQuery, - variables: { - fullPath: state.projectPath, - sort: sortParams, - ...paginationParams, - }, - }) - .then((response) => { - const { data, paginationInfo: pageInfo } = convertAllReleasesGraphQLResponse(response); - - commit(types.RECEIVE_RELEASES_SUCCESS, { - data, - pageInfo, - }); - }) - .catch(() => dispatch('receiveReleasesError')); -}; - -export const receiveReleasesError = ({ commit }) => { - commit(types.RECEIVE_RELEASES_ERROR); - createFlash({ - message: __('An error occurred while fetching the releases. Please try again.'), - }); -}; - -export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data); diff --git a/app/assets/javascripts/releases/stores/modules/index/index.js b/app/assets/javascripts/releases/stores/modules/index/index.js deleted file mode 100644 index d5ca191153a..00000000000 --- a/app/assets/javascripts/releases/stores/modules/index/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import * as actions from './actions'; -import mutations from './mutations'; -import createState from './state'; - -export default (initialState) => ({ - namespaced: true, - actions, - mutations, - state: createState(initialState), -}); diff --git a/app/assets/javascripts/releases/stores/modules/index/mutation_types.js b/app/assets/javascripts/releases/stores/modules/index/mutation_types.js deleted file mode 100644 index 669168efb88..00000000000 --- a/app/assets/javascripts/releases/stores/modules/index/mutation_types.js +++ /dev/null @@ -1,4 +0,0 @@ -export const REQUEST_RELEASES = 'REQUEST_RELEASES'; -export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS'; -export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR'; -export const SET_SORTING = 'SET_SORTING'; diff --git a/app/assets/javascripts/releases/stores/modules/index/mutations.js b/app/assets/javascripts/releases/stores/modules/index/mutations.js deleted file mode 100644 index 55a8a488be8..00000000000 --- a/app/assets/javascripts/releases/stores/modules/index/mutations.js +++ /dev/null @@ -1,44 +0,0 @@ -import * as types from './mutation_types'; - -export default { - /** - * Sets isLoading to true while the request is being made. - * @param {Object} state - */ - [types.REQUEST_RELEASES](state) { - state.isLoading = true; - }, - - /** - * Sets isLoading to false. - * Sets hasError to false. - * Sets the received data - * Sets the received pagination information - * @param {Object} state - * @param {Object} resp - */ - [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) { - state.hasError = false; - state.isLoading = false; - state.releases = data; - state.pageInfo = pageInfo; - }, - - /** - * Sets isLoading to false. - * Sets hasError to true. - * Resets the data - * @param {Object} state - * @param {Object} data - */ - [types.RECEIVE_RELEASES_ERROR](state) { - state.isLoading = false; - state.releases = []; - state.hasError = true; - state.pageInfo = {}; - }, - - [types.SET_SORTING](state, sorting) { - state.sorting = { ...state.sorting, ...sorting }; - }, -}; diff --git a/app/assets/javascripts/releases/stores/modules/index/state.js b/app/assets/javascripts/releases/stores/modules/index/state.js deleted file mode 100644 index 5e1aaab7b58..00000000000 --- a/app/assets/javascripts/releases/stores/modules/index/state.js +++ /dev/null @@ -1,24 +0,0 @@ -import { DESCENDING_ORDER, RELEASED_AT } from '../../../constants'; - -export default ({ - projectId, - projectPath, - documentationPath, - illustrationPath, - newReleasePath = '', -}) => ({ - projectId, - projectPath, - documentationPath, - illustrationPath, - newReleasePath, - - isLoading: false, - hasError: false, - releases: [], - pageInfo: {}, - sorting: { - sort: DESCENDING_ORDER, - orderBy: RELEASED_AT, - }, -}); diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue index 59bd54eab60..fb2ef850e4f 100644 --- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue +++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue @@ -34,7 +34,7 @@ export default { return `${this.severityLabel} - ${this.issue.name}`; }, issueSeverity() { - return this.issue.severity.toLowerCase(); + return this.issue.severity?.toLowerCase(); }, isStatusSuccess() { return this.status === STATUS_SUCCESS; diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 7a490210f0b..0714d88b392 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -3,7 +3,7 @@ import { GlButton } from '@gitlab/ui'; import api from '~/api'; import { __ } from '~/locale'; import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; -import Popover from '~/vue_shared/components/help_popover.vue'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants'; import IssuesList from './issues_list.vue'; @@ -13,7 +13,7 @@ export default { components: { GlButton, IssuesList, - Popover, + HelpPopover, StatusIcon, }, mixins: [glFeatureFlagsMixin()], @@ -172,7 +172,7 @@ export default { }, methods: { toggleCollapsed() { - if (this.trackAction && this.glFeatures.usersExpandingWidgetsUsageData) { + if (this.trackAction) { api.trackRedisHllUserEvent(this.trackAction); } @@ -193,7 +193,7 @@ export default { <div class="gl-display-flex gl-align-items-center"> <p class="gl-line-height-normal gl-m-0">{{ headerText }}</p> <slot :name="slotName"></slot> - <popover + <help-popover v-if="hasPopover" :options="popoverOptions" class="gl-ml-2 gl-display-inline-flex" diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index 6b7d81c4878..7419b5b59d6 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import Popover from '~/vue_shared/components/help_popover.vue'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import { ICON_WARNING } from '../constants'; /** @@ -16,7 +16,7 @@ export default { name: 'ReportSummaryRow', components: { CiIcon, - Popover, + HelpPopover, GlLoadingIcon, }, props: { @@ -79,7 +79,7 @@ export default { <div class="report-block-list-issue-description-text" data-testid="summary-row-description"> <slot name="summary">{{ summary }}</slot ><span v-if="popoverOptions" class="text-nowrap" - > <popover v-if="popoverOptions" :options="popoverOptions" class="align-top" /> + > <help-popover v-if="popoverOptions" :options="popoverOptions" class="align-top" /> </span> </div> </div> diff --git a/app/assets/javascripts/reports/grouped_test_report/store/actions.js b/app/assets/javascripts/reports/grouped_test_report/store/actions.js index e3db57ad846..73f8df016b6 100644 --- a/app/assets/javascripts/reports/grouped_test_report/store/actions.js +++ b/app/assets/javascripts/reports/grouped_test_report/store/actions.js @@ -1,7 +1,7 @@ import Visibility from 'visibilityjs'; -import axios from '../../../lib/utils/axios_utils'; -import httpStatusCodes from '../../../lib/utils/http_status'; -import Poll from '../../../lib/utils/poll'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; +import Poll from '~/lib/utils/poll'; import * as types from './mutation_types'; export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths); diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 85652301f4d..c9e4aab1db1 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -12,6 +12,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; import CodeIntelligence from '~/code_navigation/components/app.vue'; +import LineHighlighter from '~/blob/line_highlighter'; import getRefMixin from '../mixins/get_ref'; import blobInfoQuery from '../queries/blob_info.query.graphql'; import userInfoQuery from '../queries/user_info.query.graphql'; @@ -192,6 +193,7 @@ export default { window.requestIdleCallback(() => { this.isRenderingLegacyTextViewer = false; + new LineHighlighter(); // eslint-disable-line no-new }); } else { this.legacyRichViewer = html; @@ -301,6 +303,7 @@ export default { :code-navigation-path="blobInfo.codeNavigationPath" :blob-path="blobInfo.path" :path-prefix="blobInfo.projectBlobPathRoot" + :wrap-text-nodes="glFeatures.highlightJs" /> </div> </div> diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index cbe18ea396e..81d2168e2ce 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -8,6 +8,7 @@ const viewers = { pdf: () => import('./pdf_viewer.vue'), lfs: () => import('./lfs_viewer.vue'), audio: () => import('./audio_viewer.vue'), + svg: () => import('./image_viewer.vue'), }; export const loadViewer = (type, isUsingLfs) => { diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 08faf19d12a..84c9f9d0bbe 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -9,8 +9,7 @@ import { } from '@gitlab/ui'; import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { __ } from '../../locale'; +import { __ } from '~/locale'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import projectShortPathQuery from '../queries/project_short_path.query.graphql'; @@ -58,7 +57,7 @@ export default { directives: { GlModal: GlModalDirective, }, - mixins: [getRefMixin, glFeatureFlagsMixin()], + mixins: [getRefMixin], props: { currentPath: { type: String, @@ -176,11 +175,7 @@ export default { return this.canEditTree && !this.$apollo.queries.userPermissions.loading; }, showNewDirectoryModal() { - return ( - this.glFeatures.newDirModal && - this.canEditTree && - !this.$apollo.queries.userPermissions.loading - ); + return this.canEditTree && !this.$apollo.queries.userPermissions.loading; }, dropdownItems() { const items = []; @@ -209,24 +204,13 @@ export default { }, ); - if (this.glFeatures.newDirModal) { - items.push({ - attrs: { - href: '#modal-create-new-dir', - }, - text: __('New directory'), - modalId: NEW_DIRECTORY_MODAL_ID, - }); - } else { - items.push({ - attrs: { - href: '#modal-create-new-dir', - 'data-target': '#modal-create-new-dir', - 'data-toggle': 'modal', - }, - text: __('New directory'), - }); - } + items.push({ + attrs: { + href: '#modal-create-new-dir', + }, + text: __('New directory'), + modalId: NEW_DIRECTORY_MODAL_ID, + }); } else if (this.canCreateMrFromFork) { items.push( { diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index c3d121505b6..2810db33e64 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -10,10 +10,10 @@ import { import defaultAvatarUrl from 'images/no_avatar.png'; import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { sprintf, s__ } from '~/locale'; -import CiIcon from '../../vue_shared/components/ci_icon.vue'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; -import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; @@ -171,7 +171,7 @@ export default { <div class="commit-actions flex-row"> <div v-if="commit.signatureHtml" - v-safe-html:[$options.safeHtmlConfig]="commit.signatureHtml" + v-html="commit.signatureHtml /* eslint-disable-line vue/no-v-html */" ></div> <div v-if="commit.pipeline" class="ci-status-link"> <gl-link diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 0a2ed753e38..c2323d6b286 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,7 +1,7 @@ <script> import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { sprintf, __ } from '../../../locale'; +import { sprintf, __ } from '~/locale'; import getRefMixin from '../../mixins/get_ref'; import projectPathQuery from '../../queries/project_path.query.graphql'; import TableHeader from './header.vue'; diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 130ebf77361..2200e999c75 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -2,7 +2,7 @@ import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import createFlash from '~/flash'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { __ } from '../../locale'; +import { __ } from '~/locale'; import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index 8aba91eedf7..accc9926a57 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -1,12 +1,14 @@ <script> import { GlBadge, GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; import { formatNumber } from '~/locale'; +import { fetchPolicies } from '~/lib/graphql'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; +import RunnerBulkDelete from '../components/runner_bulk_delete.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerName from '../components/runner_name.vue'; import RunnerStats from '../components/stat/runner_stats.vue'; @@ -14,6 +16,7 @@ import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; +import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { tagTokenConfig } from '../components/search_tokens/tag_token_config'; import { @@ -37,7 +40,7 @@ import { captureException } from '../sentry_utils'; const runnersCountSmartQuery = { query: runnersAdminCountQuery, - fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + fetchPolicy: fetchPolicies.NETWORK_ONLY, update(data) { return data?.runners?.count; }, @@ -53,6 +56,7 @@ export default { GlLink, RegistrationDropdown, RunnerFilteredSearchBar, + RunnerBulkDelete, RunnerList, RunnerName, RunnerStats, @@ -60,6 +64,8 @@ export default { RunnerTypeTabs, RunnerActionsCell, }, + mixins: [glFeatureFlagMixin()], + inject: ['localMutations'], props: { registrationToken: { type: String, @@ -78,10 +84,7 @@ export default { apollo: { runners: { query: runnersAdminQuery, - // Runners can be updated by users directly in this list. - // A "cache and network" policy prevents outdated filtered - // results. - fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; }, @@ -176,6 +179,7 @@ export default { }, searchTokens() { return [ + pausedTokenConfig, statusTokenConfig, { ...tagTokenConfig, @@ -183,6 +187,11 @@ export default { }, ]; }, + isBulkDeleteEnabled() { + // Feature flag: admin_runners_bulk_delete + // Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/issues/353981 + return this.glFeatures.adminRunnersBulkDelete; + }, }, watch: { search: { @@ -224,13 +233,29 @@ export default { } return ''; }, + refetchFilteredCounts() { + this.$apollo.queries.allRunnersCount.refetch(); + this.$apollo.queries.instanceRunnersCount.refetch(); + this.$apollo.queries.groupRunnersCount.refetch(); + this.$apollo.queries.projectRunnersCount.refetch(); + }, + onToggledPaused() { + // When a runner is Paused, the tab count can + // become stale, refetch outdated counts. + this.refetchFilteredCounts(); + }, onDeleted({ message }) { this.$root.$toast?.show(message); - this.$apollo.queries.runners.refetch(); }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, + onChecked({ runner, isChecked }) { + this.localMutations.setRunnerChecked({ + runner, + isChecked, + }); + }, }, filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, @@ -279,7 +304,13 @@ export default { {{ __('No runners found') }} </div> <template v-else> - <runner-list :runners="runners.items" :loading="runnersLoading"> + <runner-bulk-delete v-if="isBulkDeleteEnabled" /> + <runner-list + :runners="runners.items" + :loading="runnersLoading" + :checkable="isBulkDeleteEnabled" + @checked="onChecked" + > <template #runner-name="{ runner }"> <gl-link :href="runner.adminUrl"> <runner-name :runner="runner" /> @@ -289,6 +320,7 @@ export default { <runner-actions-cell :runner="runner" :edit-url="runner.editAdminUrl" + @toggledPaused="onToggledPaused" @deleted="onDeleted" /> </template> diff --git a/app/assets/javascripts/runner/admin_runners/index.js b/app/assets/javascripts/runner/admin_runners/index.js index 3b8a8fe9cd1..12e2cb2ee9f 100644 --- a/app/assets/javascripts/runner/admin_runners/index.js +++ b/app/assets/javascripts/runner/admin_runners/index.js @@ -1,9 +1,10 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import { visitUrl } from '~/lib/utils/url_utility'; import { updateOutdatedUrl } from '~/runner/runner_search_utils'; +import createDefaultClient from '~/lib/graphql'; +import { createLocalState } from '../graphql/list/local_state'; import AdminRunnersApp from './admin_runners_app.vue'; Vue.use(GlToast); @@ -25,10 +26,17 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { return null; } - const { runnerInstallHelpPage, registrationToken } = el.dataset; + const { + runnerInstallHelpPage, + registrationToken, + onlineContactTimeoutSecs, + staleTimeoutSecs, + } = el.dataset; + + const { cacheConfig, typeDefs, localMutations } = createLocalState(); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { cacheConfig, typeDefs }), }); return new Vue({ @@ -36,6 +44,9 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { apolloProvider, provide: { runnerInstallHelpPage, + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, }, render(h) { return h(AdminRunnersApp, { diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index c69321de001..7a4760f81ee 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -23,7 +23,7 @@ export default { required: false, }, }, - emits: ['deleted'], + emits: ['toggledPaused', 'deleted'], computed: { canUpdate() { return this.runner.userPermissions?.updateRunner; @@ -33,6 +33,9 @@ export default { }, }, methods: { + onToggledPaused() { + this.$emit('toggledPaused'); + }, onDeleted(value) { this.$emit('deleted', value); }, @@ -43,7 +46,17 @@ export default { <template> <gl-button-group> <runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" /> - <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" /> - <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" /> + <runner-pause-button + v-if="canUpdate" + :runner="runner" + :compact="true" + @toggledPaused="onToggledPaused" + /> + <runner-delete-button + :disabled="!canDelete" + :runner="runner" + :compact="true" + @deleted="onDeleted" + /> </gl-button-group> </template> diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue index 937ec631633..1eb383a1904 100644 --- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue @@ -33,6 +33,9 @@ export default { description() { return this.runner.description; }, + ipAddress() { + return this.runner.ipAddress; + }, }, i18n: { I18N_LOCKED_RUNNER_DESCRIPTION, @@ -53,10 +56,12 @@ export default { :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION" name="lock" /> - <tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child"> - <div class="gl-text-truncate"> - {{ description }} - </div> + <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="description"> + {{ description }} + </tooltip-on-truncate> + <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress"> + <span class="gl-md-display-none gl-lg-display-inline">{{ __('IP Address') }}</span> + <strong>{{ ipAddress }}</strong> </tooltip-on-truncate> </div> </template> diff --git a/app/assets/javascripts/runner/components/registration/registration_token.vue b/app/assets/javascripts/runner/components/registration/registration_token.vue index d54a66ff0e4..68c6429a056 100644 --- a/app/assets/javascripts/runner/components/registration/registration_token.vue +++ b/app/assets/javascripts/runner/components/registration/registration_token.vue @@ -1,16 +1,10 @@ <script> -import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { s__, __ } from '~/locale'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { s__ } from '~/locale'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; export default { components: { - GlButtonGroup, - GlButton, - ModalCopyButton, - }, - directives: { - GlTooltip: GlTooltipDirective, + InputCopyToggleVisibility, }, props: { value: { @@ -19,65 +13,21 @@ export default { default: '', }, }, - data() { - return { - isMasked: true, - }; - }, - computed: { - maskLabel() { - if (this.isMasked) { - return __('Click to reveal'); - } - return __('Click to hide'); - }, - maskIcon() { - if (this.isMasked) { - return 'eye'; - } - return 'eye-slash'; - }, - displayedValue() { - if (this.isMasked && this.value?.length) { - return '*'.repeat(this.value.length); - } - return this.value; - }, - }, methods: { - onToggleMasked() { - this.isMasked = !this.isMasked; - }, - onCopied() { + onCopy() { // value already in the clipboard, simply notify the user this.$toast?.show(s__('Runners|Registration token copied!')); }, }, - i18n: { - copyLabel: s__('Runners|Copy registration token'), - }, + I18N_COPY_BUTTON_TITLE: s__('Runners|Copy registration token'), }; </script> <template> - <gl-button-group> - <gl-button class="gl-font-monospace" data-testid="token-value" label> - {{ displayedValue }} - </gl-button> - <gl-button - v-gl-tooltip - :aria-label="maskLabel" - :title="maskLabel" - :icon="maskIcon" - class="gl-w-auto! gl-flex-shrink-0!" - data-testid="toggle-masked" - @click.stop="onToggleMasked" - /> - <modal-copy-button - class="gl-w-auto! gl-flex-shrink-0!" - :aria-label="$options.i18n.copyLabel" - :title="$options.i18n.copyLabel" - :text="value" - @success="onCopied" - /> - </gl-button-group> + <input-copy-toggle-visibility + class="gl-m-0" + :value="value" + data-testid="token-value" + :copy-button-title="$options.I18N_COPY_BUTTON_TITLE" + @copy="onCopy" + /> </template> diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue index ea8074199a6..38bdfecb7df 100644 --- a/app/assets/javascripts/runner/components/runner_assigned_item.vue +++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue @@ -1,5 +1,6 @@ <script> import { GlAvatar, GlLink } from '@gitlab/ui'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; export default { components: { @@ -25,13 +26,20 @@ export default { default: null, }, }, + AVATAR_SHAPE_OPTION_RECT, }; </script> <template> <div class="gl-display-flex gl-align-items-center gl-py-5"> <gl-link :href="href" data-testid="item-avatar" class="gl-text-decoration-none! gl-mr-3"> - <gl-avatar shape="rect" :entity-name="name" :alt="name" :src="avatarUrl" :size="48" /> + <gl-avatar + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :entity-name="name" + :alt="name" + :src="avatarUrl" + :size="48" + /> </gl-link> <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link> diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/runner/components/runner_bulk_delete.vue new file mode 100644 index 00000000000..50791de0bda --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_bulk_delete.vue @@ -0,0 +1,111 @@ +<script> +import { GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { n__, sprintf } from '~/locale'; +import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; + +export default { + components: { + GlButton, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: ['localMutations'], + data() { + return { + checkedRunnerIds: [], + }; + }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + }, + }, + computed: { + checkedCount() { + return this.checkedRunnerIds.length || 0; + }, + bannerMessage() { + return sprintf( + n__( + 'Runners|%{strongStart}%{count}%{strongEnd} runner selected', + 'Runners|%{strongStart}%{count}%{strongEnd} runners selected', + this.checkedCount, + ), + { + count: this.checkedCount, + }, + ); + }, + modalTitle() { + return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount); + }, + modalHtmlMessage() { + return sprintf( + n__( + 'Runners|%{strongStart}%{count}%{strongEnd} runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', + 'Runners|%{strongStart}%{count}%{strongEnd} runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', + this.checkedCount, + ), + { + strongStart: '<strong>', + strongEnd: '</strong>', + count: this.checkedCount, + }, + false, + ); + }, + primaryBtnText() { + return n__( + 'Runners|Permanently delete %d runner', + 'Runners|Permanently delete %d runners', + this.checkedCount, + ); + }, + }, + methods: { + onClearChecked() { + this.localMutations.clearChecked(); + }, + onClickDelete: ignoreWhilePending(async function onClickDelete() { + const confirmed = await confirmAction(null, { + title: this.modalTitle, + modalHtmlMessage: this.modalHtmlMessage, + primaryBtnVariant: 'danger', + primaryBtnText: this.primaryBtnText, + }); + + if (confirmed) { + // TODO Call $apollo.mutate with list of runner + // ids in `this.checkedRunnerIds`. + // See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/ + } + }), + }, +}; +</script> + +<template> + <div v-if="checkedCount" class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"> + <div class="gl-display-flex gl-align-items-center"> + <div> + <gl-sprintf :message="bannerMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </div> + <div class="gl-ml-auto"> + <gl-button data-testid="clear-btn" variant="default" @click="onClearChecked">{{ + s__('Runners|Clear selection') + }}</gl-button> + <gl-button data-testid="delete-btn" variant="danger" @click="onClickDelete">{{ + s__('Runners|Delete selected') + }}</gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_delete_button.vue b/app/assets/javascripts/runner/components/runner_delete_button.vue index 854c983f4da..b58665ecbc9 100644 --- a/app/assets/javascripts/runner/components/runner_delete_button.vue +++ b/app/assets/javascripts/runner/components/runner_delete_button.vue @@ -5,7 +5,12 @@ import { createAlert } from '~/flash'; import { sprintf } from '~/locale'; import { captureException } from '~/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants'; +import { + I18N_DELETE_DISABLED_MANY_PROJECTS, + I18N_DELETE_DISABLED_UNKNOWN_REASON, + I18N_DELETE_RUNNER, + I18N_DELETED_TOAST, +} from '../constants'; import RunnerDeleteModal from './runner_delete_modal.vue'; export default { @@ -26,6 +31,11 @@ export default { return runner?.id && runner?.shortSha; }, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, compact: { type: Boolean, required: false, @@ -75,7 +85,14 @@ export default { return null; }, tooltip() { - // Only show tooltip when compact. + if (this.disabled && this.runner.projectCount > 1) { + return I18N_DELETE_DISABLED_MANY_PROJECTS; + } + if (this.disabled) { + return I18N_DELETE_DISABLED_UNKNOWN_REASON; + } + + // Only show basic "delete" tooltip when compact. // Also prevent a "sticky" tooltip: If this button is // disabled, mouseout listeners don't run leaving the tooltip stuck if (this.compact && !this.deleting) { @@ -83,6 +100,14 @@ export default { } return ''; }, + wrapperTabindex() { + if (this.disabled) { + // Trigger tooltip on keyboard-focusable wrapper + // See https://bootstrap-vue.org/docs/directives/tooltip + return '0'; + } + return null; + }, }, methods: { async onDelete() { @@ -90,31 +115,37 @@ export default { // should only change back if the operation fails. this.deleting = true; try { - const { - data: { - runnerDelete: { errors }, - }, - } = await this.$apollo.mutate({ + await this.$apollo.mutate({ mutation: runnerDeleteMutation, variables: { input: { id: this.runner.id, }, }, + update: (cache, { data }) => { + const { errors } = data.runnerDelete; + + if (errors?.length) { + this.onError(new Error(errors.join(' '))); + return; + } + + this.$emit('deleted', { + message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }), + }); + + // Remove deleted runner from the cache + const cacheId = cache.identify(this.runner); + cache.evict({ id: cacheId }); + cache.gc(); + }, }); - if (errors && errors.length) { - throw new Error(errors.join(' ')); - } else { - this.$emit('deleted', { - message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }), - }); - } } catch (e) { - this.deleting = false; this.onError(e); } }, onError(error) { + this.deleting = false; const { message } = error; createAlert({ message }); @@ -125,20 +156,22 @@ export default { </script> <template> - <gl-button - v-gl-tooltip.hover.viewport="tooltip" - v-gl-modal="runnerDeleteModalId" - :aria-label="ariaLabel" - :icon="icon" - :class="buttonClass" - :loading="deleting" - variant="danger" - > - {{ buttonContent }} + <div v-gl-tooltip="tooltip" class="btn-group" :tabindex="wrapperTabindex"> + <gl-button + v-gl-modal="runnerDeleteModalId" + :aria-label="ariaLabel" + :icon="icon" + :class="buttonClass" + :loading="deleting" + :disabled="disabled" + variant="danger" + > + {{ buttonContent }} + </gl-button> <runner-delete-modal :modal-id="runnerDeleteModalId" :runner-name="runnerName" @primary="onDelete" /> - </gl-button> + </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue index eb77babcc57..b25d92d049e 100644 --- a/app/assets/javascripts/runner/components/runner_jobs.vue +++ b/app/assets/javascripts/runner/components/runner_jobs.vue @@ -1,5 +1,5 @@ <script> -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { createAlert } from '~/flash'; import runnerJobsQuery from '../graphql/details/runner_jobs.query.graphql'; import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants'; diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index 51749b0255f..dcfd4b84dd2 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -4,17 +4,30 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/toolt import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; import { formatJobCount, tableField } from '../utils'; import RunnerSummaryCell from './cells/runner_summary_cell.vue'; +import RunnerStatusPopover from './runner_status_popover.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerTags from './runner_tags.vue'; +const defaultFields = [ + tableField({ key: 'status', label: s__('Runners|Status') }), + tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }), + tableField({ key: 'version', label: __('Version') }), + tableField({ key: 'jobCount', label: __('Jobs') }), + tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }), + tableField({ key: 'contactedAt', label: __('Last contact') }), + tableField({ key: 'actions', label: '' }), +]; + export default { components: { GlTableLite, GlSkeletonLoader, TooltipOnTruncate, TimeAgo, + RunnerStatusPopover, RunnerSummaryCell, RunnerTags, RunnerStatusCell, @@ -22,7 +35,20 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + skip() { + return !this.checkable; + }, + }, + }, props: { + checkable: { + type: Boolean, + required: false, + default: false, + }, loading: { type: Boolean, required: false, @@ -33,6 +59,10 @@ export default { required: true, }, }, + emits: ['checked'], + data() { + return { checkedRunnerIds: [] }; + }, computed: { tableClass() { // <gl-table-lite> does not provide a busy state, add @@ -42,6 +72,18 @@ export default { 'gl-opacity-6': this.loading, }; }, + fields() { + if (this.checkable) { + const checkboxField = tableField({ + key: 'checkbox', + label: s__('Runners|Checkbox'), + thClasses: ['gl-w-9'], + tdClass: ['gl-text-center'], + }); + return [checkboxField, ...defaultFields]; + } + return defaultFields; + }, }, methods: { formatJobCount(jobCount) { @@ -55,17 +97,16 @@ export default { } return {}; }, + onCheckboxChange(runner, isChecked) { + this.$emit('checked', { + runner, + isChecked, + }); + }, + isChecked(runner) { + return this.checkedRunnerIds.includes(runner.id); + }, }, - fields: [ - tableField({ key: 'status', label: s__('Runners|Status') }), - tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }), - tableField({ key: 'version', label: __('Version') }), - tableField({ key: 'ipAddress', label: __('IP') }), - tableField({ key: 'jobCount', label: __('Jobs') }), - tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }), - tableField({ key: 'contactedAt', label: __('Last contact') }), - tableField({ key: 'actions', label: '' }), - ], }; </script> <template> @@ -74,13 +115,34 @@ export default { :aria-busy="loading" :class="tableClass" :items="runners" - :fields="$options.fields" + :fields="fields" :tbody-tr-attr="runnerTrAttr" data-testid="runner-list" stacked="md" primary-key="id" fixed > + <template #head(checkbox)> + <!-- + Checkbox to select all to be added here + See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/ + --> + <span></span> + </template> + + <template #cell(checkbox)="{ item }"> + <input + type="checkbox" + :checked="isChecked(item)" + @change="onCheckboxChange(item, $event.target.checked)" + /> + </template> + + <template #head(status)="{ label }"> + {{ label }} + <runner-status-popover /> + </template> + <template #cell(status)="{ item }"> <runner-status-cell :runner="item" /> </template> @@ -99,12 +161,6 @@ export default { </tooltip-on-truncate> </template> - <template #cell(ipAddress)="{ item: { ipAddress } }"> - <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress"> - {{ ipAddress }} - </tooltip-on-truncate> - </template> - <template #cell(jobCount)="{ item: { jobCount } }"> {{ formatJobCount(jobCount) }} </template> diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue index c88634bfbd9..334e5f6023a 100644 --- a/app/assets/javascripts/runner/components/runner_pause_button.vue +++ b/app/assets/javascripts/runner/components/runner_pause_button.vue @@ -24,6 +24,7 @@ export default { default: false, }, }, + emits: ['toggledPaused'], data() { return { updating: false, @@ -83,6 +84,7 @@ export default { if (errors && errors.length) { throw new Error(errors.join(' ')); } + this.$emit('toggledPaused'); } catch (e) { this.onError(e); } finally { diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue index f8ec29b8a24..d080d34fdd3 100644 --- a/app/assets/javascripts/runner/components/runner_projects.vue +++ b/app/assets/javascripts/runner/components/runner_projects.vue @@ -1,5 +1,5 @@ <script> -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { sprintf, formatNumber } from '~/locale'; import { createAlert } from '~/flash'; import runnerProjectsQuery from '../graphql/details/runner_projects.query.graphql'; diff --git a/app/assets/javascripts/runner/components/runner_status_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue index 6d0445ecb7a..073d0a49f59 100644 --- a/app/assets/javascripts/runner/components/runner_status_badge.vue +++ b/app/assets/javascripts/runner/components/runner_status_badge.vue @@ -3,10 +3,11 @@ import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { - I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, - I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION, - I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, - I18N_STALE_RUNNER_DESCRIPTION, + I18N_ONLINE_TIMEAGO_TOOLTIP, + I18N_NEVER_CONTACTED_TOOLTIP, + I18N_OFFLINE_TIMEAGO_TOOLTIP, + I18N_STALE_TIMEAGO_TOOLTIP, + I18N_STALE_NEVER_CONTACTED_TOOLTIP, STATUS_ONLINE, STATUS_NEVER_CONTACTED, STATUS_OFFLINE, @@ -32,7 +33,7 @@ export default { return getTimeago().format(this.runner.contactedAt); } // Prevent "just now" from being rendered, in case data is missing. - return __('n/a'); + return __('never'); }, badge() { switch (this.runner?.status) { @@ -40,35 +41,39 @@ export default { return { variant: 'success', label: s__('Runners|online'), - tooltip: sprintf(I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, { - timeAgo: this.contactedAtTimeAgo, - }), + tooltip: this.timeAgoTooltip(I18N_ONLINE_TIMEAGO_TOOLTIP), }; case STATUS_NEVER_CONTACTED: return { variant: 'muted', label: s__('Runners|never contacted'), - tooltip: I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION, + tooltip: I18N_NEVER_CONTACTED_TOOLTIP, }; case STATUS_OFFLINE: return { variant: 'muted', label: s__('Runners|offline'), - tooltip: sprintf(I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, { - timeAgo: this.contactedAtTimeAgo, - }), + tooltip: this.timeAgoTooltip(I18N_OFFLINE_TIMEAGO_TOOLTIP), }; case STATUS_STALE: return { variant: 'warning', label: s__('Runners|stale'), - tooltip: I18N_STALE_RUNNER_DESCRIPTION, + // runner may have contacted (or not) and be stale: consider both cases. + tooltip: this.runner.contactedAt + ? this.timeAgoTooltip(I18N_STALE_TIMEAGO_TOOLTIP) + : I18N_STALE_NEVER_CONTACTED_TOOLTIP, }; default: return null; } }, }, + methods: { + timeAgoTooltip(text) { + return sprintf(text, { timeAgo: this.contactedAtTimeAgo }); + }, + }, }; </script> <template> diff --git a/app/assets/javascripts/runner/components/runner_status_popover.vue b/app/assets/javascripts/runner/components/runner_status_popover.vue new file mode 100644 index 00000000000..5b22f7828a1 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_status_popover.vue @@ -0,0 +1,75 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { duration } from '~/lib/utils/datetime/timeago_utility'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { + I18N_STATUS_POPOVER_TITLE, + I18N_STATUS_POPOVER_NEVER_CONTACTED, + I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION, + I18N_STATUS_POPOVER_ONLINE, + I18N_STATUS_POPOVER_ONLINE_DESCRIPTION, + I18N_STATUS_POPOVER_OFFLINE, + I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION, + I18N_STATUS_POPOVER_STALE, + I18N_STATUS_POPOVER_STALE_DESCRIPTION, +} from '~/runner/constants'; + +export default { + name: 'RunnerStatusPopover', + components: { + GlSprintf, + HelpPopover, + }, + inject: ['onlineContactTimeoutSecs', 'staleTimeoutSecs'], + computed: { + onlineContactTimeoutDuration() { + return duration(this.onlineContactTimeoutSecs * 1000); + }, + staleTimeoutDuration() { + return duration(this.staleTimeoutSecs * 1000); + }, + }, + I18N_STATUS_POPOVER_TITLE, + I18N_STATUS_POPOVER_NEVER_CONTACTED, + I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION, + I18N_STATUS_POPOVER_ONLINE, + I18N_STATUS_POPOVER_ONLINE_DESCRIPTION, + I18N_STATUS_POPOVER_OFFLINE, + I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION, + I18N_STATUS_POPOVER_STALE, + I18N_STATUS_POPOVER_STALE_DESCRIPTION, +}; +</script> + +<template> + <help-popover> + <template #title>{{ $options.I18N_STATUS_POPOVER_TITLE }}</template> + + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_NEVER_CONTACTED }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_ONLINE }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_ONLINE_DESCRIPTION"> + <template #elapsedTime>{{ onlineContactTimeoutDuration }}</template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_OFFLINE }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION"> + <template #elapsedTime>{{ onlineContactTimeoutDuration }}</template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_STALE }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_STALE_DESCRIPTION"> + <template #elapsedTime>{{ staleTimeoutDuration }}</template> + </gl-sprintf> + </p> + </help-popover> +</template> diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue index e44450a2a8d..119e5236f85 100644 --- a/app/assets/javascripts/runner/components/runner_update_form.vue +++ b/app/assets/javascripts/runner/components/runner_update_form.vue @@ -138,7 +138,11 @@ export default { > {{ __('Lock to current projects') }} <template #help> - {{ s__('Runners|Use the runner for the currently assigned projects only.') }} + {{ + s__( + 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.', + ) + }} </template> </gl-form-checkbox> diff --git a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js new file mode 100644 index 00000000000..1bab875a8a1 --- /dev/null +++ b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js @@ -0,0 +1,28 @@ +import { __, s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { PARAM_KEY_PAUSED } from '../../constants'; + +const options = [ + { value: 'true', title: __('Yes') }, + { value: 'false', title: __('No') }, +]; + +export const pausedTokenConfig = { + icon: 'pause', + title: s__('Runners|Paused'), + type: PARAM_KEY_PAUSED, + token: BaseToken, + unique: true, + options: options.map(({ value, title }) => ({ + value, + // Replace whitespace with a special character to avoid + // splitting this value. + // Replacing in each option, as translations may also + // contain spaces! + // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142 + // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 + title: title.replace(' ', '\u00a0'), + })), + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js index 79038eb8228..f28bd491ea5 100644 --- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js @@ -2,8 +2,6 @@ import { __, s__ } from '~/locale'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { - STATUS_ACTIVE, - STATUS_PAUSED, STATUS_ONLINE, STATUS_OFFLINE, STATUS_NEVER_CONTACTED, @@ -12,8 +10,6 @@ import { } from '../../constants'; const options = [ - { value: STATUS_ACTIVE, title: s__('Runners|Active') }, - { value: STATUS_PAUSED, title: s__('Runners|Paused') }, { value: STATUS_ONLINE, title: s__('Runners|Online') }, { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, { value: STATUS_NEVER_CONTACTED, title: s__('Runners|Never contacted') }, diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index bd5be2175ad..b9621c26b59 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -21,18 +21,39 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__( ); export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects'); -// Status -export const I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION = s__( +// Status help popover +export const I18N_STATUS_POPOVER_TITLE = s__('Runners|Runner statuses'); + +export const I18N_STATUS_POPOVER_NEVER_CONTACTED = s__('Runners|Never contacted:'); +export const I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION = s__( + 'Runners|Runner has never contacted GitLab (when you register a runner, use %{codeStart}gitlab-runner run%{codeEnd} to bring it online)', +); +export const I18N_STATUS_POPOVER_ONLINE = s__('Runners|Online:'); +export const I18N_STATUS_POPOVER_ONLINE_DESCRIPTION = s__( + 'Runners|Runner has contacted GitLab within the last %{elapsedTime}', +); +export const I18N_STATUS_POPOVER_OFFLINE = s__('Runners|Offline:'); +export const I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION = s__( + 'Runners|Runner has not contacted GitLab in more than %{elapsedTime}', +); +export const I18N_STATUS_POPOVER_STALE = s__('Runners|Stale:'); +export const I18N_STATUS_POPOVER_STALE_DESCRIPTION = s__( + 'Runners|Runner has not contacted GitLab in more than %{elapsedTime}', +); + +// Status tooltips +export const I18N_ONLINE_TIMEAGO_TOOLTIP = s__( 'Runners|Runner is online; last contact was %{timeAgo}', ); -export const I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION = s__( - 'Runners|This runner has never contacted this instance', +export const I18N_NEVER_CONTACTED_TOOLTIP = s__('Runners|Runner has never contacted this instance'); +export const I18N_OFFLINE_TIMEAGO_TOOLTIP = s__( + 'Runners|Runner is offline; last contact was %{timeAgo}', ); -export const I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION = s__( - 'Runners|No recent contact from this runner; last contact was %{timeAgo}', +export const I18N_STALE_TIMEAGO_TOOLTIP = s__( + 'Runners|Runner is stale; last contact was %{timeAgo}', ); -export const I18N_STALE_RUNNER_DESCRIPTION = s__( - 'Runners|No contact from this runner in over 3 months', +export const I18N_STALE_NEVER_CONTACTED_TOOLTIP = s__( + 'Runners|Runner is stale; it has never contacted this instance', ); // Actions @@ -46,15 +67,23 @@ export const I18N_RESUME = __('Resume'); export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs'); export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); +export const I18N_DELETE_DISABLED_MANY_PROJECTS = s__( + 'Runners|Multi-project runners cannot be deleted', +); +export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__( + 'Runners|Runner cannot be deleted, please contact your administrator', +); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); -export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects'); +export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( + 'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.', +); // Runner details export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); export const I18N_NONE = __('None'); -export const I18N_NO_JOBS_FOUND = s__('Runner|This runner has not run any jobs.'); +export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.'); // Styles @@ -66,6 +95,7 @@ export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100'; // - GlFilteredSearch tokens type export const PARAM_KEY_STATUS = 'status'; +export const PARAM_KEY_PAUSED = 'paused'; export const PARAM_KEY_RUNNER_TYPE = 'runner_type'; export const PARAM_KEY_TAG = 'tag'; export const PARAM_KEY_SEARCH = 'search'; @@ -83,9 +113,6 @@ export const PROJECT_TYPE = 'PROJECT_TYPE'; // CiRunnerStatus -export const STATUS_ACTIVE = 'ACTIVE'; -export const STATUS_PAUSED = 'PAUSED'; - export const STATUS_ONLINE = 'ONLINE'; export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED'; export const STATUS_OFFLINE = 'OFFLINE'; diff --git a/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql index 2b1decd3ddd..14585e62bf2 100644 --- a/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql +++ b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, $after: String) { runner(id: $id) { diff --git a/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql index f97237b8267..cb27de7c200 100644 --- a/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql +++ b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getRunnerProjects( $id: CiRunnerID! diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql index 8df4c2fc65c..5d0450e7418 100644 --- a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql @@ -1,11 +1,12 @@ #import "~/runner/graphql/list/list_item.fragment.graphql" -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getRunners( $before: String $after: String $first: Int $last: Int + $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType $tagList: [String!] @@ -17,6 +18,7 @@ query getRunners( after: $after first: $first last: $last + paused: $paused status: $status type: $type tagList: $tagList diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql index 181a4495cae..1dd258a3524 100644 --- a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql @@ -1,10 +1,11 @@ query getRunnersCount( + $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType $tagList: [String!] $search: String ) { - runners(status: $status, type: $type, tagList: $tagList, search: $search) { + runners(paused: $paused, status: $status, type: $type, tagList: $tagList, search: $search) { count } } diff --git a/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql b/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql new file mode 100644 index 00000000000..c01f1edb451 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql @@ -0,0 +1,3 @@ +query getCheckedRunnerIds { + checkedRunnerIds @client +} diff --git a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql index b517f5e89a8..b4f2b5cd8c8 100644 --- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql @@ -1,5 +1,5 @@ #import "~/runner/graphql/list/list_item.fragment.graphql" -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getGroupRunners( $groupFullPath: ID! @@ -7,6 +7,7 @@ query getGroupRunners( $after: String $first: Int $last: Int + $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType $search: String @@ -20,6 +21,7 @@ query getGroupRunners( after: $after first: $first last: $last + paused: $paused status: $status type: $type search: $search @@ -30,6 +32,7 @@ query getGroupRunners( editUrl node { ...ListItem + projectCount # Used to determine why some project runners can't be deleted } } pageInfo { diff --git a/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql index 554eb09e372..958b4ea0dd3 100644 --- a/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql @@ -1,5 +1,6 @@ query getGroupRunnersCount( $groupFullPath: ID! + $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType $tagList: [String!] @@ -9,6 +10,7 @@ query getGroupRunnersCount( id # Apollo required runners( membership: DESCENDANTS + paused: $paused status: $status type: $type tagList: $tagList diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js new file mode 100644 index 00000000000..e87bc72c86a --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/local_state.js @@ -0,0 +1,63 @@ +import { makeVar } from '@apollo/client/core'; +import typeDefs from './typedefs.graphql'; + +/** + * Local state for checkable runner items. + * + * Usage: + * + * ``` + * import { createLocalState } from '~/runner/graphql/list/local_state'; + * + * // initialize local state + * const { cacheConfig, typeDefs, localMutations } = createLocalState(); + * + * // configure the client + * apolloClient = createApolloClient({}, { cacheConfig, typeDefs }); + * + * // modify local state + * localMutations.setRunnerChecked( ... ) + * ``` + * + * Note: Currently only in use behind a feature flag: + * admin_runners_bulk_delete for the admin list, rollout issue: + * https://gitlab.com/gitlab-org/gitlab/-/issues/353981 + * + * @returns {Object} An object to configure an Apollo client: + * contains cacheConfig, typeDefs, localMutations. + */ +export const createLocalState = () => { + const checkedRunnerIdsVar = makeVar({}); + + const cacheConfig = { + typePolicies: { + Query: { + fields: { + checkedRunnerIds() { + return Object.entries(checkedRunnerIdsVar()) + .filter(([, isChecked]) => isChecked) + .map(([key]) => key); + }, + }, + }, + }, + }; + + const localMutations = { + setRunnerChecked({ runner, isChecked }) { + checkedRunnerIdsVar({ + ...checkedRunnerIdsVar(), + [runner.id]: isChecked, + }); + }, + clearChecked() { + checkedRunnerIdsVar({}); + }, + }; + + return { + cacheConfig, + typeDefs, + localMutations, + }; +}; diff --git a/app/assets/javascripts/runner/graphql/list/typedefs.graphql b/app/assets/javascripts/runner/graphql/list/typedefs.graphql new file mode 100644 index 00000000000..24e9e20cc8c --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/typedefs.graphql @@ -0,0 +1,3 @@ +extend type Query { + checkedRunnerIds: [ID!]! +} diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index 35fd7fff6d3..b299d7c40fe 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -1,9 +1,9 @@ <script> import { GlBadge, GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; import { formatNumber } from '~/locale'; +import { fetchPolicies } from '~/lib/graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; @@ -14,6 +14,7 @@ import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; +import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { GROUP_FILTERED_SEARCH_NAMESPACE, @@ -35,7 +36,7 @@ import { captureException } from '../sentry_utils'; const runnersCountSmartQuery = { query: groupRunnersCountQuery, - fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + fetchPolicy: fetchPolicies.NETWORK_ONLY, update(data) { return data?.group?.runners?.count; }, @@ -85,10 +86,7 @@ export default { apollo: { runners: { query: groupRunnersQuery, - // Runners can be updated by users directly in this list. - // A "cache and network" policy prevents outdated filtered - // results. - fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; }, @@ -192,7 +190,7 @@ export default { return !this.runnersLoading && !this.runners.items.length; }, searchTokens() { - return [statusTokenConfig]; + return [pausedTokenConfig, statusTokenConfig]; }, filteredSearchNamespace() { return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`; @@ -241,9 +239,18 @@ export default { editUrl(runner) { return this.runners.urlsById[runner.id]?.edit; }, + refetchFilteredCounts() { + this.$apollo.queries.allRunnersCount.refetch(); + this.$apollo.queries.groupRunnersCount.refetch(); + this.$apollo.queries.projectRunnersCount.refetch(); + }, + onToggledPaused() { + // When a runner is Paused, the tab count can + // become stale, refetch outdated counts. + this.refetchFilteredCounts(); + }, onDeleted({ message }) { this.$root.$toast?.show(message); - this.$apollo.queries.runners.refetch(); }, reportToSentry(error) { captureException({ error, component: this.$options.name }); @@ -302,7 +309,12 @@ export default { </gl-link> </template> <template #runner-actions-cell="{ runner }"> - <runner-actions-cell :runner="runner" :edit-url="editUrl(runner)" @deleted="onDeleted" /> + <runner-actions-cell + :runner="runner" + :edit-url="editUrl(runner)" + @toggledPaused="onToggledPaused" + @deleted="onDeleted" + /> </template> </runner-list> <runner-pagination diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js index 60b7a7ab541..0dade30f820 100644 --- a/app/assets/javascripts/runner/group_runners/index.js +++ b/app/assets/javascripts/runner/group_runners/index.js @@ -20,6 +20,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => { groupId, groupFullPath, groupRunnersLimitedCount, + onlineContactTimeoutSecs, + staleTimeoutSecs, } = el.dataset; const apolloProvider = new VueApollo({ @@ -32,6 +34,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => { provide: { runnerInstallHelpPage, groupId, + onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10), + staleTimeoutSecs: parseInt(staleTimeoutSecs, 10), }, render(h) { return h(GroupRunnersApp, { diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index fe141332be3..5e3c412ddb6 100644 --- a/app/assets/javascripts/runner/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -5,7 +5,9 @@ import { urlQueryToFilter, prepareTokens, } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { + PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG, @@ -83,6 +85,19 @@ const getPaginationFromParams = (params) => { // Outdated URL parameters const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; +const STATUS_ACTIVE = 'ACTIVE'; +const STATUS_PAUSED = 'PAUSED'; + +/** + * Replaces params into a URL + * + * @param {String} url - Original URL + * @param {Object} params - Query parameters to update + * @returns Updated URL + */ +const updateUrlParams = (url, params = {}) => { + return setUrlParams(params, url, false, true, true); +}; /** * Returns an updated URL for old (or deprecated) admin runner URLs. @@ -98,14 +113,26 @@ export const updateOutdatedUrl = (url = window.location.href) => { const params = queryToObject(query, { gatherArrays: true }); - const runnerType = params[PARAM_KEY_STATUS]?.[0] || null; - if (runnerType === STATUS_NOT_CONNECTED) { - const updatedParams = { - [PARAM_KEY_STATUS]: [STATUS_NEVER_CONTACTED], - }; - return setUrlParams(updatedParams, url, false, true, true); + const status = params[PARAM_KEY_STATUS]?.[0] || null; + + switch (status) { + case STATUS_NOT_CONNECTED: + return updateUrlParams(url, { + [PARAM_KEY_STATUS]: [STATUS_NEVER_CONTACTED], + }); + case STATUS_ACTIVE: + return updateUrlParams(url, { + [PARAM_KEY_PAUSED]: ['false'], + [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! + }); + case STATUS_PAUSED: + return updateUrlParams(url, { + [PARAM_KEY_PAUSED]: ['true'], + [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! + }); + default: + return null; } - return null; }; /** @@ -121,7 +148,7 @@ export const fromUrlQueryToSearch = (query = window.location.search) => { runnerType, filters: prepareTokens( urlQueryToFilter(query, { - filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_TAG], + filterNamesAllowList: [PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG], filteredSearchTermKey: PARAM_KEY_SEARCH, }), ), @@ -195,6 +222,12 @@ export const fromSearchToVariables = ({ filterVariables.search = queryObj[PARAM_KEY_SEARCH]; filterVariables.tagList = queryObj[PARAM_KEY_TAG]; + if (queryObj[PARAM_KEY_PAUSED]) { + filterVariables.paused = parseBoolean(queryObj[PARAM_KEY_PAUSED]); + } else { + filterVariables.paused = undefined; + } + if (runnerType) { filterVariables.type = runnerType; } diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/runner/utils.js index 6e4c8c45e7b..1f7794720de 100644 --- a/app/assets/javascripts/runner/utils.js +++ b/app/assets/javascripts/runner/utils.js @@ -24,7 +24,7 @@ export const formatJobCount = (jobCount) => { * @param {Object} options * @returns Field object to add to GlTable fields */ -export const tableField = ({ key, label = '', thClasses = [] }) => { +export const tableField = ({ key, label = '', thClasses = [], ...options }) => { return { key, label, @@ -32,6 +32,7 @@ export const tableField = ({ key, label = '', thClasses = [] }) => { tdAttr: { 'data-testid': `td-${key}`, }, + ...options, }; }; diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index a6af5644681..40513a7f363 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -18,31 +18,35 @@ export const fetchGroups = ({ commit }, search) => { }); }; -export const fetchProjects = ({ commit, state }, search) => { +export const fetchProjects = ({ commit, state }, search, emptyCallback = () => {}) => { commit(types.REQUEST_PROJECTS); const groupId = state.query?.group_id; - const callback = (data) => { - if (data) { - commit(types.RECEIVE_PROJECTS_SUCCESS, data); - } else { - createFlash({ message: __('There was an error fetching projects') }); - commit(types.RECEIVE_PROJECTS_ERROR); - } + + const handleCatch = () => { + createFlash({ message: __('There was an error fetching projects') }); + commit(types.RECEIVE_PROJECTS_ERROR); + }; + const handleSuccess = ({ data }) => { + commit(types.RECEIVE_PROJECTS_SUCCESS, data); }; if (groupId) { - // TODO (https://gitlab.com/gitlab-org/gitlab/-/issues/323331): For errors `createFlash` is called twice; in `callback` and in `Api.groupProjects` Api.groupProjects( groupId, search, - { order_by: 'similarity', with_shared: false, include_subgroups: true }, - callback, - ); + { + order_by: 'similarity', + with_shared: false, + include_subgroups: true, + }, + emptyCallback, + true, + ) + .then(handleSuccess) + .catch(handleCatch); } else { // The .catch() is due to the API method not handling a rejection properly - Api.projects(search, { order_by: 'similarity' }, callback).catch(() => { - callback(); - }); + Api.projects(search, { order_by: 'similarity' }).then(handleSuccess).catch(handleCatch); } }; diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js index 6b56ff0b5e5..f8198017bf8 100644 --- a/app/assets/javascripts/search/store/utils.js +++ b/app/assets/javascripts/search/store/utils.js @@ -1,4 +1,4 @@ -import AccessorUtilities from '../../lib/utils/accessor'; +import AccessorUtilities from '~/lib/utils/accessor'; import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY, SIDEBAR_PARAMS } from './constants'; function extractKeys(object, keyList) { diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue index 42d6444e690..a4254a355a2 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue @@ -2,6 +2,7 @@ import { GlDropdownItem, GlAvatar, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; export default { name: 'SearchableDropdownItem', @@ -46,6 +47,7 @@ export default { return highlight(this.item[this.name], this.searchText); }, }, + AVATAR_SHAPE_OPTION_RECT, }; </script> @@ -61,7 +63,7 @@ export default { :src="item.avatar_url" :entity-id="item.id" :entity-name="item[name]" - shape="rect" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" :size="32" /> <div class="gl-display-flex gl-flex-direction-column"> diff --git a/app/assets/javascripts/search_settings/components/search_settings.vue b/app/assets/javascripts/search_settings/components/search_settings.vue index 3e23b8a3435..f6439c6f4c4 100644 --- a/app/assets/javascripts/search_settings/components/search_settings.vue +++ b/app/assets/javascripts/search_settings/components/search_settings.vue @@ -1,6 +1,6 @@ <script> import { GlSearchBoxByType } from '@gitlab/ui'; -import { uniq, escapeRegExp } from 'lodash'; +import { escapeRegExp } from 'lodash'; import { EXCLUDED_NODES, HIDE_CLASS, @@ -60,41 +60,42 @@ const hideSectionsExcept = (sectionSelector, visibleSections) => { }); }; -const transformMatchElement = (element, searchTerm) => { - const textStr = element.textContent; +const highlightTextNode = (textNode, searchTerm) => { const escapedSearchTerm = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi'); + const textList = textNode.data.split(escapedSearchTerm); + + return textList.reduce((documentFragment, text) => { + let addElement; - const textList = textStr.split(escapedSearchTerm); - const replaceFragment = document.createDocumentFragment(); - textList.forEach((text) => { - let addElement = document.createTextNode(text); if (escapedSearchTerm.test(text)) { addElement = document.createElement('mark'); addElement.className = `${HIGHLIGHT_CLASS} ${NONE_PADDING_CLASS}`; addElement.textContent = text; escapedSearchTerm.lastIndex = 0; + } else { + addElement = document.createTextNode(text); } - replaceFragment.appendChild(addElement); - }); - return replaceFragment; + documentFragment.appendChild(addElement); + return documentFragment; + }, document.createDocumentFragment()); }; -const highlightElements = (elements = [], searchTerm) => { - elements.forEach((element) => { - const replaceFragment = transformMatchElement(element, searchTerm); - element.innerHTML = ''; - element.appendChild(replaceFragment); +const highlightText = (textNodes = [], searchTerm) => { + textNodes.forEach((textNode) => { + const fragmentWithHighlights = highlightTextNode(textNode, searchTerm); + textNode.parentElement.replaceChild(fragmentWithHighlights, textNode); }); }; -const displayResults = ({ sectionSelector, expandSection, searchTerm }, matches) => { - const elements = matches.map((match) => match.parentElement); - const sections = uniq(elements.map((element) => findSettingsSection(sectionSelector, element))); +const displayResults = ({ sectionSelector, expandSection, searchTerm }, matchingTextNodes) => { + const sections = Array.from( + new Set(matchingTextNodes.map((node) => findSettingsSection(sectionSelector, node))), + ); hideSectionsExcept(sectionSelector, sections); sections.forEach(expandSection); - highlightElements(elements, searchTerm); + highlightText(matchingTextNodes, searchTerm); }; const clearResults = (params) => { @@ -114,13 +115,13 @@ const search = (root, searchTerm) => { : NodeFilter.FILTER_REJECT; }, }); - const results = []; + const textNodes = []; for (let currentNode = iterator.nextNode(); currentNode; currentNode = iterator.nextNode()) { - results.push(currentNode); + textNodes.push(currentNode); } - return results; + return textNodes; }; export default { diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index c48c9067250..ba0120a0a70 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -30,6 +30,7 @@ export const i18n = { securityTrainingDescription: s__( 'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability.', ), + securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'), }; export default { @@ -50,7 +51,7 @@ export default { TrainingProviderList, }, mixins: [glFeatureFlagsMixin()], - inject: ['projectFullPath'], + inject: ['projectFullPath', 'vulnerabilityTrainingDocsPath'], props: { augmentedSecurityFeatures: { type: Array, @@ -143,7 +144,6 @@ export default { <local-storage-sync v-model="autoDevopsEnabledAlertDismissedProjects" :storage-key="$options.autoDevopsEnabledAlertStorageKey" - as-json /> <user-callout-dismisser @@ -262,6 +262,11 @@ export default { <p> {{ $options.i18n.securityTrainingDescription }} </p> + <p> + <gl-link :href="vulnerabilityTrainingDocsPath">{{ + $options.i18n.securityTrainingDoc + }}</gl-link> + </p> </template> <template #features> <training-provider-list /> diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 39a2939f52a..6db28ef0fad 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -50,18 +50,24 @@ export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath( export const DAST_NAME = __('Dynamic Application Security Testing (DAST)'); export const DAST_SHORT_NAME = s__('ciReport|DAST'); -export const DAST_DESCRIPTION = __('Analyze a review version of your web application.'); +export const DAST_DESCRIPTION = s__( + 'ciReport|Analyze a deployed version of your web application for known vulnerabilities by examining it from the outside in. DAST works by simulating external attacks on your application while it is running.', +); export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { anchor: 'enable-dast', }); +export const DAST_BADGE_TEXT = __('Available on-demand'); +export const DAST_BADGE_TOOLTIP = __( + 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects', +); -export const DAST_PROFILES_NAME = __('DAST Scans'); +export const DAST_PROFILES_NAME = __('DAST profiles'); export const DAST_PROFILES_DESCRIPTION = s__( 'SecurityConfiguration|Manage profiles for use by DAST scans.', ); export const DAST_PROFILES_HELP_PATH = helpPagePath('user/application_security/dast/index'); -export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage scans'); +export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles'); export const SECRET_DETECTION_NAME = __('Secret Detection'); export const SECRET_DETECTION_DESCRIPTION = __( @@ -171,18 +177,23 @@ export const securityFeatures = [ type: REPORT_TYPE_SAST_IAC, }, { - name: DAST_NAME, - shortName: DAST_SHORT_NAME, - description: DAST_DESCRIPTION, - helpPath: DAST_HELP_PATH, - configurationHelpPath: DAST_CONFIG_HELP_PATH, - type: REPORT_TYPE_DAST, + badge: { + text: DAST_BADGE_TEXT, + tooltipText: DAST_BADGE_TOOLTIP, + variant: 'info', + }, secondary: { type: REPORT_TYPE_DAST_PROFILES, name: DAST_PROFILES_NAME, description: DAST_PROFILES_DESCRIPTION, configurationText: DAST_PROFILES_CONFIG_TEXT, }, + name: DAST_NAME, + shortName: DAST_SHORT_NAME, + description: DAST_DESCRIPTION, + helpPath: DAST_HELP_PATH, + configurationHelpPath: DAST_CONFIG_HELP_PATH, + type: REPORT_TYPE_DAST, }, { name: DEPENDENCY_SCANNING_NAME, diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index cd5ad86e1a8..309e5f21445 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -1,7 +1,9 @@ <script> import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; +import { REPORT_TYPE_SAST_IAC } from '~/vue_shared/security_reports/constants'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; +import FeatureCardBadge from './feature_card_badge.vue'; export default { components: { @@ -9,6 +11,7 @@ export default { GlCard, GlIcon, GlLink, + FeatureCardBadge, ManageViaMr, }, props: { @@ -36,11 +39,14 @@ export default { text: this.$options.i18n.enableFeature, }; - button.category = 'secondary'; + button.category = this.feature.category || 'secondary'; button.text = sprintf(button.text, { feature: this.shortName }); return button; }, + manageViaMrButtonCategory() { + return this.feature.category || 'secondary'; + }, showManageViaMr() { return ManageViaMr.canRender(this.feature); }, @@ -48,19 +54,32 @@ export default { return { 'gl-bg-gray-10': !this.available }; }, statusClasses() { - const { enabled } = this; + const { enabled, hasBadge } = this; return { 'gl-ml-auto': true, 'gl-flex-shrink-0': true, 'gl-text-gray-500': !enabled, 'gl-text-green-500': enabled, + 'gl-w-full': hasBadge, + 'gl-justify-content-space-between': hasBadge, + 'gl-display-flex': hasBadge, + 'gl-mb-4': hasBadge, }; }, hasSecondary() { const { name, description, configurationText } = this.feature.secondary ?? {}; return Boolean(name && description && configurationText); }, + // This condition is a temporary hack to not display any wrong information + // until this BE Bug is fixed: https://gitlab.com/gitlab-org/gitlab/-/issues/350307. + // More Information: https://gitlab.com/gitlab-org/gitlab/-/issues/350307#note_825447417 + isNotSastIACTemporaryHack() { + return this.feature.type !== REPORT_TYPE_SAST_IAC; + }, + hasBadge() { + return Boolean(this.available && this.feature.badge?.text); + }, }, methods: { onError(message) { @@ -81,21 +100,31 @@ export default { <template> <gl-card :class="cardClasses"> - <div class="gl-display-flex gl-align-items-baseline"> + <div + class="gl-display-flex gl-align-items-baseline" + :class="{ 'gl-flex-direction-column-reverse': hasBadge }" + > <h3 class="gl-font-lg gl-m-0 gl-mr-3">{{ feature.name }}</h3> <div + v-if="isNotSastIACTemporaryHack" :class="statusClasses" data-testid="feature-status" :data-qa-selector="`${feature.type}_status`" > + <feature-card-badge + v-if="hasBadge" + :badge="feature.badge" + :badge-href="feature.badge.badgeHref" + /> + <template v-if="enabled"> <gl-icon name="check-circle-filled" /> <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> </template> <template v-else-if="available"> - {{ $options.i18n.notEnabled }} + <span>{{ $options.i18n.notEnabled }}</span> </template> <template v-else> @@ -109,7 +138,7 @@ export default { <gl-link :href="feature.helpPath">{{ $options.i18n.learnMore }}</gl-link> </p> - <template v-if="available"> + <template v-if="available && isNotSastIACTemporaryHack"> <gl-button v-if="feature.configurationPath" :href="feature.configurationPath" @@ -125,7 +154,7 @@ export default { v-else-if="showManageViaMr" :feature="feature" variant="confirm" - category="secondary" + :category="manageViaMrButtonCategory" class="gl-mt-5" :data-qa-selector="`${feature.type}_mr_button`" @error="onError" diff --git a/app/assets/javascripts/security_configuration/components/feature_card_badge.vue b/app/assets/javascripts/security_configuration/components/feature_card_badge.vue new file mode 100644 index 00000000000..0907e33c8e2 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/feature_card_badge.vue @@ -0,0 +1,40 @@ +<script> +import { GlBadge, GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + GlTooltip, + }, + props: { + badge: { + type: Object, + required: true, + }, + badgeHref: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <span> + <gl-tooltip + v-if="badge.tooltipText" + placement="top" + boundary="window" + title="Tooltip title" + :target="() => $refs.badge" + > + {{ badge.tooltipText }} + </gl-tooltip> + <span ref="badge"> + <gl-badge size="sm" :href="badgeHref" :variant="badge.variant"> + {{ badge.text }} + </gl-badge> + </span> + </span> +</template> diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index bb540303cfd..ef50d085ae8 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -3,6 +3,7 @@ import { GlAlert, GlTooltipDirective, GlCard, + GlFormRadio, GlToggle, GlLink, GlSkeletonLoader, @@ -44,6 +45,7 @@ export default { components: { GlAlert, GlCard, + GlFormRadio, GlToggle, GlLink, GlSkeletonLoader, @@ -79,6 +81,9 @@ export default { }; }, computed: { + primaryProviderId() { + return this.securityTrainingProviders.find(({ isPrimary }) => isPrimary)?.id; + }, enabledProviders() { return this.securityTrainingProviders.filter(({ isEnabled }) => isEnabled); }, @@ -256,31 +261,19 @@ export default { {{ __('Learn more.') }} </gl-link> </p> - <!-- Note: The following `div` and it's content will be replaced by 'GlFormRadio' once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1720#note_857342988 is resolved --> - <div - class="gl-form-radio custom-control custom-radio" - data-testid="primary-provider-radio" + <gl-form-radio + :checked="primaryProviderId" + :disabled="!provider.isEnabled" + :value="provider.id" + @change="setPrimaryProvider(provider)" > - <input - :id="`security-training-provider-${provider.id}`" - type="radio" - :checked="provider.isPrimary" - class="custom-control-input" - :disabled="!provider.isEnabled" - @change="setPrimaryProvider(provider)" - /> - <label - class="custom-control-label" - :for="`security-training-provider-${provider.id}`" - > - {{ $options.i18n.primaryTraining }} - </label> + {{ $options.i18n.primaryTraining }} <gl-icon v-gl-tooltip="$options.i18n.primaryTrainingDescription" name="information-o" class="gl-ml-2 gl-cursor-help" /> - </div> + </gl-form-radio> </div> </div> </gl-card> diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index 8416692dd27..65cf1ec27a3 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -25,6 +25,7 @@ export const initSecurityConfiguration = (el) => { gitlabCiHistoryPath, autoDevopsHelpPagePath, autoDevopsPath, + vulnerabilityTrainingDocsPath, } = el.dataset; const { augmentedSecurityFeatures, augmentedComplianceFeatures } = augmentFeatures( @@ -41,6 +42,7 @@ export const initSecurityConfiguration = (el) => { upgradePath, autoDevopsHelpPagePath, autoDevopsPath, + vulnerabilityTrainingDocsPath, }, render(createElement) { return createElement(SecurityConfigurationApp, { diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js index 173560f8370..df23698ba7e 100644 --- a/app/assets/javascripts/security_configuration/utils.js +++ b/app/assets/javascripts/security_configuration/utils.js @@ -30,6 +30,10 @@ export const augmentFeatures = (securityFeatures, complianceFeatures, features = augmented.secondary = { ...augmented.secondary, ...featuresByType[feature.secondary.type] }; } + if (augmented.badge && augmented.metaInfoPath) { + augmented.badge.badgeHref = augmented.metaInfoPath; + } + return augmented; }; diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue index 0023c64e3e4..d9e6bb5009e 100644 --- a/app/assets/javascripts/serverless/components/missing_prometheus.vue +++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlLink } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { s__ } from '../../locale'; +import { s__ } from '~/locale'; export default { components: { diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue index 79a1f39c7dd..b105f49e475 100644 --- a/app/assets/javascripts/serverless/components/url.vue +++ b/app/assets/javascripts/serverless/components/url.vue @@ -1,5 +1,5 @@ <script> -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; export default { components: { diff --git a/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js b/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js deleted file mode 100644 index 3a8631a196f..00000000000 --- a/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js +++ /dev/null @@ -1,20 +0,0 @@ -import { AwardsHandler } from '~/awards_handler'; - -class EmojiMenuInModal extends AwardsHandler { - constructor(emoji, toggleButtonSelector, menuClass, selectEmojiCallback, targetContainerEl) { - super(emoji); - - this.selectEmojiCallback = selectEmojiCallback; - this.toggleButtonSelector = toggleButtonSelector; - this.menuClass = menuClass; - this.targetContainerEl = targetContainerEl; - - this.bindEvents(); - } - - postEmoji($emojiButton, awardUrl, selectedEmoji) { - this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji)); - } -} - -export default EmojiMenuInModal; diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index a746642c191..eb0931c6fe2 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -19,10 +19,8 @@ import { __, s__, sprintf } from '~/locale'; import { updateUserStatus } from '~/rest_api'; import { timeRanges } from '~/vue_shared/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import EmojiMenuInModal from './emoji_menu_in_modal'; import { isUserBusy } from './utils'; -const emojiMenuClass = 'js-modal-status-emoji-menu'; export const AVAILABILITY_STATUS = { BUSY: 'busy', NOT_SET: 'not_set', @@ -83,7 +81,6 @@ export default { emoji: this.currentEmoji, emojiMenu: null, emojiTag: '', - isEmojiMenuVisible: false, message: this.currentMessage, modalId: 'set-user-status-modal', noEmoji: true, @@ -105,17 +102,11 @@ export default { mounted() { this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, - beforeDestroy() { - if (this.emojiMenu) { - this.emojiMenu.destroy(); - } - }, methods: { closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, setupEmojiListAndAutocomplete() { - const toggleEmojiMenuButtonSelector = '#set-user-status-modal .js-toggle-emoji-menu'; const emojiAutocomplete = new GfmAutoComplete(); emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true }); @@ -127,16 +118,6 @@ export default { this.noEmoji = this.emoji === ''; this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji); - if (!this.glFeatures.improvedEmojiPicker) { - this.emojiMenu = new EmojiMenuInModal( - Emoji, - toggleEmojiMenuButtonSelector, - emojiMenuClass, - this.setEmoji, - this.$refs.userStatusForm, - ); - } - this.setDefaultEmoji(); }) .catch(() => @@ -145,19 +126,6 @@ export default { }), ); }, - showEmojiMenu(e) { - e.stopPropagation(); - this.isEmojiMenuVisible = true; - this.emojiMenu.showEmojiMenu($(this.$refs.toggleEmojiMenuButton)); - }, - hideEmojiMenu() { - if (!this.isEmojiMenuVisible) { - return; - } - - this.isEmojiMenuVisible = false; - this.emojiMenu.hideMenuElement($(`.${emojiMenuClass}`)); - }, setDefaultEmoji() { const { emojiTag } = this; const hasStatusMessage = Boolean(this.message.length); @@ -173,16 +141,12 @@ export default { this.clearEmoji(); } }, - setEmoji(emoji, emojiTag) { + setEmoji(emoji) { this.emoji = emoji; this.noEmoji = false; this.clearEmoji(); - if (this.glFeatures.improvedEmojiPicker) { - this.emojiTag = Emoji.glEmojiTag(this.emoji); - } else { - this.emojiTag = emojiTag; - } + this.emojiTag = Emoji.glEmojiTag(this.emoji); }, clearEmoji() { if (this.emojiTag) { @@ -194,7 +158,6 @@ export default { this.message = ''; this.noEmoji = true; this.clearEmoji(); - this.hideEmojiMenu(); }, removeStatus() { this.availability = false; @@ -249,7 +212,6 @@ export default { :action-secondary="$options.actionSecondary" modal-class="set-user-status-modal" @shown="setupEmojiListAndAutocomplete" - @hide="hideEmojiMenu" @primary="setStatus" @secondary="removeStatus" > @@ -264,7 +226,6 @@ export default { <div class="input-group gl-mb-5"> <span class="input-group-prepend"> <emoji-picker - v-if="glFeatures.improvedEmojiPicker" dropdown-class="gl-h-full" toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" boundary="viewport" @@ -283,27 +244,6 @@ export default { </span> </template> </emoji-picker> - <button - v-else - ref="toggleEmojiMenuButton" - v-gl-tooltip.bottom.hover - :title="s__('SetStatusModal|Add status emoji')" - :aria-label="s__('SetStatusModal|Add status emoji')" - name="button" - type="button" - class="js-toggle-emoji-menu emoji-menu-toggle-button btn" - @click="showEmojiMenu" - > - <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span> - <span - v-show="noEmoji" - class="js-no-emoji-placeholder no-emoji-placeholder position-relative" - > - <gl-icon name="slight-smile" class="award-control-icon-neutral" /> - <gl-icon name="smiley" class="award-control-icon-positive" /> - <gl-icon name="smile" class="award-control-icon-super-positive" /> - </span> - </button> </span> <input ref="statusMessageField" @@ -314,7 +254,6 @@ export default { name="user[status][message]" @keyup="setDefaultEmoji" @keyup.enter.prevent - @click="hideEmojiMenu" /> <span v-show="isDirty" class="input-group-append"> <button diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index 2387fe64b8f..78d12ac113b 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,6 +1,5 @@ <script> -import produce from 'immer'; -import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issues/constants'; import { assigneesQueries } from '~/sidebar/constants'; @@ -17,10 +16,6 @@ export default { type: String, required: true, }, - issuableId: { - type: Number, - required: true, - }, queryVariables: { type: Object, required: true, @@ -30,6 +25,9 @@ export default { issuableClass() { return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType); }, + issuableId() { + return this.issuable?.id; + }, }, apollo: { issuable: { @@ -48,29 +46,36 @@ export default { }, variables() { return { - issuableId: convertToGraphQLId(this.issuableClass, this.issuableId), + issuableId: this.issuableId, }; }, - updateQuery(prev, { subscriptionData }) { - if (prev && subscriptionData?.data?.issuableAssigneesUpdated) { - const data = produce(prev, (draftData) => { - draftData.workspace.issuable.assignees.nodes = - subscriptionData.data.issuableAssigneesUpdated.assignees.nodes; - }); + skip() { + return !this.issuableId; + }, + updateQuery( + _, + { + subscriptionData: { + data: { issuableAssigneesUpdated }, + }, + }, + ) { + if (issuableAssigneesUpdated) { + const { + id, + assignees: { nodes }, + } = issuableAssigneesUpdated; if (this.mediator) { - this.handleFetchResult(data); + this.handleFetchResult(nodes); } - return data; + this.$emit('assigneesUpdated', { id, assignees: nodes }); } - return prev; }, }, }, }, methods: { - handleFetchResult(data) { - const { nodes } = data.workspace.issuable.assignees; - + handleFetchResult(nodes) { const assignees = nodes.map((n) => ({ ...n, avatar_url: n.avatarUrl, diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 7743004a293..14f6c9d3a15 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -232,6 +232,7 @@ export default { :issuable-type="issuableType" :issuable-id="issuableId" :query-variables="queryVariables" + @assigneesUpdated="$emit('assignees-updated', $event)" /> <sidebar-editable-item ref="toggle" diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index 558fe8ca2aa..8717d205dcb 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -109,20 +109,18 @@ export default { :key="user.id" :class="{ 'user-item': !showVerticalList, + 'gl-display-inline-block': !showVerticalList, + 'gl-display-grid gl-align-items-center': showVerticalList, 'gl-mb-3': index !== users.length - 1 && showVerticalList, }" - class="gl-display-inline-block" + class="assignee-grid" > - <attention-requested-toggle - v-if="showVerticalList" - :user="user" - type="assignee" - @toggle-attention-requested="toggleAttentionRequested" - /> <assignee-avatar-link :user="user" :issuable-type="issuableType" :tooltip-has-name="!showVerticalList" + class="gl-word-break-word" + data-css-area="user" > <div v-if="showVerticalList" @@ -133,6 +131,14 @@ export default { <span>@{{ user.username }}</span> </div> </assignee-avatar-link> + <attention-requested-toggle + v-if="showVerticalList" + :user="user" + type="assignee" + class="gl-mr-2" + data-css-area="attention" + @toggle-attention-requested="toggleAttentionRequested" + /> </div> </div> <div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800"> diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue index 6ba88939373..cdc1c65a516 100644 --- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue +++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue @@ -70,19 +70,21 @@ export default { </script> <template> - <span - v-gl-tooltip.left.viewport="tooltipTitle" - class="gl-display-inline-block js-attention-request-toggle" - > - <gl-button - :loading="loading" - :variant="user.attention_requested ? 'warning' : 'default'" - :icon="user.attention_requested ? 'attention-solid' : 'attention'" - :aria-label="tooltipTitle" - :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }" - size="small" - category="tertiary" - @click="toggleAttentionRequired" - /> - </span> + <div> + <span + v-gl-tooltip.left.viewport="tooltipTitle" + class="gl-display-inline-block js-attention-request-toggle" + > + <gl-button + :loading="loading" + :variant="user.attention_requested ? 'warning' : 'default'" + :icon="user.attention_requested ? 'attention-solid' : 'attention'" + :aria-label="tooltipTitle" + :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }" + size="small" + category="tertiary" + @click="toggleAttentionRequired" + /> + </span> + </div> </template> diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue index 0d8cb8cb2b6..8528ad56ddb 100644 --- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue +++ b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue @@ -1,5 +1,5 @@ <script> -import CopyableField from '../../vue_shared/components/sidebar/copyable_field.vue'; +import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; export default { components: { diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue index 2c32cf89387..aeaac76cff4 100644 --- a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue +++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue @@ -21,6 +21,11 @@ export default { return [...STATUS_LIST, null].includes(value); }, }, + preventDropdownClose: { + type: Boolean, + required: false, + default: false, + }, }, computed: { currentStatusLabel() { @@ -35,6 +40,11 @@ export default { this.$refs.dropdown.hide(); }, getStatusLabel, + hideDropdown(event) { + if (this.preventDropdownClose) { + event.preventDefault(); + } + }, }, }; </script> @@ -45,6 +55,7 @@ export default { block :text="currentStatusLabel" toggle-class="dropdown-menu-toggle gl-mb-2" + @hide="hideDropdown" > <slot name="header"> </slot> <gl-dropdown-item diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index 42d2e456a07..2ab46a7a655 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -3,7 +3,7 @@ import { GlButton } from '@gitlab/ui'; import $ from 'jquery'; import { mapActions } from 'vuex'; import createFlash from '~/flash'; -import { __, sprintf } from '../../../locale'; +import { __, sprintf } from '~/locale'; import eventHub from '../../event_hub'; export default { diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 4a255a3b916..3fd35de2132 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; @@ -8,9 +8,10 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - userAvatarImage, + GlButton, GlIcon, GlLoadingIcon, + userAvatarImage, }, props: { loading: { @@ -124,9 +125,13 @@ export default { </div> </div> <div v-if="hasMoreParticipants" class="participants-more hide-collapsed"> - <button type="button" class="btn-transparent btn-link" @click="toggleMoreParticipants"> - {{ toggleLabel }} - </button> + <gl-button + variant="link" + button-text-classes="gl-text-secondary" + data-testid="more-participants" + @click="toggleMoreParticipants" + >{{ toggleLabel }}</gl-button + > </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue index 361a082def6..a11468c8761 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -73,10 +73,10 @@ export default { v-gl-tooltip="tooltipOption" :href="reviewerUrl" :title="tooltipTitle" - class="d-inline-block" + class="gl-display-inline-block" > <!-- use d-flex so that slot can be appropriately styled --> - <span class="gl-display-flex gl-align-items-center"> + <span class="gl-display-flex"> <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> <slot :user="user"></slot> </span> diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index 9485802d3da..3e6be3487b1 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -94,28 +94,40 @@ export default { <div v-for="(user, index) in users" :key="user.id" - :class="{ 'gl-mb-3': index !== users.length - 1 }" + :class="{ + 'gl-mb-3': index !== users.length - 1, + 'attention-requests': glFeatures.mrAttentionRequests, + }" + class="gl-display-grid gl-align-items-center reviewer-grid" data-testid="reviewer" > - <attention-requested-toggle - v-if="glFeatures.mrAttentionRequests" + <reviewer-avatar-link :user="user" - type="reviewer" - @toggle-attention-requested="toggleAttentionRequested" - /> - <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType"> + :root-path="rootPath" + :issuable-type="issuableType" + class="gl-word-break-word gl-mr-2" + data-css-area="user" + > <div class="gl-ml-3 gl-line-height-normal gl-display-grid"> <span>{{ user.name }}</span> <span>@{{ user.username }}</span> </div> </reviewer-avatar-link> + <attention-requested-toggle + v-if="glFeatures.mrAttentionRequests" + :user="user" + type="reviewer" + class="gl-mr-2" + data-css-area="attention" + @toggle-attention-requested="toggleAttentionRequested" + /> <gl-icon v-if="user.approved" v-gl-tooltip.left :size="16" :title="approvedByTooltipTitle(user)" name="status-success" - class="float-right gl-my-2 gl-ml-2 gl-text-green-500" + class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0" data-testid="re-approved" /> <gl-icon diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue index bb90ef8e444..91c15061fb9 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; -import { sprintf, s__ } from '../../../locale'; +import { sprintf, s__ } from '~/locale'; export default { name: 'TimeTrackingHelpState', diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index d222a2af382..fdbcef22bba 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -207,7 +207,7 @@ export default { class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center" > {{ __('Time tracking') }} - <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline /> + <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" class="gl-ml-2" inline /> <gl-button :data-testid="showHelpState ? 'closeHelpButton' : 'helpButton'" category="tertiary" diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js index fc757922f09..034bdc71122 100644 --- a/app/assets/javascripts/sidebar/graphql.js +++ b/app/assets/javascripts/sidebar/graphql.js @@ -1,11 +1,9 @@ import produce from 'immer'; import VueApollo from 'vue-apollo'; import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; -import { resolvers as workItemResolvers } from '~/work_items/graphql/resolvers'; import createDefaultClient from '~/lib/graphql'; const resolvers = { - ...workItemResolvers, Mutation: { updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => { const sourceData = cache.readQuery({ query: getIssueStateQuery }); @@ -14,7 +12,6 @@ const resolvers = { }); cache.writeQuery({ query: getIssueStateQuery, data }); }, - ...workItemResolvers.Mutation, }, }; diff --git a/app/assets/javascripts/sidebar/queries/issuable_labels.subscription.graphql b/app/assets/javascripts/sidebar/queries/issuable_labels.subscription.graphql new file mode 100644 index 00000000000..edd713baddf --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issuable_labels.subscription.graphql @@ -0,0 +1,22 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +subscription issuableLabelsUpdated($issuableId: IssuableID!) { + issuableLabelsUpdated(issuableId: $issuableId) { + ... on Issue { + id + labels { + nodes { + ...Label + } + } + } + ... on MergeRequest { + id + labels { + nodes { + ...Label + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql b/app/assets/javascripts/sidebar/queries/sidebar_details.query.graphql index 90d1a7794ea..90d1a7794ea 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebar_details.query.graphql diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql b/app/assets/javascripts/sidebar/queries/sidebar_details_mr.query.graphql index 0505f88773d..0505f88773d 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebar_details_mr.query.graphql diff --git a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_status.mutation.graphql index 2c6f379744e..2c6f379744e 100644 --- a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_status.mutation.graphql diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index d8ab8f1c65b..90d8f2098bb 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -1,10 +1,10 @@ -import sidebarDetailsIssueQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql'; +import sidebarDetailsIssueQuery from 'ee_else_ce/sidebar/queries/sidebar_details.query.graphql'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql'; -import sidebarDetailsMRQuery from '../queries/sidebarDetailsMR.query.graphql'; +import sidebarDetailsMRQuery from '../queries/sidebar_details_mr.query.graphql'; import toggleAttentionRequestedMutation from '../queries/toggle_attention_requested.mutation.graphql'; const queries = { diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index e3aa29d5f89..e4a97f08c8d 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -15,8 +15,8 @@ import TitleField from '~/vue_shared/components/form/title.vue'; import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '../constants'; import { getSnippetMixin } from '../mixins/snippets'; -import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql'; -import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; +import CreateSnippetMutation from '../mutations/create_snippet.mutation.graphql'; +import UpdateSnippetMutation from '../mutations/update_snippet.mutation.graphql'; import { markBlobPerformance } from '../utils/blob'; import { getErrorMessage } from '../utils/error'; @@ -238,9 +238,9 @@ export default { > </template> <template #append> - <gl-button type="cancel" data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{ - __('Cancel') - }}</gl-button> + <gl-button type="cancel" data-testid="snippet-cancel-btn" :href="cancelButtonHref"> + {{ __('Cancel') }} + </gl-button> </template> </form-footer-actions> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 9b24c8afe37..dd8f2897018 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -21,7 +21,7 @@ import { __, s__, sprintf } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import createFlash, { FLASH_TYPES } from '~/flash'; -import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; +import DeleteSnippetMutation from '../mutations/delete_snippet.mutation.graphql'; export const i18n = { snippetSpamSuccess: sprintf( @@ -294,9 +294,9 @@ export default { <gl-modal ref="deleteModal" modal-id="delete-modal" title="Example title"> <template #modal-title>{{ __('Delete snippet?') }}</template> - <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''"> - {{ errorMessage }} - </gl-alert> + <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">{{ + errorMessage + }}</gl-alert> <gl-sprintf :message="__('Are you sure you want to delete %{name}?')"> <template #name> diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql index d75b4011d1c..d75b4011d1c 100644 --- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql +++ b/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/create_snippet.mutation.graphql index 8640c4725f4..8640c4725f4 100644 --- a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/create_snippet.mutation.graphql diff --git a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/delete_snippet.mutation.graphql index f43d53661f4..f43d53661f4 100644 --- a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/delete_snippet.mutation.graphql diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/update_snippet.mutation.graphql index 99242c5d500..99242c5d500 100644 --- a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/update_snippet.mutation.graphql diff --git a/app/assets/javascripts/sortable/constants.js b/app/assets/javascripts/sortable/constants.js new file mode 100644 index 00000000000..7fddac00ab2 --- /dev/null +++ b/app/assets/javascripts/sortable/constants.js @@ -0,0 +1,19 @@ +/** + * Default config options for sortablejs. + * @type {object} + * + * @example + * import Sortable from 'sortablejs'; + * + * const sortable = Sortable.create(el, { + * ...defaultSortableOptions, + * }); + */ +export const defaultSortableOptions = { + animation: 200, + forceFallback: true, + fallbackClass: 'is-dragging', + fallbackOnBody: true, + ghostClass: 'is-ghost', + fallbackTolerance: 1, +}; diff --git a/app/assets/javascripts/sortable/sortable_config.js b/app/assets/javascripts/sortable/sortable_config.js deleted file mode 100644 index a4c4cb7f101..00000000000 --- a/app/assets/javascripts/sortable/sortable_config.js +++ /dev/null @@ -1,8 +0,0 @@ -export default { - animation: 200, - forceFallback: true, - fallbackClass: 'is-dragging', - fallbackOnBody: true, - ghostClass: 'is-ghost', - fallbackTolerance: 1, -}; diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/sortable/utils.js index 1bb0ee5b7e3..c2c8fb03b58 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/sortable/utils.js @@ -1,6 +1,6 @@ /* global DocumentTouch */ -import sortableConfig from '~/sortable/sortable_config'; +import { defaultSortableOptions } from './constants'; export function sortableStart() { document.body.classList.add('is-dragging'); @@ -10,12 +10,12 @@ export function sortableEnd() { document.body.classList.remove('is-dragging'); } -export function getBoardSortableDefaultOptions(obj) { +export function getSortableDefaultOptions(options) { const touchEnabled = 'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch); const defaultSortOptions = { - ...sortableConfig, + ...defaultSortableOptions, filter: '.no-drag', delay: touchEnabled ? 100 : 0, scrollSensitivity: touchEnabled ? 60 : 100, @@ -24,8 +24,8 @@ export function getBoardSortableDefaultOptions(obj) { onEnd: sortableEnd, }; - Object.keys(obj).forEach((key) => { - defaultSortOptions[key] = obj[key]; - }); - return defaultSortOptions; + return { + ...defaultSortOptions, + ...options, + }; } diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue index 8c3ee7b9609..e69a6b8cd69 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue @@ -112,7 +112,6 @@ export default { v-model="mergeRequestMeta" :storage-key="$options.storageKey" :clear="clearStorage" - as-json /> <edit-meta-controls ref="editMetaControls" diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue index fd9177bef3f..6dae55bac50 100644 --- a/app/assets/javascripts/terraform/components/empty_state.vue +++ b/app/assets/javascripts/terraform/components/empty_state.vue @@ -1,11 +1,16 @@ <script> -import { GlEmptyState, GlIcon, GlLink } from '@gitlab/ui'; +import { GlEmptyState, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; export default { + i18n: { + title: s__("Terraform|Your project doesn't have any Terraform state files"), + description: s__('Terraform|How to use GitLab-managed Terraform state?'), + }, + docsUrl: helpPagePath('user/infrastructure/iac/terraform_state'), components: { GlEmptyState, - GlIcon, GlLink, }, props: { @@ -14,23 +19,13 @@ export default { required: true, }, }, - computed: { - docsUrl() { - return helpPagePath('user/infrastructure/iac/terraform_state'); - }, - }, }; </script> <template> - <gl-empty-state :svg-path="image" :title="s__('Terraform|Get started with Terraform')"> + <gl-empty-state :svg-path="image" :title="$options.i18n.title"> <template #description> - <p> - <gl-link :href="docsUrl" target="_blank" - >{{ s__('Terraform|How to use GitLab-managed Terraform State?') }} - <gl-icon name="external-link" - /></gl-link> - </p> + <gl-link :href="$options.docsUrl">{{ $options.i18n.description }}</gl-link> </template> </gl-empty-state> </template> diff --git a/app/assets/javascripts/terraform/components/states_table_actions.vue b/app/assets/javascripts/terraform/components/states_table_actions.vue index 817c421823c..1970d6d7949 100644 --- a/app/assets/javascripts/terraform/components/states_table_actions.vue +++ b/app/assets/javascripts/terraform/components/states_table_actions.vue @@ -11,6 +11,7 @@ import { GlModalDirective, } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; +import getStatesQuery from '../graphql/queries/get_states.query.graphql'; import addDataToState from '../graphql/mutations/add_data_to_state.mutation.graphql'; import lockState from '../graphql/mutations/lock_state.mutation.graphql'; import removeState from '../graphql/mutations/remove_state.mutation.graphql'; @@ -148,7 +149,7 @@ export default { variables: { stateID: this.state.id, }, - refetchQueries: () => ['getStates'], + refetchQueries: () => [{ query: getStatesQuery }], awaitRefetchQueries: true, notifyOnNetworkStatusChange: true, }) diff --git a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql index 4d26ea88ddf..2ae7b7d905e 100644 --- a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql +++ b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql @@ -1,5 +1,5 @@ #import "../fragments/state.fragment.graphql" -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getStates($projectPath: ID!, $first: Int, $last: Int, $before: String, $after: String) { project(fullPath: $projectPath) { diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js index 173eef0646b..f299c57b33f 100644 --- a/app/assets/javascripts/tracking/tracking.js +++ b/app/assets/javascripts/tracking/tracking.js @@ -13,8 +13,12 @@ import { const ALLOWED_URL_HASHES = ['#diff', '#note']; export default class Tracking { - static queuedEvents = []; + static nonInitializedQueue = []; static initialized = false; + static definitionsLoaded = false; + static definitionsManifest = {}; + static definitionsEventsQueue = []; + static definitions = []; /** * (Legacy) Determines if tracking is enabled at the user level. @@ -54,7 +58,7 @@ export default class Tracking { } if (!this.initialized) { - this.queuedEvents.push(eventData); + this.nonInitializedQueue.push(eventData); return false; } @@ -62,6 +66,64 @@ export default class Tracking { } /** + * Preloads event definitions. + * + * @returns {undefined} + */ + static loadDefinitions() { + // TODO: fetch definitions from the server and flush the queue + // See https://gitlab.com/gitlab-org/gitlab/-/issues/358256 + this.definitionsLoaded = true; + + while (this.definitionsEventsQueue.length) { + this.dispatchFromDefinition(...this.definitionsEventsQueue.shift()); + } + } + + /** + * Dispatches a structured event with data from its event definition. + * + * @param {String} basename + * @param {Object} eventData + * @returns {undefined|Boolean} + */ + static definition(basename, eventData = {}) { + if (!this.enabled()) { + return false; + } + + if (!(basename in this.definitionsManifest)) { + throw new Error(`Missing Snowplow event definition "${basename}"`); + } + + return this.dispatchFromDefinition(basename, eventData); + } + + /** + * Builds an event with data from a valid definition and sends it to + * Snowplow. If the definitions are not loaded, it pushes the data to a queue. + * + * @param {String} basename + * @param {Object} eventData + * @returns {undefined|Boolean} + */ + static dispatchFromDefinition(basename, eventData) { + if (!this.definitionsLoaded) { + this.definitionsEventsQueue.push([basename, eventData]); + + return false; + } + + const eventDefinition = this.definitions.find((definition) => definition.key === basename); + + return this.event( + eventData.category ?? eventDefinition.category, + eventData.action ?? eventDefinition.action, + eventData, + ); + } + + /** * Dispatches any event emitted before initialization. * * @returns {undefined} @@ -69,8 +131,8 @@ export default class Tracking { static flushPendingEvents() { this.initialized = true; - while (this.queuedEvents.length) { - dispatchSnowplowEvent(...this.queuedEvents.shift()); + while (this.nonInitializedQueue.length) { + dispatchSnowplowEvent(...this.nonInitializedQueue.shift()); } } diff --git a/app/assets/javascripts/user_lists/components/user_list_form.vue b/app/assets/javascripts/user_lists/components/user_list_form.vue index b53aaf46ace..44aa2d9a5b4 100644 --- a/app/assets/javascripts/user_lists/components/user_list_form.vue +++ b/app/assets/javascripts/user_lists/components/user_list_form.vue @@ -84,7 +84,7 @@ export default { <gl-form-input id="user-list-name" v-model="name" data-testid="user-list-name" required /> </gl-form-group> <div :class="$options.classes.actionContainer"> - <gl-button variant="success" data-testid="save-user-list" @click="submit"> + <gl-button variant="confirm" data-testid="save-user-list" @click="submit"> {{ saveButtonLabel }} </gl-button> <gl-button :href="cancelPath" data-testid="user-list-cancel"> diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 656c851aa3d..f7a5589af90 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-self-compare, no-unused-expressions, yoda, prefer-spread, babel/camelcase, no-param-reassign */ +/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */ /* global Issuable */ /* global emitSidebarEvent */ @@ -11,10 +11,10 @@ import { import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { isUserBusy } from '~/set_status_modal/utils'; import { fixTitle, dispose } from '~/tooltips'; -import axios from '../lib/utils/axios_utils'; -import { parseBoolean, spriteIcon } from '../lib/utils/common_utils'; -import { loadCSSFile } from '../lib/utils/css_utils'; -import { s__, __, sprintf } from '../locale'; +import axios from '~/lib/utils/axios_utils'; +import { parseBoolean, spriteIcon } from '~/lib/utils/common_utils'; +import { loadCSSFile } from '~/lib/utils/css_utils'; +import { s__, __, sprintf } from '~/locale'; import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils'; // TODO: remove eventHub hack after code splitting refactor diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index 25dbb614c1d..0e31f97b9db 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -102,7 +102,11 @@ export default { <template v-if="hasApprovers"> <span v-if="approvalLeftMessage">{{ message }}</span> <strong v-else>{{ message }}</strong> - <user-avatar-list class="d-inline-block align-middle" :items="approvers" /> + <user-avatar-list + class="gl-display-inline-block gl-vertical-align-middle" + :img-size="24" + :items="approvers" + /> </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 684386883c8..f1b89c42fb5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -66,7 +66,15 @@ export default { return this.loadingState === LOADING_STATES.expandedLoading; }, isCollapsible() { - return !this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError; + if (!this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError) { + if (this.shouldCollapse) { + return this.shouldCollapse(); + } + + return true; + } + + return false; }, hasFullData() { return this.fullData.length > 0; @@ -86,7 +94,7 @@ export default { ); }, statusIconName() { - if (this.hasFetchError) return EXTENSION_ICONS.error; + if (this.hasFetchError) return EXTENSION_ICONS.failed; if (this.isLoadingSummary) return null; return this.statusIcon(this.collapsedData); @@ -128,7 +136,7 @@ export default { } }), toggleCollapsed(e) { - if (!e?.target?.closest('.btn:not(.btn-icon),a')) { + if (this.isCollapsible && !e?.target?.closest('.btn:not(.btn-icon),a')) { this.isCollapsed = !this.isCollapsed; this.triggerRedisTracking(); @@ -214,7 +222,7 @@ export default { // To allow for text to be selected we check if the the user is clicking // or selecting, if they are selecting the time difference should be // more than 200ms - if (up - this.down < 200) { + if (up - this.down < 200 && !e?.target?.closest('.btn-icon')) { this.toggleCollapsed(e); } }, @@ -226,7 +234,12 @@ export default { <template> <section class="media-section" data-testid="widget-extension"> - <div class="media gl-p-5 gl-cursor-pointer" @mousedown="onRowMouseDown" @mouseup="onRowMouseUp"> + <div + :class="{ 'gl-cursor-pointer': isCollapsible }" + class="media gl-p-5" + @mousedown="onRowMouseDown" + @mouseup="onRowMouseUp" + > <status-icon :name="$options.label || $options.name" :is-loading="isLoadingSummary" @@ -264,7 +277,7 @@ export default { category="tertiary" data-testid="toggle-button" size="small" - @click.self="toggleCollapsed" + @click="toggleCollapsed" /> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue index 5f42c6c7acb..5cfee21dd5e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue @@ -55,19 +55,21 @@ export default { <div class="gl-display-flex"> <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" /> <div class="gl-w-full"> - <div class="gl-flex-wrap gl-display-flex gl-w-full"> - <div class="gl-mr-4 gl-display-flex gl-align-items-center"> - <p v-safe-html="generateText(data.text)" class="gl-m-0"></p> + <div class="gl-display-flex gl-flex-nowrap"> + <div class="gl-flex-wrap gl-display-flex gl-w-full"> + <div class="gl-mr-4 gl-display-flex gl-align-items-center"> + <p v-safe-html="generateText(data.text)" class="gl-m-0"></p> + </div> + <div v-if="data.link"> + <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> + </div> + <div v-if="data.supportingText"> + <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p> + </div> + <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> + {{ data.badge.text }} + </gl-badge> </div> - <div v-if="data.link"> - <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> - </div> - <div v-if="data.supportingText"> - <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p> - </div> - <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> - {{ data.badge.text }} - </gl-badge> <actions :widget="widgetLabel" :tertiary-buttons="data.actions" class="gl-ml-auto" /> </div> <p diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js index 8ba13cf8252..5fba070f79c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js @@ -32,7 +32,7 @@ const textStyleTags = { [getStartTag('critical')]: '<span class="gl-font-weight-bold gl-text-red-800">', [getStartTag('same')]: '<span class="gl-font-weight-bold gl-text-gray-700">', [getStartTag('strong')]: '<span class="gl-font-weight-bold">', - [getStartTag('small')]: '<span class="gl-font-sm">', + [getStartTag('small')]: '<span class="gl-font-sm gl-text-gray-700">', }; export const generateText = (text) => { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue index b062833cdf8..e906b8c3b59 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -79,7 +79,7 @@ export default { }, data() { return { - resolveConflictsFromCli: helpPagePath('ee/user/project/merge_requests/conflicts.html', { + resolveConflictsFromCli: helpPagePath('user/project/merge_requests/conflicts', { anchor: 'resolve-conflicts-from-the-command-line', }), }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 2e3a02b1712..9499603163b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import ciIcon from '../../vue_shared/components/ci_icon.vue'; +import ciIcon from '~/vue_shared/components/ci_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue index caafd6b995e..e86724d133a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '../../locale'; +import { __ } from '~/locale'; export default { i18n: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index e0c4679b983..887d1aab524 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -1,7 +1,7 @@ <script> import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import simplePoll from '~/lib/utils/simple_poll'; -import MergeRequest from '../../../merge_request'; +import MergeRequest from '~/merge_request'; import eventHub from '../../event_hub'; import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants'; import statusIcon from '../mr_widget_status_icon.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index ebdc8309cd5..3511fffcfbb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -3,7 +3,7 @@ import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import simplePoll from '../../../lib/utils/simple_poll'; +import simplePoll from '~/lib/utils/simple_poll'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import rebaseQuery from '../../queries/states/rebase.query.graphql'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue index e43319d42ca..4902c9b45e8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue @@ -28,7 +28,7 @@ export default { api.trackRedisHllUserEvent('i_code_review_widget_nothing_merge_click_new_file'); }, }, - ciHelpPage: helpPagePath('/ci/quick_start/index.html'), + ciHelpPage: helpPagePath('ci/quick_start/index.html'), safeHtmlConfig: { ADD_TAGS: ['use'] }, }; </script> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index e52f2c2c666..6ca0ea9c4e7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -48,6 +48,9 @@ export default { { text: 'Full report', href: this.conflictsDocsPath, target: '_blank' }, ]; }, + shouldCollapse() { + return true; + }, }, methods: { // Fetches the collapsed data diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js new file mode 100644 index 00000000000..cd5cfb6837c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js @@ -0,0 +1,39 @@ +import { __, n__, s__, sprintf } from '~/locale'; + +const digitText = (bold = false) => (bold ? '%{strong_start}%d%{strong_end}' : '%d'); +const noText = (bold = false) => (bold ? '%{strong_start}no%{strong_end}' : 'no'); + +export const TESTS_FAILED_STATUS = 'failed'; +export const ERROR_STATUS = 'error'; + +export const i18n = { + label: s__('Reports|Test summary'), + loading: s__('Reports|Test summary results are loading'), + error: s__('Reports|Test summary failed to load results'), + fullReport: s__('Reports|Full report'), + + noChanges: (bold) => s__(`Reports|${noText(bold)} changed test results`), + resultsString: (combinedString, resolvedString) => + sprintf(s__('Reports|%{combinedString} and %{resolvedString}'), { + combinedString, + resolvedString, + }), + + summaryText: (name, resultsString) => + sprintf(__('%{name}: %{resultsString}'), { name, resultsString }), + + failedClause: (failed, bold) => + n__(`${digitText(bold)} failed`, `${digitText(bold)} failed`, failed), + erroredClause: (errored, bold) => + n__(`${digitText(bold)} error`, `${digitText(bold)} errors`, errored), + resolvedClause: (resolved, bold) => + n__(`${digitText(bold)} fixed test result`, `${digitText(bold)} fixed test results`, resolved), + totalClause: (total, bold) => + n__(`${digitText(bold)} total test`, `${digitText(bold)} total tests`, total), + + reportError: s__('Reports|An error occurred while loading report'), + reportErrorWithName: (name) => + sprintf(s__('Reports|An error occurred while loading %{name} results'), { name }), + headReportParsingError: s__('Reports|Head report parsing error:'), + baseReportParsingError: s__('Reports|Base report parsing error:'), +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js new file mode 100644 index 00000000000..65d9257903f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js @@ -0,0 +1,82 @@ +import { uniqueId } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { EXTENSION_ICONS } from '../../constants'; +import { summaryTextBuilder, reportTextBuilder, reportSubTextBuilder } from './utils'; +import { i18n, TESTS_FAILED_STATUS, ERROR_STATUS } from './constants'; + +export default { + name: 'WidgetTestSummary', + enablePolling: true, + i18n, + expandEvent: 'i_testing_summary_widget_total', + props: ['testResultsPath', 'headBlobPath', 'pipeline'], + computed: { + summary(data) { + if (data.parsingInProgress) { + return this.$options.i18n.loading; + } + if (data.hasSuiteError) { + return this.$options.i18n.error; + } + return summaryTextBuilder(this.$options.i18n.label, data.summary); + }, + statusIcon(data) { + if (data.parsingInProgress) { + return null; + } + if (data.status === TESTS_FAILED_STATUS) { + return EXTENSION_ICONS.warning; + } + if (data.hasSuiteError) { + return EXTENSION_ICONS.failed; + } + return EXTENSION_ICONS.success; + }, + tertiaryButtons() { + return [ + { + text: this.$options.i18n.fullReport, + href: `${this.pipeline.path}/test_report`, + target: '_blank', + }, + ]; + }, + }, + methods: { + fetchCollapsedData() { + return axios.get(this.testResultsPath).then(({ data = {}, status }) => { + return { + data: { + hasSuiteError: data.suites?.some((suite) => suite.status === ERROR_STATUS), + parsingInProgress: status === 204, + ...data, + }, + }; + }); + }, + fetchFullData() { + return Promise.resolve(this.prepareReports()); + }, + suiteIcon(suite) { + if (suite.status === ERROR_STATUS) { + return EXTENSION_ICONS.error; + } + if (suite.status === TESTS_FAILED_STATUS) { + return EXTENSION_ICONS.failed; + } + return EXTENSION_ICONS.success; + }, + prepareReports() { + return this.collapsedData.suites.map((suite) => { + return { + id: uniqueId('suite-'), + text: reportTextBuilder(suite), + subtext: reportSubTextBuilder(suite), + icon: { + name: this.suiteIcon(suite), + }, + }; + }); + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js new file mode 100644 index 00000000000..a74ed20362f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js @@ -0,0 +1,55 @@ +import { i18n } from './constants'; + +const textBuilder = (results, boldNumbers = false) => { + const { failed, errored, resolved, total } = results; + + const failedOrErrored = (failed || 0) + (errored || 0); + const failedString = failed ? i18n.failedClause(failed, boldNumbers) : null; + const erroredString = errored ? i18n.erroredClause(errored, boldNumbers) : null; + const combinedString = + failed && errored ? `${failedString}, ${erroredString}` : failedString || erroredString; + const resolvedString = resolved ? i18n.resolvedClause(resolved, boldNumbers) : null; + const totalString = total ? i18n.totalClause(total, boldNumbers) : null; + + let resultsString = i18n.noChanges(boldNumbers); + + if (failedOrErrored) { + if (resolved) { + resultsString = i18n.resultsString(combinedString, resolvedString); + } else { + resultsString = combinedString; + } + } else if (resolved) { + resultsString = resolvedString; + } + + return `${resultsString}, ${totalString}`; +}; + +export const summaryTextBuilder = (name = '', results = {}) => { + const resultsString = textBuilder(results, true); + return i18n.summaryText(name, resultsString); +}; + +export const reportTextBuilder = ({ name = '', summary = {}, status }) => { + if (!name) { + return i18n.reportError; + } + if (status === 'error') { + return i18n.reportErrorWithName(name); + } + + const resultsString = textBuilder(summary); + return i18n.summaryText(name, resultsString); +}; + +export const reportSubTextBuilder = ({ suite_errors }) => { + const errors = []; + if (suite_errors?.head) { + errors.push(`${i18n.headReportParsingError} ${suite_errors.head}`); + } + if (suite_errors?.base) { + errors.push(`${i18n.baseReportParsingError} ${suite_errors.base}`); + } + return errors.join('<br />'); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 965746e79fb..4b3ad288768 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -47,6 +47,7 @@ import getStateQuery from './queries/get_state.query.graphql'; import terraformExtension from './extensions/terraform'; import accessibilityExtension from './extensions/accessibility'; import codeQualityExtension from './extensions/code_quality'; +import testReportExtension from './extensions/test_report'; export default { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 @@ -191,6 +192,9 @@ export default { shouldRenderTerraformPlans() { return Boolean(this.mr?.terraformReportsPath); }, + shouldRenderTestReport() { + return Boolean(this.mr?.testResultsPath); + }, mergeError() { let { mergeError } = this.mr; @@ -252,6 +256,11 @@ export default { this.registerAccessibilityExtension(); } }, + shouldRenderTestReport(newVal) { + if (newVal) { + this.registerTestReportExtension(); + } + }, }, mounted() { MRWidgetService.fetchInitialData() @@ -502,6 +511,11 @@ export default { registerExtension(codeQualityExtension); } }, + registerTestReportExtension() { + if (this.shouldRenderTestReport && this.shouldShowExtension) { + registerExtension(testReportExtension); + } + }, }, }; </script> @@ -574,7 +588,7 @@ export default { /> <grouped-test-reports-app - v-if="mr.testResultsPath" + v-if="mr.testResultsPath && !shouldShowExtension" class="js-reports-container" :endpoint="mr.testResultsPath" :head-blob-path="mr.headBlobPath" diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index 7b803b0fcbb..6515d76c17e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -1,5 +1,5 @@ import { normalizeHeaders } from '~/lib/utils/common_utils'; -import axios from '../../lib/utils/axios_utils'; +import axios from '~/lib/utils/axios_utils'; export default class MRWidgetService { constructor(endpoints) { diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue index d595c49f9aa..948d2505966 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -21,12 +21,12 @@ import Tracking from '~/tracking'; import initUserPopovers from '~/user_popovers'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue'; import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants'; import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql'; import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql'; import alertQuery from '../graphql/queries/alert_sidebar_details.query.graphql'; import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.query.graphql'; -import AlertMetrics from './alert_metrics.vue'; import AlertSidebar from './alert_sidebar.vue'; import AlertSummaryRow from './alert_summary_row.vue'; import SystemNote from './system_notes/system_note.vue'; @@ -74,7 +74,7 @@ export default { TimeAgoTooltip, AlertSidebar, SystemNote, - AlertMetrics, + MetricImagesTab, }, inject: { projectPath: { @@ -372,13 +372,12 @@ export default { </alert-summary-row> <alert-details-table :alert="alert" :loading="loading" :statuses="statuses" /> </gl-tab> - <gl-tab + + <metric-images-tab v-if="!isThreatMonitoringPage" :data-testid="$options.tabsConfig[1].id" :title="$options.tabsConfig[1].title" - > - <alert-metrics :dashboard-url="alert.metricsDashboardUrl" /> - </gl-tab> + /> <gl-tab :data-testid="$options.tabsConfig[2].id" :title="$options.tabsConfig[2].title"> <div v-if="alert.notes.nodes.length > 0" class="issuable-discussion"> <ul class="notes main-notes-list timeline"> diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js index d0155c18b9c..614748fa80d 100644 --- a/app/assets/javascripts/vue_shared/alert_details/index.js +++ b/app/assets/javascripts/vue_shared/alert_details/index.js @@ -3,6 +3,9 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import createStore from '~/vue_shared/components/metric_images/store'; +import service from './service'; import AlertDetails from './components/alert_details.vue'; import { PAGE_CONFIG } from './constants'; import sidebarStatusQuery from './graphql/queries/alert_sidebar_status.query.graphql'; @@ -12,7 +15,8 @@ Vue.use(VueApollo); export default (selector) => { const domEl = document.querySelector(selector); - const { alertId, projectPath, projectIssuesPath, projectId, page } = domEl.dataset; + const { alertId, projectPath, projectIssuesPath, projectId, page, canUpdate } = domEl.dataset; + const iid = alertId; const router = createRouter(); const resolvers = { @@ -54,15 +58,20 @@ export default (selector) => { page, projectIssuesPath, projectId, + iid, statuses: PAGE_CONFIG[page].STATUSES, + canUpdate: parseBoolean(canUpdate), }; + const opsProperties = {}; + if (page === PAGE_CONFIG.OPERATIONS.TITLE) { const { TRACK_ALERTS_DETAILS_VIEWS_OPTIONS, TRACK_ALERT_STATUS_UPDATE_OPTIONS } = PAGE_CONFIG[ page ]; provide.trackAlertsDetailsViewsOptions = TRACK_ALERTS_DETAILS_VIEWS_OPTIONS; provide.trackAlertStatusUpdateOptions = TRACK_ALERT_STATUS_UPDATE_OPTIONS; + opsProperties.store = createStore({}, service); } else if (page === PAGE_CONFIG.THREAT_MONITORING.TITLE) { provide.isThreatMonitoringPage = true; } @@ -74,6 +83,7 @@ export default (selector) => { components: { AlertDetails, }, + ...opsProperties, provide, apolloProvider, router, diff --git a/app/assets/javascripts/vue_shared/alert_details/service.js b/app/assets/javascripts/vue_shared/alert_details/service.js new file mode 100644 index 00000000000..90f4961103b --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/service.js @@ -0,0 +1,43 @@ +import { + fetchAlertMetricImages, + uploadAlertMetricImage, + updateAlertMetricImage, + deleteAlertMetricImage, +} from '~/rest_api'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +function replaceModelIId(payload = {}) { + delete Object.assign(payload, { alertIid: payload.modelIid }).modelIid; + return payload; +} + +export const getMetricImages = async (payload) => { + const apiPayload = replaceModelIId(payload); + const response = await fetchAlertMetricImages(apiPayload); + return convertObjectPropsToCamelCase(response.data, { deep: true }); +}; + +export const uploadMetricImage = async (payload) => { + const apiPayload = replaceModelIId(payload); + const response = await uploadAlertMetricImage(apiPayload); + return convertObjectPropsToCamelCase(response.data); +}; + +export const updateMetricImage = async (payload) => { + const apiPayload = replaceModelIId(payload); + const response = await updateAlertMetricImage(apiPayload); + return convertObjectPropsToCamelCase(response.data); +}; + +export const deleteMetricImage = async (payload) => { + const apiPayload = replaceModelIId(payload); + const response = await deleteAlertMetricImage(apiPayload); + return convertObjectPropsToCamelCase(response.data); +}; + +export default { + getMetricImages, + uploadMetricImage, + updateMetricImage, + deleteMetricImage, +}; diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 96970f4ce2f..f5d8811e83c 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -4,7 +4,7 @@ import { groupBy } from 'lodash'; import EmojiPicker from '~/emoji/components/picker.vue'; import { __, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { glEmojiTag } from '../../emoji'; +import { glEmojiTag } from '~/emoji'; // Internal constant, specific to this component, used when no `currentUserId` is given const NO_USER_ID = -1; @@ -93,12 +93,14 @@ export default { return awardList.some((award) => award.user.id === this.currentUserId); }, createAwardList(name, list) { + const url = list.length ? list[0].url : null; + return { name, list, title: this.getAwardListTitle(list, name), classes: this.getAwardClassBindings(list), - html: glEmojiTag(name), + html: glEmojiTag(name, { url }), }; }, getAwardListTitle(awardsList, name) { @@ -198,10 +200,10 @@ export default { </gl-button> <div v-if="canAwardEmoji" class="award-menu-holder gl-my-2"> <emoji-picker - v-if="glFeatures.improvedEmojiPicker" v-gl-tooltip.viewport :title="__('Add reaction')" :toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]" + data-testid="emoji-picker" @click="handleAward" @shown="setIsMenuOpen(true)" @hidden="setIsMenuOpen(false)" @@ -219,24 +221,6 @@ export default { </span> </template> </emoji-picker> - <gl-button - v-else - v-gl-tooltip.viewport - :class="addButtonClass" - class="add-reaction-button js-add-award" - title="Add reaction" - :aria-label="__('Add reaction')" - > - <span class="reaction-control-icon reaction-control-icon-neutral"> - <gl-icon name="slight-smile" /> - </span> - <span class="reaction-control-icon reaction-control-icon-positive"> - <gl-icon name="smiley" /> - </span> - <span class="reaction-control-icon reaction-control-icon-super-positive"> - <gl-icon name="smile" /> - </span> - </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index 3aaa7d915ea..0117c06c3d5 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -1,7 +1,5 @@ <script> import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import LineHighlighter from '~/blob/line_highlighter'; import { HIGHLIGHT_CLASS_NAME } from './constants'; import ViewerMixin from './mixins'; @@ -13,7 +11,7 @@ export default { directives: { SafeHtml: GlSafeHtmlDirective, }, - mixins: [ViewerMixin, glFeatureFlagsMixin()], + mixins: [ViewerMixin], inject: ['blobHash'], data() { return { @@ -21,21 +19,14 @@ export default { }; }, computed: { - refactorBlobViewerEnabled() { - return this.glFeatures.refactorBlobViewer; - }, - lineNumbers() { return this.content.split('\n').length; }, }, mounted() { - if (this.refactorBlobViewerEnabled) { - // This line will be removed once we start using highlight.js on the frontend (https://gitlab.com/groups/gitlab-org/-/epics/7146) - new LineHighlighter(); // eslint-disable-line no-new - } else { - const { hash } = window.location; - if (hash) this.scrollToLine(hash, true); + const { hash } = window.location; + if (hash) { + this.scrollToLine(hash, true); } }, methods: { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index af85a2fda06..f28a2801bc0 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -1,6 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; -import { numberToHumanSize } from '../../../../lib/utils/number_utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue deleted file mode 100644 index 733accdff44..00000000000 --- a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue +++ /dev/null @@ -1,47 +0,0 @@ -<script> -import Identicon from '../identicon.vue'; -import ProjectAvatarImage from './image.vue'; - -export default { - name: 'DeprecatedProjectAvatar', - components: { - Identicon, - ProjectAvatarImage, - }, - props: { - project: { - type: Object, - required: true, - }, - size: { - type: Number, - default: 40, - required: false, - }, - }, - computed: { - sizeClass() { - return `s${this.size}`; - }, - }, -}; -</script> - -<template> - <span :class="sizeClass" class="avatar-container rect-avatar project-avatar"> - <project-avatar-image - v-if="project.avatar_url" - :link-href="project.path" - :img-src="project.avatar_url" - :img-alt="project.name" - :img-size="size" - /> - <identicon - v-else - :entity-id="project.id" - :entity-name="project.name" - :size-class="sizeClass" - class="rect-avatar" - /> - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue deleted file mode 100644 index 269736c799c..00000000000 --- a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a project avatar that - does not need to link to the project's profile. The image and an optional - tooltip can be configured by props passed to this component. - - Sample configuration: - - <project-avatar-image - :lazy="true" - :img-src="projectAvatarSrc" - :img-alt="tooltipText" - :tooltip-text="tooltipText" - tooltip-placement="top" - /> - - */ -import defaultAvatarUrl from 'images/no_avatar.png'; -import { __ } from '~/locale'; -import { placeholderImage } from '../../../lazy_loader'; - -export default { - name: 'ProjectAvatarImage', - props: { - lazy: { - type: Boolean, - required: false, - default: false, - }, - imgSrc: { - type: String, - required: false, - default: defaultAvatarUrl, - }, - cssClasses: { - type: String, - required: false, - default: '', - }, - imgAlt: { - type: String, - required: false, - default: __('project avatar'), - }, - size: { - type: Number, - required: false, - default: 20, - }, - }, - computed: { - // API response sends null when gravatar is disabled and - // we provide an empty string when we use it inside project avatar link. - // In both cases we should render the defaultAvatarUrl - sanitizedSource() { - return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - }, - resultantSrcAttribute() { - return this.lazy ? placeholderImage : this.sanitizedSource; - }, - avatarSizeClass() { - return `s${this.size}`; - }, - }, -}; -</script> - -<template> - <img - :class="{ - lazy: lazy, - [avatarSizeClass]: true, - [cssClasses]: true, - }" - :src="resultantSrcAttribute" - :width="size" - :height="size" - :alt="imgAlt" - :data-src="sanitizedSource" - class="avatar" - /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue index 014276c7e36..d14d8c9b92e 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue @@ -37,7 +37,7 @@ export default { <template> <div v-show="showAlert"> - <local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json /> + <local-storage-sync v-model="isDismissed" :storage-key="storageKey" /> <gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert"> <slot></slot> </gl-alert> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 5cdf7b6a3b2..6638a5de62f 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -79,6 +79,16 @@ export default { required: false, default: '', }, + searchButtonAttributes: { + type: Object, + required: false, + default: () => ({}), + }, + searchInputAttributes: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending; @@ -163,33 +173,6 @@ export default { return undefined; }, }, - watch: { - /** - * GlFilteredSearch currently doesn't emit any event when - * tokens are manually removed from search field so we'd - * never know when user actually clears all the tokens. - * This watcher listens for updates to `filterValue` on - * such instances. :( - */ - filterValue(newValue, oldValue) { - const [firstVal] = newValue; - if ( - !this.initialRender && - newValue.length === 1 && - firstVal.type === 'filtered-search-term' && - !firstVal.value.data - ) { - const filtersCleared = - oldValue[0].type !== 'filtered-search-term' || oldValue[0].value.data !== ''; - this.$emit('onFilter', [], filtersCleared); - } - - // Set initial render flag to false - // as we don't want to emit event - // on initial load when value is empty already. - this.initialRender = false; - }, - }, created() { if (this.recentSearchesStorageKey) this.setupRecentSearch(); }, @@ -322,6 +305,10 @@ export default { return tokenOption.title; }, + onClear() { + const cleared = true; + this.$emit('onFilter', [], cleared); + }, }, }; </script> @@ -343,8 +330,11 @@ export default { :available-tokens="tokens" :history-items="filteredRecentSearches" :suggestions-list-class="suggestionsListClass" + :search-button-attributes="searchButtonAttributes" + :search-input-attributes="searchInputAttributes" class="flex-grow-1" @history-item-selected="handleHistoryItemSelected" + @clear="onClear" @clear-history="handleClearHistory" @submit="handleFilterSubmit" > 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 b70317b2ec4..696456be990 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 @@ -95,7 +95,6 @@ export default { v-if="activeTokenValue" :size="16" :src="getAvatarUrl(activeTokenValue)" - shape="circle" class="gl-mr-2" /> {{ activeTokenValue ? activeTokenValue.name : inputValue }} 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 06949b59823..69548f0e7a8 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 @@ -110,7 +110,7 @@ export default { v-gl-tooltip.hover="toggleVisibilityLabel" :aria-label="toggleVisibilityLabel" :icon="toggleVisibilityIcon" - @click="handleToggleVisibilityButtonClick" + @click.stop="handleToggleVisibilityButtonClick" /> <clipboard-button v-if="showCopyButton" 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 9bff469b670..f2abade8036 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -8,8 +8,8 @@ import { GlTooltip, } from '@gitlab/ui'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { glEmojiTag } from '../../emoji'; -import { __, sprintf } from '../../locale'; +import { glEmojiTag } from '~/emoji'; +import { __, sprintf } from '~/locale'; import CiIconBadge from './ci_badge_link.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; @@ -117,7 +117,7 @@ export default { <template> <header - class="page-content-header gl-display-flex gl-min-h-7" + class="page-content-header gl-md-display-flex gl-min-h-7" data-qa-selector="pipeline_header" data-testid="ci-header-content" > @@ -163,11 +163,7 @@ export default { </template> </section> - <section - v-if="$slots.default" - data-testid="ci-header-action-buttons" - class="gl-display-flex gl-mr-3" - > + <section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex"> <slot></slot> </section> <gl-button diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index f3b871c91b6..c3f184446a8 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -21,12 +21,17 @@ export default { default: () => ({}), }, }, + methods: { + targetFn() { + return this.$refs.popoverTrigger?.$el; + }, + }, }; </script> <template> <span> - <gl-button ref="popoverTrigger" variant="link" icon="question" :aria-label="__('Help')" /> - <gl-popover :target="() => $refs.popoverTrigger.$el" v-bind="options"> + <gl-button ref="popoverTrigger" variant="link" icon="question-o" :aria-label="__('Help')" /> + <gl-popover :target="targetFn" v-bind="options"> <template v-if="options.title" #title> <span v-safe-html="options.title"></span> </template> diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue deleted file mode 100644 index 87a995464fa..00000000000 --- a/app/assets/javascripts/vue_shared/components/identicon.vue +++ /dev/null @@ -1,35 +0,0 @@ -<script> -import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; - -export default { - props: { - entityId: { - type: [Number, String], - required: true, - }, - entityName: { - type: String, - required: true, - }, - sizeClass: { - type: String, - required: false, - default: 's40', - }, - }, - computed: { - identiconBackgroundClass() { - return getIdenticonBackgroundClass(this.entityId); - }, - identiconTitle() { - return getIdenticonTitle(this.entityName); - }, - }, -}; -</script> - -<template> - <div ref="identicon" :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon"> - {{ identiconTitle }} - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/line_numbers.vue b/app/assets/javascripts/vue_shared/components/line_numbers.vue deleted file mode 100644 index 11caf3be00a..00000000000 --- a/app/assets/javascripts/vue_shared/components/line_numbers.vue +++ /dev/null @@ -1,31 +0,0 @@ -<script> -import { GlIcon, GlLink } from '@gitlab/ui'; - -export default { - components: { - GlIcon, - GlLink, - }, - props: { - lines: { - type: Number, - required: true, - }, - }, -}; -</script> -<template> - <div class="line-numbers"> - <gl-link - v-for="line in lines" - :id="`L${line}`" - :key="line" - class="diff-line-num gl-shadow-none!" - :to="`#LC${line}`" - :data-line-number="line" - > - <gl-icon :size="12" name="link" /> - {{ line }} - </gl-link> - </div> -</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 33e77b6510c..4ece87310c7 100644 --- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue +++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue @@ -1,6 +1,18 @@ <script> -import { isEqual } from 'lodash'; +import { isEqual, isString } from 'lodash'; +/** + * This component will save and restore a value to and from localStorage. + * The value will be saved only when the value changes; the initial value won't be saved. + * + * By default, the value will be saved using JSON.stringify(), and retrieved back using JSON.parse(). + * + * If you would like to save the raw string instead, you may set the 'asString' prop to true, though be aware that this is a + * legacy prop to maintain backwards compatibility. + * + * For new components saving data for the first time, it's recommended to not use 'asString' even if you're saving a string; it will still be + * saved and restored properly using JSON.stringify()/JSON.parse(). + */ export default { props: { storageKey: { @@ -12,7 +24,7 @@ export default { required: false, default: '', }, - asJson: { + asString: { type: Boolean, required: false, default: false, @@ -30,6 +42,8 @@ export default { }, watch: { value(newVal) { + if (!this.persist) return; + this.saveValue(this.serialize(newVal)); }, clear(newVal) { @@ -67,15 +81,22 @@ export default { } }, saveValue(val) { - if (!this.persist) return; - localStorage.setItem(this.storageKey, val); }, serialize(val) { - return this.asJson ? JSON.stringify(val) : val; + if (!isString(val) && this.asString) { + // eslint-disable-next-line no-console + console.warn( + `[gitlab] LocalStorageSync is saving`, + val, + `to the key "${this.storageKey}", but it is not a string and the 'asString' prop is true. This will save and restore the stringified value rather than the original value. If this is not intended, please remove or set the 'asString' prop to false.`, + ); + } + + return this.asString ? val : JSON.stringify(val); }, deserialize(val) { - return this.asJson ? JSON.parse(val) : val; + return this.asString ? val : JSON.parse(val); }, }, render() { diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue index 709d3592828..926034efd10 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -1,9 +1,9 @@ <script> -import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui'; import { __, n__ } from '~/locale'; export default { - components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton }, + components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton, GlAlert }, props: { disabled: { type: Boolean, @@ -19,6 +19,11 @@ export default { required: false, default: 0, }, + errorMessage: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -55,6 +60,9 @@ export default { > <gl-dropdown-form class="gl-px-4! gl-m-0!"> <label for="commit-message">{{ __('Commit message') }}</label> + <gl-alert v-if="errorMessage" variant="danger" :dismissible="false" class="gl-mb-4"> + {{ errorMessage }} + </gl-alert> <gl-form-textarea id="commit-message" ref="commitMessage" diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index e1020ce656b..722df3cc58b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; import { debounce, unescape } from 'lodash'; @@ -24,6 +24,9 @@ export default { GlIcon, Suggestions, }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, mixins: [glFeatureFlagsMixin()], props: { /** @@ -308,6 +311,9 @@ export default { ); }, }, + safeHtmlConfig: { + ADD_TAGS: ['gl-emoji'], + }, }; </script> @@ -369,18 +375,19 @@ export default { <div v-show="previewMarkdown" ref="markdown-preview" + v-safe-html:[$options.safeHtmlConfig]="markdownPreview" class="js-vue-md-preview md md-preview-holder" - v-html="markdownPreview /* eslint-disable-line vue/no-v-html */" ></div> </template> <div v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading" + v-safe-html:[$options.safeHtmlConfig]="referencedCommands" class="referenced-commands" - v-html="referencedCommands /* eslint-disable-line vue/no-v-html */" + data-testid="referenced-commands" ></div> <div v-if="shouldShowReferencedUsers" class="referenced-users"> <gl-icon name="warning-solid" /> - <span v-html="addMultipleToDiscussionWarning /* eslint-disable-line vue/no-v-html */"></span> + <span v-safe-html:[$options.safeHtmlConfig]="addMultipleToDiscussionWarning"></span> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 13189670e17..d0bd5046bf0 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -10,7 +10,7 @@ import { } from '~/behaviors/shortcuts/keybindings'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; -import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm'; +import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import ToolbarButton from './toolbar_button.vue'; export default { @@ -187,7 +187,7 @@ export default { <template #tabs-end> <div data-testid="md-header-toolbar" - :class="{ 'gl-display-none': previewMarkdown }" + :class="{ 'gl-display-none!': previewMarkdown }" class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center" > <toolbar-button diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index 7d8d8c0b90e..4d10c3f0a51 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -36,6 +36,11 @@ export default { required: false, default: 0, }, + failedToLoadMetadata: { + type: Boolean, + required: false, + default: false, + }, }, computed: { batchSuggestionsCount() { @@ -80,6 +85,7 @@ export default { :help-page-path="helpPagePath" :default-commit-message="defaultCommitMessage" :inapplicable-reason="suggestion.inapplicable_reason" + :failed-to-load-metadata="failedToLoadMetadata" @apply="applySuggestion" @applyBatch="applySuggestionBatch" @addToBatch="addSuggestionToBatch" 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 648e9c9462f..8a1b8363f19 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 @@ -4,6 +4,10 @@ import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import ApplySuggestion from './apply_suggestion.vue'; +const APPLY_SUGGESTION_ERROR_MESSAGE = __( + 'Unable to fully load the default commit message. You can still apply this suggestion and the commit message will be correct.', +); + export default { components: { GlBadge, GlIcon, GlButton, GlLoadingIcon, ApplySuggestion }, directives: { 'gl-tooltip': GlTooltipDirective }, @@ -52,6 +56,11 @@ export default { required: false, default: 0, }, + failedToLoadMetadata: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -94,6 +103,9 @@ export default { return true; }, + applySuggestionErrorMessage() { + return this.failedToLoadMetadata ? APPLY_SUGGESTION_ERROR_MESSAGE : null; + }, }, methods: { apply(message) { @@ -171,6 +183,7 @@ export default { :disabled="isDisableButton" :default-commit-message="defaultCommitMessage" :batch-suggestions-count="batchSuggestionsCount" + :error-message="applySuggestionErrorMessage" class="gl-ml-3" @apply="apply" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 2f6776f835e..de3eda6b04f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -47,6 +47,11 @@ export default { required: false, default: 0, }, + failedToLoadMetadata: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -60,6 +65,9 @@ export default { noteHtml() { this.reset(); }, + failedToLoadMetadata() { + this.reset(); + }, }, mounted() { this.renderSuggestions(); @@ -105,6 +113,7 @@ export default { helpPagePath, defaultCommitMessage, suggestionsCount, + failedToLoadMetadata, } = this; const suggestion = suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {}; @@ -117,6 +126,7 @@ export default { helpPagePath, defaultCommitMessage, suggestionsCount, + failedToLoadMetadata, }, }); diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue new file mode 100644 index 00000000000..3e796a73f72 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue @@ -0,0 +1,119 @@ +<script> +import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal, GlTab } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { __, s__ } from '~/locale'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; +import MetricImagesTable from '~/vue_shared/components/metric_images/metric_images_table.vue'; + +export default { + components: { + GlFormGroup, + GlFormInput, + GlLoadingIcon, + GlModal, + GlTab, + MetricImagesTable, + UploadDropzone, + }, + inject: ['canUpdate', 'projectId', 'iid'], + data() { + return { + currentFiles: [], + modalVisible: false, + modalUrl: '', + modalUrlText: '', + }; + }, + computed: { + ...mapState(['metricImages', 'isLoadingMetricImages', 'isUploadingImage']), + actionPrimaryProps() { + return { + text: this.$options.i18n.modalUpload, + attributes: { + loading: this.isUploadingImage, + disabled: this.isUploadingImage, + category: 'primary', + variant: 'confirm', + }, + }; + }, + }, + mounted() { + this.setInitialData({ modelIid: this.iid, projectId: this.projectId }); + this.fetchImages(); + }, + methods: { + ...mapActions(['fetchImages', 'uploadImage', 'setInitialData']), + clearInputs() { + this.modalVisible = false; + this.modalUrl = ''; + this.modalUrlText = ''; + this.currentFile = false; + }, + openMetricDialog(files) { + this.modalVisible = true; + this.currentFiles = files; + }, + async onUpload() { + try { + await this.uploadImage({ + files: this.currentFiles, + url: this.modalUrl, + urlText: this.modalUrlText, + }); + // Error case handled within action + } finally { + this.clearInputs(); + } + }, + }, + i18n: { + modalUpload: __('Upload'), + modalCancel: __('Cancel'), + modalTitle: s__('Incidents|Add image details'), + modalDescription: s__( + "Incidents|Add text or a link to display with your image. If you don't add either, the file name displays instead.", + ), + dropDescription: s__( + 'Incidents|Drop or %{linkStart}upload%{linkEnd} a metric screenshot to attach it to the incident', + ), + }, +}; +</script> + +<template> + <gl-tab :title="s__('Incident|Metrics')" data-testid="metrics-tab"> + <div v-if="isLoadingMetricImages"> + <gl-loading-icon class="gl-p-5" size="sm" /> + </div> + <gl-modal + modal-id="upload-metric-modal" + size="sm" + :action-primary="actionPrimaryProps" + :action-cancel="{ text: $options.i18n.modalCancel }" + :title="$options.i18n.modalTitle" + :visible="modalVisible" + @hidden="clearInputs" + @primary.prevent="onUpload" + > + <p>{{ $options.i18n.modalDescription }}</p> + <gl-form-group :label="__('Text (optional)')" label-for="upload-text-input"> + <gl-form-input id="upload-text-input" v-model="modalUrlText" /> + </gl-form-group> + + <gl-form-group + :label="__('Link (optional)')" + label-for="upload-url-input" + :description="s__('Incidents|Must start with http or https')" + > + <gl-form-input id="upload-url-input" v-model="modalUrl" /> + </gl-form-group> + </gl-modal> + <metric-images-table v-for="metric in metricImages" :key="metric.id" v-bind="metric" /> + <upload-dropzone + v-if="canUpdate" + :drop-description-message="$options.i18n.dropDescription" + @change="openMetricDialog" + /> + </gl-tab> +</template> diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue new file mode 100644 index 00000000000..8eb8e52728d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue @@ -0,0 +1,266 @@ +<script> +import { + GlButton, + GlFormGroup, + GlFormInput, + GlCard, + GlIcon, + GlLink, + GlModal, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import { __, s__ } from '~/locale'; + +export default { + i18n: { + modalDelete: __('Delete'), + modalDescription: s__('Incident|Are you sure you wish to delete this image?'), + modalCancel: __('Cancel'), + modalTitle: s__('Incident|Deleting %{filename}'), + editModalUpdate: __('Update'), + editModalTitle: s__('Incident|Editing %{filename}'), + editIconTitle: s__('Incident|Edit image text or link'), + deleteIconTitle: s__('Incident|Delete image'), + }, + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlCard, + GlIcon, + GlLink, + GlModal, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['canUpdate'], + props: { + id: { + type: Number, + required: true, + }, + filePath: { + type: String, + required: true, + }, + filename: { + type: String, + required: true, + }, + url: { + type: String, + required: false, + default: null, + }, + urlText: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + isCollapsed: false, + isDeleting: false, + isUpdating: false, + modalVisible: false, + editModalVisible: false, + modalUrl: this.url, + modalUrlText: this.urlText, + }; + }, + computed: { + deleteActionPrimaryProps() { + return { + text: this.$options.i18n.modalDelete, + attributes: { + loading: this.isDeleting, + disabled: this.isDeleting, + category: 'primary', + variant: 'danger', + }, + }; + }, + updateActionPrimaryProps() { + return { + text: this.$options.i18n.editModalUpdate, + attributes: { + loading: this.isUpdating, + disabled: this.isUpdating, + category: 'primary', + variant: 'confirm', + }, + }; + }, + arrowIconName() { + return this.isCollapsed ? 'chevron-right' : 'chevron-down'; + }, + bodyClass() { + return [ + 'gl-border-1', + 'gl-border-t-solid', + 'gl-border-gray-100', + { 'gl-display-none': this.isCollapsed }, + ]; + }, + }, + methods: { + ...mapActions(['deleteImage', 'updateImage']), + toggleCollapsed() { + this.isCollapsed = !this.isCollapsed; + }, + resetEditFields() { + this.modalUrl = this.url; + this.modalUrlText = this.urlText; + this.editModalVisible = false; + this.modalVisible = false; + }, + async onDelete() { + try { + this.isDeleting = true; + await this.deleteImage(this.id); + } finally { + this.isDeleting = false; + this.modalVisible = false; + } + }, + async onUpdate() { + try { + this.isUpdating = true; + await this.updateImage({ + imageId: this.id, + url: this.modalUrl, + urlText: this.modalUrlText, + }); + } finally { + this.isUpdating = false; + this.modalUrl = ''; + this.modalUrlText = ''; + this.editModalVisible = false; + } + }, + }, +}; +</script> + +<template> + <gl-card + class="collapsible-card border gl-p-0 gl-mb-5" + header-class="gl-display-flex gl-align-items-center gl-border-b-0 gl-py-3" + :body-class="bodyClass" + > + <gl-modal + body-class="gl-pb-0! gl-min-h-6!" + modal-id="delete-metric-modal" + size="sm" + :visible="modalVisible" + :action-primary="deleteActionPrimaryProps" + :action-cancel="{ text: $options.i18n.modalCancel }" + @primary.prevent="onDelete" + @hidden="resetEditFields" + > + <template #modal-title> + <gl-sprintf :message="$options.i18n.modalTitle"> + <template #filename> + {{ filename }} + </template> + </gl-sprintf> + </template> + <p>{{ $options.i18n.modalDescription }}</p> + </gl-modal> + + <gl-modal + modal-id="edit-metric-modal" + size="sm" + :action-primary="updateActionPrimaryProps" + :action-cancel="{ text: $options.i18n.modalCancel }" + :visible="editModalVisible" + data-testid="metric-image-edit-modal" + @hidden="resetEditFields" + @primary.prevent="onUpdate" + > + <template #modal-title> + <gl-sprintf :message="$options.i18n.editModalTitle"> + <template #filename> + {{ filename }} + </template> + </gl-sprintf> + </template> + + <gl-form-group :label="__('Text (optional)')" label-for="upload-text-input"> + <gl-form-input + id="upload-text-input" + v-model="modalUrlText" + data-testid="metric-image-text-field" + /> + </gl-form-group> + + <gl-form-group + :label="__('Link (optional)')" + label-for="upload-url-input" + :description="s__('Incidents|Must start with http or https')" + > + <gl-form-input + id="upload-url-input" + v-model="modalUrl" + data-testid="metric-image-url-field" + /> + </gl-form-group> + </gl-modal> + + <template #header> + <div class="gl-w-full gl-display-flex gl-flex-direction-row gl-justify-content-space-between"> + <div class="gl-display-flex gl-flex-direction-row gl-align-items-center gl-w-full"> + <gl-button + class="collapsible-card-btn gl-display-flex gl-text-decoration-none gl-reset-color! gl-hover-text-blue-800! gl-shadow-none!" + :aria-label="filename" + variant="link" + category="tertiary" + data-testid="collapse-button" + @click="toggleCollapsed" + > + <gl-icon class="gl-mr-2" :name="arrowIconName" /> + </gl-button> + <gl-link v-if="url" :href="url" target="_blank" data-testid="metric-image-label-span"> + {{ urlText == null || urlText == '' ? filename : urlText }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + <span v-else data-testid="metric-image-label-span">{{ + urlText == null || urlText == '' ? filename : urlText + }}</span> + <div class="gl-ml-auto btn-group"> + <gl-button + v-if="canUpdate" + v-gl-tooltip.bottom + icon="pencil" + :aria-label="__('Edit')" + :title="$options.i18n.editIconTitle" + data-testid="edit-button" + @click="editModalVisible = true" + /> + <gl-button + v-if="canUpdate" + v-gl-tooltip.bottom + icon="remove" + :aria-label="__('Delete')" + :title="$options.i18n.deleteIconTitle" + data-testid="delete-button" + @click="modalVisible = true" + /> + </div> + </div> + </div> + </template> + <div + v-show="!isCollapsed" + class="gl-display-flex gl-flex-direction-column" + data-testid="metric-image-body" + > + <img class="gl-max-w-full gl-align-self-center" :src="filePath" /> + </div> + </gl-card> +</template> diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js new file mode 100644 index 00000000000..832fb891838 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js @@ -0,0 +1,85 @@ +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import * as types from './mutation_types'; + +export const fetchImagesFactory = (service) => async ({ state, commit }) => { + commit(types.REQUEST_METRIC_IMAGES); + const { modelIid, projectId } = state; + + try { + const response = await service.getMetricImages({ id: projectId, modelIid }); + commit(types.RECEIVE_METRIC_IMAGES_SUCCESS, response); + } catch (error) { + commit(types.RECEIVE_METRIC_IMAGES_ERROR); + createFlash({ message: s__('MetricImages|There was an issue loading metric images.') }); + } +}; + +export const uploadImageFactory = (service) => async ( + { state, commit }, + { files, url, urlText }, +) => { + commit(types.REQUEST_METRIC_UPLOAD); + + const { modelIid, projectId } = state; + + try { + const response = await service.uploadMetricImage({ + file: files.item(0), + id: projectId, + modelIid, + url, + urlText, + }); + commit(types.RECEIVE_METRIC_UPLOAD_SUCCESS, response); + } catch (error) { + commit(types.RECEIVE_METRIC_UPLOAD_ERROR); + createFlash({ message: s__('MetricImages|There was an issue uploading your image.') }); + } +}; + +export const updateImageFactory = (service) => async ( + { state, commit }, + { imageId, url, urlText }, +) => { + commit(types.REQUEST_METRIC_UPLOAD); + + const { modelIid, projectId } = state; + + try { + const response = await service.updateMetricImage({ + modelIid, + id: projectId, + imageId, + url, + urlText, + }); + commit(types.RECEIVE_METRIC_UPDATE_SUCCESS, response); + } catch (error) { + commit(types.RECEIVE_METRIC_UPLOAD_ERROR); + createFlash({ message: s__('MetricImages|There was an issue updating your image.') }); + } +}; + +export const deleteImageFactory = (service) => async ({ state, commit }, imageId) => { + const { modelIid, projectId } = state; + + try { + await service.deleteMetricImage({ imageId, id: projectId, modelIid }); + commit(types.RECEIVE_METRIC_DELETE_SUCCESS, imageId); + } catch (error) { + createFlash({ message: s__('MetricImages|There was an issue deleting the image.') }); + } +}; + +export const setInitialData = ({ commit }, data) => { + commit(types.SET_INITIAL_DATA, data); +}; + +export default (service) => ({ + fetchImages: fetchImagesFactory(service), + uploadImage: uploadImageFactory(service), + updateImage: updateImageFactory(service), + deleteImage: deleteImageFactory(service), + setInitialData, +}); diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/index.js b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js new file mode 100644 index 00000000000..f13dde9a2bc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import actionsFactory from './actions'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export default (initialState, service) => + new Vuex.Store({ + actions: actionsFactory(service), + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js new file mode 100644 index 00000000000..8f1b31217a2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js @@ -0,0 +1,13 @@ +export const REQUEST_METRIC_IMAGES = 'REQUEST_METRIC_IMAGES'; +export const RECEIVE_METRIC_IMAGES_SUCCESS = 'RECEIVE_METRIC_IMAGES_SUCCESS'; +export const RECEIVE_METRIC_IMAGES_ERROR = 'RECEIVE_METRIC_IMAGES_ERROR'; + +export const REQUEST_METRIC_UPLOAD = 'REQUEST_METRIC_UPLOAD'; +export const RECEIVE_METRIC_UPLOAD_SUCCESS = 'RECEIVE_METRIC_UPLOAD_SUCCESS'; +export const RECEIVE_METRIC_UPLOAD_ERROR = 'RECEIVE_METRIC_UPLOAD_ERROR'; + +export const RECEIVE_METRIC_UPDATE_SUCCESS = 'RECEIVE_METRIC_UPDATE_SUCCESS'; + +export const RECEIVE_METRIC_DELETE_SUCCESS = 'RECEIVE_METRIC_DELETE_SUCCESS'; + +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js b/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js new file mode 100644 index 00000000000..b42234b2829 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js @@ -0,0 +1,39 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_METRIC_IMAGES](state) { + state.isLoadingMetricImages = true; + }, + [types.RECEIVE_METRIC_IMAGES_SUCCESS](state, images) { + state.metricImages = images || []; + state.isLoadingMetricImages = false; + }, + [types.RECEIVE_METRIC_IMAGES_ERROR](state) { + state.isLoadingMetricImages = false; + }, + [types.REQUEST_METRIC_UPLOAD](state) { + state.isUploadingImage = true; + }, + [types.RECEIVE_METRIC_UPLOAD_SUCCESS](state, image) { + state.metricImages.push(image); + state.isUploadingImage = false; + }, + [types.RECEIVE_METRIC_UPLOAD_ERROR](state) { + state.isUploadingImage = false; + }, + [types.RECEIVE_METRIC_UPDATE_SUCCESS](state, image) { + state.isUploadingImage = false; + const metricIndex = state.metricImages.findIndex((img) => img.id === image.id); + if (metricIndex >= 0) { + state.metricImages.splice(metricIndex, 1, image); + } + }, + [types.RECEIVE_METRIC_DELETE_SUCCESS](state, imageId) { + const metricIndex = state.metricImages.findIndex((image) => image.id === imageId); + state.metricImages.splice(metricIndex, 1); + }, + [types.SET_INITIAL_DATA](state, { modelIid, projectId }) { + state.modelIid = modelIid; + state.projectId = projectId; + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/state.js b/app/assets/javascripts/vue_shared/components/metric_images/store/state.js new file mode 100644 index 00000000000..b734e5c87a6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/metric_images/store/state.js @@ -0,0 +1,10 @@ +export default ({ modelIid, projectId } = {}) => ({ + // Initial state + modelIid, + projectId, + + // View state + metricImages: [], + isLoadingMetricImages: false, + isUploadingImage: false, +}); 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 1963d1aa7fe..dd7a851b1be 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -31,7 +31,7 @@ import { __ } from '~/locale'; import initMRPopovers from '~/mr_popover/'; import noteHeader from '~/notes/components/note_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { spriteIcon } from '../../../lib/utils/common_utils'; +import { spriteIcon } from '~/lib/utils/common_utils'; import TimelineEntryItem from './timeline_entry_item.vue'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.vue b/app/assets/javascripts/vue_shared/components/project_avatar.vue index f16187022a5..402e75962d2 100644 --- a/app/assets/javascripts/vue_shared/components/project_avatar.vue +++ b/app/assets/javascripts/vue_shared/components/project_avatar.vue @@ -1,5 +1,6 @@ <script> import { GlAvatar } from '@gitlab/ui'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; export default { components: { @@ -31,12 +32,13 @@ export default { return this.alt ?? this.projectName; }, }, + AVATAR_SHAPE_OPTION_RECT, }; </script> <template> <gl-avatar - shape="rect" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" :entity-name="projectName" :src="projectAvatarUrl" :alt="avatarAlt" diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue index 0bd57c84018..19ffbe37ce7 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -3,7 +3,7 @@ import { GlButton, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { isString } from 'lodash'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; -import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; export default { name: 'ProjectListItem', @@ -22,6 +22,9 @@ export default { matcher: { type: String, required: false, default: '' }, }, computed: { + projectAvatarUrl() { + return this.project.avatar_url || this.project.avatarUrl; + }, projectNameWithNamespace() { return this.project.nameWithNamespace || this.project.name_with_namespace; }, @@ -49,7 +52,11 @@ export default { class="gl-display-flex gl-align-items-center gl-flex-wrap project-namespace-name-container" > <gl-icon v-if="selected" class="js-selected-icon" name="mobile-issue-close" /> - <project-avatar class="gl-flex-shrink-0 js-project-avatar" :project="project" :size="32" /> + <project-avatar + :project-avatar-url="projectAvatarUrl" + :project-name="projectNameWithNamespace" + class="gl-mr-3" + /> <div v-if="truncatedNamespace" :title="projectNameWithNamespace" diff --git a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue index 36b1a9c49f4..43a8e241d77 100644 --- a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue +++ b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue @@ -43,7 +43,7 @@ export default { </script> <template> - <local-storage-sync :storage-key="storageKey" :value="selected" @input="setSelected"> + <local-storage-sync :storage-key="storageKey" :value="selected" as-string @input="setSelected"> <gl-dropdown :text="dropdownText" lazy> <gl-dropdown-item v-for="option in parsedOptions" 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 d108d8d689d..fc0976b0792 100644 --- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue +++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue @@ -1,6 +1,7 @@ <script> import { GlAvatar, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui'; import { isEqual } from 'lodash'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; export default { name: 'TitleArea', @@ -53,6 +54,7 @@ export default { } }, }, + AVATAR_SHAPE_OPTION_RECT, }; </script> @@ -64,7 +66,7 @@ export default { <gl-avatar v-if="avatar" :src="avatar" - shape="rect" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" class="gl-align-self-center gl-mr-4" /> 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 f53b75df4eb..522fbc07f5e 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 @@ -1,8 +1,11 @@ <script> import { debounce } from 'lodash'; +import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; import createFlash from '~/flash'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IssuableType } from '~/issues/constants'; + import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { issuableLabelsQueries } from '~/sidebar/constants'; @@ -21,6 +24,7 @@ export default { DropdownContents, SidebarEditableItem, }, + mixins: [glFeatureFlagsMixin()], inject: { allowLabelEdit: { default: false, @@ -106,7 +110,7 @@ export default { data() { return { contentIsOnViewport: true, - issuableLabels: [], + issuable: null, labelsSelectInProgress: false, oldIid: null, sidebarExpandedOnClick: false, @@ -114,14 +118,23 @@ export default { }, computed: { isLoading() { - return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading; + return this.labelsSelectInProgress || this.$apollo.queries.issuable.loading; }, issuableLabelIds() { return this.issuableLabels.map((label) => label.id); }, + issuableLabels() { + return this.issuable?.labels.nodes || []; + }, + issuableId() { + return this.issuable?.id; + }, + isRealtimeEnabled() { + return this.glFeatures.realtimeLabels; + }, }, apollo: { - issuableLabels: { + issuable: { query() { return issuableLabelsQueries[this.issuableType].issuableQuery; }, @@ -135,11 +148,40 @@ export default { }; }, update(data) { - return data.workspace?.issuable?.labels.nodes || []; + return data.workspace?.issuable; }, error() { createFlash({ message: __('Error fetching labels.') }); }, + subscribeToMore: { + document() { + return issuableLabelsSubscription; + }, + variables() { + return { + issuableId: this.issuableId, + }; + }, + skip() { + return !this.issuableId || !this.isDropdownVariantSidebar || !this.isRealtimeEnabled; + }, + updateQuery( + _, + { + subscriptionData: { + data: { issuableLabelsUpdated }, + }, + }, + ) { + if (issuableLabelsUpdated) { + const { + id, + labels: { nodes }, + } = issuableLabelsUpdated; + this.$emit('updateSelectedLabels', { id, labels: nodes }); + } + }, + }, }, }, watch: { diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue new file mode 100644 index 00000000000..6babbca58c3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -0,0 +1,103 @@ +<script> +import { GlIntersectionObserver, GlSafeHtmlDirective } from '@gitlab/ui'; +import ChunkLine from './chunk_line.vue'; + +/* + * We only highlight the chunk that is currently visible to the user. + * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. + * + * Content that is not visible to the user (i.e. not highlighted) do not need to look nice, + * so by making text transparent and rendering raw (non-highlighted) text, + * the browser spends less resources on painting content that is not immediately relevant. + * + * Why use transparent text as opposed to hiding content entirely? + * 1. If content is hidden entirely, native find text (⌘ + F) won't work. + * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line. + */ +export default { + components: { + ChunkLine, + GlIntersectionObserver, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + chunkIndex: { + type: Number, + required: false, + default: 0, + }, + isHighlighted: { + type: Boolean, + required: true, + }, + content: { + type: String, + required: true, + }, + startingFrom: { + type: Number, + required: false, + default: 0, + }, + totalLines: { + type: Number, + required: false, + default: 0, + }, + language: { + type: String, + required: false, + default: null, + }, + }, + computed: { + lines() { + return this.content.split('\n'); + }, + }, + methods: { + handleChunkAppear() { + if (!this.isHighlighted) { + this.$emit('appear', this.chunkIndex); + } + }, + }, +}; +</script> +<template> + <div> + <gl-intersection-observer @appear="handleChunkAppear"> + <div v-if="isHighlighted"> + <chunk-line + v-for="(line, index) in lines" + :key="index" + :number="startingFrom + index + 1" + :content="line" + :language="language" + /> + </div> + <div v-else class="gl-display-flex"> + <div class="gl-display-flex gl-flex-direction-column"> + <a + v-for="(n, index) in totalLines" + :id="`L${startingFrom + index + 1}`" + :key="index" + class="gl-ml-5 gl-text-transparent" + :href="`#L${startingFrom + index + 1}`" + :data-line-number="startingFrom + index + 1" + data-testid="line-number" + > + {{ startingFrom + index + 1 }} + </a> + </div> + <div + class="gl-white-space-pre-wrap! gl-text-transparent" + data-testid="content" + v-text="content" + ></div> + </div> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue new file mode 100644 index 00000000000..1b8e4bcfec6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue @@ -0,0 +1,74 @@ +<script> +import { GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import { setAttributes } from '~/lib/utils/dom_utils'; +import { BIDI_CHARS, BIDI_CHARS_CLASS_LIST, BIDI_CHAR_TOOLTIP } from '../constants'; + +export default { + components: { + GlLink, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + number: { + type: Number, + required: true, + }, + content: { + type: String, + required: true, + }, + language: { + type: String, + required: true, + }, + }, + computed: { + formattedContent() { + let { content } = this; + + BIDI_CHARS.forEach((bidiChar) => { + if (content.includes(bidiChar)) { + content = content.replace(bidiChar, this.wrapBidiChar(bidiChar)); + } + }); + + return content; + }, + }, + methods: { + wrapBidiChar(bidiChar) { + const span = document.createElement('span'); + + setAttributes(span, { + class: BIDI_CHARS_CLASS_LIST, + title: BIDI_CHAR_TOOLTIP, + 'data-testid': 'bidi-wrapper', + }); + + span.innerText = bidiChar; + + return span.outerHTML; + }, + }, +}; +</script> +<template> + <div class="gl-display-flex"> + <div class="line-numbers gl-pt-0! gl-pb-0! gl-absolute gl-z-index-3"> + <gl-link + :id="`L${number}`" + class="file-line-num diff-line-num gl-user-select-none" + :to="`#L${number}`" + :data-line-number="number" + > + {{ number }} + </gl-link> + </div> + + <pre + class="code highlight gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11!" + ><code><span :id="`LC${number}`" v-safe-html="formattedContent" :lang="language" class="line" data-testid="content"></span></code></pre> + </div> +</template> 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 9efe0147c37..bed6dd4d5c6 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + // Language map from Rouge::Lexer to highlight.js // Rouge::Lexer - We use it on the BE to determine the language of a source file (https://github.com/rouge-ruby/rouge/blob/master/docs/Languages.md). // Highlight.js - We use it on the FE to highlight the syntax of a source file (https://github.com/highlightjs/highlight.js/tree/main/src/languages). @@ -109,3 +111,26 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { xquery: 'xquery', yaml: 'yaml', }; + +export const LINES_PER_CHUNK = 70; + +export const BIDI_CHARS = [ + '\u202A', // Left-to-Right Embedding (Try treating following text as left-to-right) + '\u202B', // Right-to-Left Embedding (Try treating following text as right-to-left) + '\u202D', // Left-to-Right Override (Force treating following text as left-to-right) + '\u202E', // Right-to-Left Override (Force treating following text as right-to-left) + '\u2066', // Left-to-Right Isolate (Force treating following text as left-to-right without affecting adjacent text) + '\u2067', // Right-to-Left Isolate (Force treating following text as right-to-left without affecting adjacent text) + '\u2068', // First Strong Isolate (Force treating following text in direction indicated by the next character) + '\u202C', // Pop Directional Formatting (Terminate nearest LRE, RLE, LRO, or RLO) + '\u2069', // Pop Directional Isolate (Terminate nearest LRI or RLI) + '\u061C', // Arabic Letter Mark (Right-to-left zero-width Arabic character) + '\u200F', // Right-to-Left Mark (Right-to-left zero-width character non-Arabic character) + '\u200E', // Left-to-Right Mark (Left-to-right zero-width character) +]; + +export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip'; + +export const BIDI_CHAR_TOOLTIP = __( + 'Potentially unwanted character detected: Unicode BiDi Control', +); 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 4a78cbacec0..edf2229a9a1 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 @@ -1,16 +1,22 @@ <script> import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui'; -import LineNumbers from '~/vue_shared/components/line_numbers.vue'; -import { sanitize } from '~/lib/dompurify'; -import { ROUGE_TO_HLJS_LANGUAGE_MAP } from './constants'; -import { wrapLines } from './utils'; - -const LINE_SELECT_CLASS_NAME = 'hll'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; +import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants'; +import Chunk from './components/chunk.vue'; +/* + * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code, + * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible. + * + * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback). + * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes, + * it does not trigger a repaint on a parent element that wraps all 1000 lines. + */ export default { components: { - LineNumbers, GlLoadingIcon, + Chunk, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -27,46 +33,94 @@ export default { content: this.blob.rawTextBlob, language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language], hljs: null, + firstChunk: null, + chunks: {}, + isLoading: true, + isLineSelected: false, + lineHighlighter: null, }; }, computed: { + splitContent() { + return this.content.split('\n'); + }, lineNumbers() { - return this.content.split('\n').length; + return this.splitContent.length; }, - highlightedContent() { - let highlightedContent; - let { language } = this; + }, + async created() { + this.generateFirstChunk(); + this.hljs = await this.loadHighlightJS(); - if (this.hljs) { - if (!language) { - const hljsHighlightAuto = this.hljs.highlightAuto(this.content); + if (this.language) { + this.languageDefinition = await this.loadLanguage(); + } - highlightedContent = hljsHighlightAuto.value; - language = hljsHighlightAuto.language; - } else if (this.languageDefinition) { - highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value; - } + // Highlight the first chunk as soon as highlight.js is available + this.highlightChunk(null, true); + + window.requestIdleCallback(async () => { + // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first + this.generateRemainingChunks(); + this.isLoading = false; + await this.$nextTick(); + this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + }); + }, + methods: { + generateFirstChunk() { + const lines = this.splitContent.splice(0, LINES_PER_CHUNK); + this.firstChunk = this.createChunk(lines); + }, + generateRemainingChunks() { + const result = {}; + for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) { + const chunkIndex = Math.floor(i / LINES_PER_CHUNK); + const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK); + result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK); } - return wrapLines(highlightedContent, language); + this.chunks = result; }, - }, - watch: { - highlightedContent() { - this.$nextTick(() => this.selectLine()); + createChunk(lines, startingFrom = 0) { + return { + content: lines.join('\n'), + startingFrom, + totalLines: lines.length, + language: this.language, + isHighlighted: false, + }; }, - $route() { + highlightChunk(index, isFirstChunk) { + const chunk = isFirstChunk ? this.firstChunk : this.chunks[index]; + + if (chunk.isHighlighted) { + return; + } + + const { highlightedContent, language } = this.highlight(chunk.content, this.language); + + Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true }); + this.selectLine(); + + this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path)); }, - }, - async mounted() { - this.hljs = await this.loadHighlightJS(); + highlight(content, language) { + let detectedLanguage = language; + let highlightedContent; + if (this.hljs) { + if (!detectedLanguage) { + const hljsHighlightAuto = this.hljs.highlightAuto(content); + highlightedContent = hljsHighlightAuto.value; + detectedLanguage = hljsHighlightAuto.language; + } else if (this.languageDefinition) { + highlightedContent = this.hljs.highlight(content, { language: this.language }).value; + } + } - if (this.language) { - this.languageDefinition = await this.loadLanguage(); - } - }, - methods: { + return { highlightedContent, language: detectedLanguage }; + }, loadHighlightJS() { // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); @@ -83,21 +137,14 @@ export default { return languageDefinition; }, - selectLine() { - const hash = sanitize(this.$route.hash); - const lineToSelect = hash && this.$el.querySelector(hash); - - if (!lineToSelect) { + async selectLine() { + if (this.isLineSelected || !this.lineHighlighter) { return; } - if (this.$options.currentlySelectedLine) { - this.$options.currentlySelectedLine.classList.remove(LINE_SELECT_CLASS_NAME); - } - - lineToSelect.classList.add(LINE_SELECT_CLASS_NAME); - this.$options.currentlySelectedLine = lineToSelect; - lineToSelect.scrollIntoView({ behavior: 'smooth', block: 'center' }); + this.isLineSelected = true; + await this.$nextTick(); + this.lineHighlighter.highlightHash(this.$route.hash); }, }, userColorScheme: window.gon.user_color_scheme, @@ -105,16 +152,36 @@ export default { }; </script> <template> - <gl-loading-icon v-if="!highlightedContent" size="sm" class="gl-my-5" /> <div - v-else - class="file-content code js-syntax-highlight blob-content gl-display-flex" + class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" :class="$options.userColorScheme" data-type="simple" + :data-path="blob.path" data-qa-selector="blob_viewer_file_content" > - <line-numbers :lines="lineNumbers" /> - <pre class="code highlight gl-pb-0!"><code v-safe-html="highlightedContent"></code> - </pre> + <chunk + v-if="firstChunk" + :lines="firstChunk.lines" + :total-lines="firstChunk.totalLines" + :content="firstChunk.content" + :starting-from="firstChunk.startingFrom" + :is-highlighted="firstChunk.isHighlighted" + :language="firstChunk.language" + /> + + <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> + <chunk + v-for="(chunk, key, index) in chunks" + v-else + :key="key" + :lines="chunk.lines" + :content="chunk.content" + :total-lines="chunk.totalLines" + :starting-from="chunk.startingFrom" + :is-highlighted="chunk.isHighlighted" + :chunk-index="index" + :language="chunk.language" + @appear="highlightChunk" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js deleted file mode 100644 index d726a8a55ff..00000000000 --- a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js +++ /dev/null @@ -1,28 +0,0 @@ -export const wrapLines = (content, language) => { - const isValidLanguage = /^[a-z\d\-_]+$/.test(language); // To prevent the possibility of a vulnerability we only allow languages that contain alphanumeric characters ([a-z\d), dashes (-) or underscores (_). - - return ( - content && - content - .split('\n') - .map((line, i) => { - let formattedLine; - const attributes = `id="LC${i + 1}" lang="${isValidLanguage ? language : ''}"`; - - if (line.includes('<span class="hljs') && !line.includes('</span>')) { - /** - * In some cases highlight.js will wrap multiple lines in a span, in these cases we want to append the line number to the existing span - * - * example (before): <span class="hljs-code">```bash - * example (after): <span id="LC67" class="hljs-code">```bash - */ - formattedLine = line.replace(/(?=class="hljs)/, `${attributes} `); - } else { - formattedLine = `<span ${attributes} class="line">${line}</span>`; - } - - return formattedLine; - }) - .join('\n') - ); -}; diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index 66088b33c99..e784bba6698 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective } from '@gitlab/ui'; import timeagoMixin from '../mixins/timeago'; -import '../../lib/utils/datetime_utility'; +import '~/lib/utils/datetime_utility'; /** * Port of ruby helper time_ago_with_tooltip 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 f52a3471ea4..c58a5357883 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 @@ -18,7 +18,7 @@ import { GlTooltip, GlAvatar } from '@gitlab/ui'; import defaultAvatarUrl from 'images/no_avatar.png'; import { __ } from '~/locale'; -import { placeholderImage } from '../../../lazy_loader'; +import { placeholderImage } from '~/lazy_loader'; export default { name: 'UserAvatarImageNew', @@ -96,11 +96,12 @@ export default { /> <gl-tooltip + v-if="tooltipText || $slots.default" :target="() => $refs.userAvatar.$el" :placement="tooltipPlacement" boundary="window" > - <slot> {{ tooltipText }}</slot> + <slot>{{ tooltipText }}</slot> </gl-tooltip> </span> </template> 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 bca10c76038..15ba8e3b39b 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 @@ -18,7 +18,7 @@ import { GlTooltip } from '@gitlab/ui'; import defaultAvatarUrl from 'images/no_avatar.png'; import { __ } from '~/locale'; -import { placeholderImage } from '../../../lazy_loader'; +import { placeholderImage } from '~/lazy_loader'; export default { name: 'UserAvatarImageOld', @@ -100,11 +100,12 @@ export default { class="avatar" /> <gl-tooltip + v-if="tooltipText || $slots.default" :target="() => $refs.userAvatarImage" :placement="tooltipPlacement" boundary="window" > - <slot> {{ tooltipText }}</slot> + <slot>{{ tooltipText }}</slot> </gl-tooltip> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue index e19d659c179..60b26d688b2 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { sprintf, __ } from '~/locale'; import UserAvatarLink from './user_avatar_link.vue'; @@ -8,6 +9,7 @@ export default { UserAvatarLink, GlButton, }, + mixins: [glFeatureFlagMixin()], props: { items: { type: Array, @@ -57,6 +59,9 @@ export default { return sprintf(__('%{count} more'), { count }); }, + imgCssClasses() { + return this.glFeatures.glAvatarForAllUserAvatars ? 'gl-mr-3' : ''; + }, }, methods: { expand() { @@ -80,6 +85,7 @@ export default { :img-alt="item.name" :tooltip-text="item.name" :img-size="imgSize" + :img-css-classes="imgCssClasses" /> <template v-if="hasBreakpoint"> <gl-button v-if="hasHiddenItems" variant="link" @click="expand"> 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 41507ca94e2..cac8f0a9aa5 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 @@ -8,7 +8,7 @@ import { GlSprintf, } from '@gitlab/ui'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; -import { glEmojiTag } from '../../../emoji'; +import { glEmojiTag } from '~/emoji'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; const MAX_SKELETON_LINES = 4; @@ -69,7 +69,7 @@ export default { <gl-popover :target="target" :delay="200" boundary="viewport" placement="top"> <div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover"> <div class="gl-p-2 flex-shrink-1"> - <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" /> + <user-avatar-image :img-src="user.avatarUrl" :size="64" css-classes="gl-mr-3!" /> </div> <div class="gl-p-2 gl-w-full gl-min-w-0"> <template v-if="userIsLoading"> diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 199516b3eb3..15f84e48179 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -314,6 +314,7 @@ export default { <local-storage-sync storage-key="gl-web-ide-button-selected" :value="selection" + as-string @input="select" /> <gl-modal 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 028d48e7e8a..20f178dfb7d 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,5 +1,10 @@ <script> -import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { + GlAlert, + GlKeysetPagination, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlPagination, +} from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js index 45452f2ea35..c5f41d81167 100644 --- a/app/assets/javascripts/vue_shared/mixins/timeago.js +++ b/app/assets/javascripts/vue_shared/mixins/timeago.js @@ -1,4 +1,4 @@ -import { formatDate, getTimeago } from '../../lib/utils/datetime_utility'; +import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; /** * Mixin with time ago methods used in some vue components @@ -14,25 +14,5 @@ export default { tooltipTitle(time) { return formatDate(time); }, - - durationTimeFormatted(duration) { - const date = new Date(duration * 1000); - - let hh = date.getUTCHours(); - let mm = date.getUTCMinutes(); - let ss = date.getSeconds(); - - if (hh < 10) { - hh = `0${hh}`; - } - if (mm < 10) { - mm = `0${mm}`; - } - if (ss < 10) { - ss = `0${ss}`; - } - - return `${hh}:${mm}:${ss}`; - }, }, }; diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js index 616848639f1..bc1f8865261 100644 --- a/app/assets/javascripts/vue_shared/translate.js +++ b/app/assets/javascripts/vue_shared/translate.js @@ -1,4 +1,4 @@ -import { __, n__, s__, sprintf } from '../locale'; +import { __, n__, s__, sprintf } from '~/locale'; export default (Vue) => { Vue.mixin({ diff --git a/app/assets/javascripts/webpack.js b/app/assets/javascripts/webpack.js index b901f17790f..1c6e632135d 100644 --- a/app/assets/javascripts/webpack.js +++ b/app/assets/javascripts/webpack.js @@ -8,5 +8,5 @@ */ if (gon && gon.webpack_public_path) { - __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line babel/camelcase + __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line camelcase } diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 79840cc4f0f..232510b108d 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -2,12 +2,9 @@ import { escape } from 'lodash'; import { __ } from '~/locale'; -import { WI_TITLE_TRACK_LABEL } from '../constants'; - export default { - WI_TITLE_TRACK_LABEL, props: { - initialTitle: { + title: { type: String, required: false, default: '', @@ -23,11 +20,6 @@ export default { default: false, }, }, - data() { - return { - title: this.initialTitle, - }; - }, methods: { getSanitizedTitle(inputEl) { const { innerText } = inputEl; @@ -50,7 +42,6 @@ export default { <h2 class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block" :class="{ 'gl-cursor-not-allowed': disabled }" - data-testid="title" aria-labelledby="item-title" > <span @@ -59,7 +50,6 @@ export default { role="textbox" :aria-label="__('Title')" :data-placeholder="placeholder" - :data-track-label="$options.WI_TITLE_TRACK_LABEL" :contenteditable="!disabled" class="gl-pseudo-placeholder" @blur="handleBlur" diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue new file mode 100644 index 00000000000..40b6fcdd204 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -0,0 +1,93 @@ +<script> +import { GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql'; + +export default { + i18n: { + deleteWorkItem: s__('WorkItem|Delete work item'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlModal, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + workItemId: { + type: String, + required: false, + default: null, + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + }, + emits: ['workItemDeleted', 'error'], + methods: { + deleteWorkItem() { + this.$apollo + .mutate({ + mutation: deleteWorkItemMutation, + variables: { + input: { + id: this.workItemId, + }, + }, + }) + .then(({ data: { workItemDelete, errors } }) => { + if (errors?.length) { + throw new Error(errors[0].message); + } + + if (workItemDelete?.errors.length) { + throw new Error(workItemDelete.errors[0]); + } + + this.$emit('workItemDeleted'); + }) + .catch((e) => { + this.$emit( + 'error', + e.message || + s__('WorkItem|Something went wrong when deleting the work item. Please try again.'), + ); + }); + }, + }, +}; +</script> + +<template> + <div v-if="canUpdate"> + <gl-dropdown + icon="ellipsis_v" + text-sr-only + :text="__('More actions')" + category="tertiary" + no-caret + right + > + <gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{ + $options.i18n.deleteWorkItem + }}</gl-dropdown-item> + </gl-dropdown> + <gl-modal + modal-id="work-item-confirm-delete" + :title="$options.i18n.deleteWorkItem" + :ok-title="$options.i18n.deleteWorkItem" + ok-variant="danger" + @ok="deleteWorkItem" + > + {{ + s__( + 'WorkItem|Are you sure you want to delete the work item? This action cannot be reversed.', + ) + }} + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue new file mode 100644 index 00000000000..f2fb1e3ccbc --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -0,0 +1,73 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { i18n } from '../constants'; +import workItemQuery from '../graphql/work_item.query.graphql'; +import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; +import WorkItemTitle from './work_item_title.vue'; + +export default { + i18n, + components: { + GlAlert, + WorkItemTitle, + }, + props: { + workItemId: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + error: undefined, + workItem: {}, + }; + }, + apollo: { + workItem: { + query: workItemQuery, + variables() { + return { + id: this.workItemId, + }; + }, + skip() { + return !this.workItemId; + }, + error() { + this.error = this.$options.i18n.fetchError; + }, + subscribeToMore: { + document: workItemTitleSubscription, + variables() { + return { + issuableId: this.workItemId, + }; + }, + }, + }, + }, + computed: { + workItemType() { + return this.workItem.workItemType?.name; + }, + }, +}; +</script> + +<template> + <section> + <gl-alert v-if="error" variant="danger" @dismiss="error = undefined"> + {{ error }} + </gl-alert> + + <work-item-title + :loading="$apollo.queries.workItem.loading" + :work-item-id="workItem.id" + :work-item-title="workItem.title" + :work-item-type="workItemType" + @error="error = $event" + /> + </section> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index 942677bb937..a79091fb8b2 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -1,15 +1,22 @@ <script> -import { GlModal } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import workItemQuery from '../graphql/work_item.query.graphql'; -import ItemTitle from './item_title.vue'; +import { GlAlert, GlButton, GlModal } from '@gitlab/ui'; +import WorkItemActions from './work_item_actions.vue'; +import WorkItemDetail from './work_item_detail.vue'; export default { components: { + GlAlert, + GlButton, GlModal, - ItemTitle, + WorkItemDetail, + WorkItemActions, }, props: { + canUpdate: { + type: Boolean, + required: false, + default: false, + }, visible: { type: Boolean, required: true, @@ -20,43 +27,55 @@ export default { default: null, }, }, + emits: ['workItemDeleted', 'close'], data() { return { - workItem: {}, + error: undefined, }; }, - apollo: { - workItem: { - query: workItemQuery, - variables() { - return { - id: this.workItemId, - }; - }, - update(data) { - return data.workItem; - }, - skip() { - return !this.workItemId; - }, - error() { - this.$emit( - 'error', - s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), - ); - }, + methods: { + handleWorkItemDeleted() { + this.$emit('workItemDeleted'); + this.closeModal(); }, - }, - computed: { - workItemTitle() { - return this.workItem?.title; + closeModal() { + this.error = ''; + this.$emit('close'); + }, + setErrorMessage(message) { + this.error = message; }, }, }; </script> <template> - <gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')"> - <item-title class="gl-m-0!" :initial-title="workItemTitle" /> + <gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="closeModal"> + <template #modal-header> + <div class="gl-w-full gl-display-flex gl-align-items-center gl-justify-content-end"> + <h2 class="modal-title gl-mr-auto">{{ s__('WorkItem|Work Item') }}</h2> + <work-item-actions + :work-item-id="workItemId" + :can-update="canUpdate" + @workItemDeleted="handleWorkItemDeleted" + @error="setErrorMessage" + /> + <gl-button category="tertiary" icon="close" :aria-label="__('Close')" @click="closeModal" /> + </div> + </template> + <gl-alert v-if="error" variant="danger" @dismiss="error = false"> + {{ error }} + </gl-alert> + + <work-item-detail :work-item-id="workItemId" /> </gl-modal> </template> + +<style> +/* hide the existing close button until we can do it + * with https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2710 + */ +#work-item-detail-modal .modal-header > .gl-button { + display: none; +} +</style> diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue new file mode 100644 index 00000000000..88a825853cc --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_title.vue @@ -0,0 +1,73 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { i18n } from '../constants'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import ItemTitle from './item_title.vue'; + +export default { + components: { + GlLoadingIcon, + ItemTitle, + }, + mixins: [Tracking.mixin()], + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + workItemId: { + type: String, + required: false, + default: '', + }, + workItemTitle: { + type: String, + required: false, + default: '', + }, + workItemType: { + type: String, + required: false, + default: '', + }, + }, + computed: { + tracking() { + return { + category: 'workItems:show', + label: 'item_title', + property: `type_${this.workItemType}`, + }; + }, + }, + methods: { + async updateTitle(updatedTitle) { + if (updatedTitle === this.workItemTitle) { + return; + } + + try { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + title: updatedTitle, + }, + }, + }); + this.track('updated_title'); + } catch { + this.$emit('error', i18n.updateError); + } + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="loading" class="gl-mt-3" size="md" /> + <item-title v-else :title="workItemTitle" @title-changed="updateTitle" /> +</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 995c02a2c5b..d3bcaf0f95f 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -1,5 +1,6 @@ -export const widgetTypes = { - title: 'TITLE', -}; +import { s__ } from '~/locale'; -export const WI_TITLE_TRACK_LABEL = 'item_title'; +export const i18n = { + fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), + updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), +}; diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql index 9312d1c582b..7f9aaf43068 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql @@ -1,18 +1,9 @@ -#import './widget.fragment.graphql' +#import "./work_item.fragment.graphql" mutation createWorkItem($input: WorkItemCreateInput!) { workItemCreate(input: $input) { workItem { - id - title - workItemType { - id - } - widgets @client { - nodes { - ...WidgetBase - } - } + ...WorkItem } } } diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql new file mode 100644 index 00000000000..b25210f5c74 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql @@ -0,0 +1,9 @@ +mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) { + workItemCreateFromTask(input: $input) { + workItem { + id + descriptionHtml + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/delete_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/delete_work_item.mutation.graphql new file mode 100644 index 00000000000..c52c49ec5f6 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/delete_work_item.mutation.graphql @@ -0,0 +1,5 @@ +mutation deleteWorkItem($input: WorkItemDeleteInput!) { + workItemDelete(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 28328a840cf..3c2955ce1e2 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -1,41 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import workItemQuery from './work_item.query.graphql'; -import { resolvers } from './resolvers'; -import typeDefs from './typedefs.graphql'; export function createApolloProvider() { Vue.use(VueApollo); - const defaultClient = createDefaultClient(resolvers, { - typeDefs, - cacheConfig: { - possibleTypes: { - LocalWorkItemWidget: ['LocalTitleWidget'], - }, - }, - }); - - defaultClient.cache.writeQuery({ - query: workItemQuery, - variables: { - id: 'gid://gitlab/WorkItem/1', - }, - data: { - localWorkItem: { - __typename: 'LocalWorkItem', - id: 'gid://gitlab/WorkItem/1', - type: 'FEATURE', - // eslint-disable-next-line @gitlab/require-i18n-strings - title: 'Test Work Item', - widgets: { - __typename: 'LocalWorkItemWidgetConnection', - nodes: [], - }, - }, - }, - }); + const defaultClient = createDefaultClient(); return new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js deleted file mode 100644 index fb74e27f840..00000000000 --- a/app/assets/javascripts/work_items/graphql/resolvers.js +++ /dev/null @@ -1,29 +0,0 @@ -import workItemQuery from './work_item.query.graphql'; - -export const resolvers = { - Mutation: { - localUpdateWorkItem(_, { input }, { cache }) { - const workItem = { - __typename: 'LocalWorkItem', - type: 'FEATURE', - id: input.id, - title: input.title, - widgets: { - __typename: 'LocalWorkItemWidgetConnection', - nodes: [], - }, - }; - - cache.writeQuery({ - query: workItemQuery, - variables: { id: input.id }, - data: { localWorkItem: workItem }, - }); - - return { - __typename: 'LocalUpdateWorkItemPayload', - workItem, - }; - }, - }, -}; diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql deleted file mode 100644 index 9b4811203f5..00000000000 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ /dev/null @@ -1,56 +0,0 @@ -enum LocalWorkItemType { - FEATURE -} - -enum LocalWidgetType { - TITLE -} - -interface LocalWorkItemWidget { - type: LocalWidgetType! -} - -# Replicating Relay connection type for client schema -type LocalWorkItemWidgetEdge { - cursor: String! - node: LocalWorkItemWidget -} - -type LocalWorkItemWidgetConnection { - edges: [LocalWorkItemWidgetEdge] - nodes: [LocalWorkItemWidget] - pageInfo: PageInfo! -} - -type LocalWorkItem { - id: ID! - type: LocalWorkItemType! - title: String! - widgets: [LocalWorkItemWidgetConnection] -} - -input LocalCreateWorkItemInput { - title: String! -} - -input LocalUpdateWorkItemInput { - id: ID! - title: String -} - -type LocalCreateWorkItemPayload { - workItem: LocalWorkItem! -} - -type LocalUpdateWorkItemPayload { - workItem: LocalWorkItem! -} - -extend type Query { - localWorkItem(id: ID!): LocalWorkItem! -} - -extend type Mutation { - localCreateWorkItem(input: LocalCreateWorkItemInput!): LocalCreateWorkItemPayload! - localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalUpdateWorkItemPayload! -} diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql index efb1ed8d6df..c0b6e856411 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -1,18 +1,9 @@ -#import './widget.fragment.graphql' +#import "./work_item.fragment.graphql" mutation workItemUpdate($input: WorkItemUpdateInput!) { workItemUpdate(input: $input) { workItem { - id - title - workItemType { - id - } - widgets @client { - nodes { - ...WidgetBase - } - } + ...WorkItem } } } diff --git a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql deleted file mode 100644 index 154367dc0d8..00000000000 --- a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql +++ /dev/null @@ -1,3 +0,0 @@ -fragment WidgetBase on LocalWorkItemWidget { - type -} diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql new file mode 100644 index 00000000000..2707d6bb790 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -0,0 +1,8 @@ +fragment WorkItem on WorkItem { + id + title + workItemType { + id + name + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index b32cb4f28fb..1d3dae0649d 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,16 +1,7 @@ -#import './widget.fragment.graphql' +#import "./work_item.fragment.graphql" -query WorkItem($id: ID!) { +query workItem($id: ID!) { workItem(id: $id) { - id - title - workItemType { - id - } - widgets @client { - nodes { - ...WidgetBase - } - } + ...WorkItem } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql new file mode 100644 index 00000000000..2ac01b79d6f --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql @@ -0,0 +1,8 @@ +subscription issuableTitleUpdated($issuableId: IssuableID!) { + issuableTitleUpdated(issuableId: $issuableId) { + ... on WorkItem { + id + title + } + } +} diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index cc90cedb110..a95da80ac95 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -1,21 +1,25 @@ <script> -import { GlButton, GlAlert, GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import workItemQuery from '../graphql/work_item.query.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; +import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; import ItemTitle from '../components/item_title.vue'; export default { + createErrorText: s__('WorkItem|Something went wrong when creating a work item. Please try again'), + fetchTypesErrorText: s__( + 'WorkItem|Something went wrong when fetching work item types. Please try again', + ), components: { GlButton, GlAlert, GlLoadingIcon, - GlDropdown, - GlDropdownItem, ItemTitle, + GlFormSelect, }, inject: ['fullPath'], props: { @@ -29,6 +33,26 @@ export default { required: false, default: '', }, + issueGid: { + type: String, + required: false, + default: '', + }, + lockVersion: { + type: Number, + required: false, + default: null, + }, + lineNumberStart: { + type: String, + required: false, + default: null, + }, + lineNumberEnd: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -36,6 +60,7 @@ export default { error: null, workItemTypes: [], selectedWorkItemType: null, + loading: false, }; }, apollo: { @@ -47,12 +72,13 @@ export default { }; }, update(data) { - return data.workspace?.workItemTypes?.nodes; + return data.workspace?.workItemTypes?.nodes.map((node) => ({ + value: node.id, + text: node.name, + })); }, error() { - this.error = s__( - 'WorkItem|Something went wrong when fetching work item types. Please try again', - ); + this.error = this.$options.fetchTypesErrorText; }, }, }, @@ -60,9 +86,24 @@ export default { dropdownButtonText() { return this.selectedWorkItemType?.name || s__('WorkItem|Type'); }, + formOptions() { + return [{ value: null, text: s__('WorkItem|Select type') }, ...this.workItemTypes]; + }, + isButtonDisabled() { + return this.title.trim().length === 0 || !this.selectedWorkItemType; + }, }, methods: { async createWorkItem() { + this.loading = true; + if (this.isModal) { + await this.createWorkItemFromTask(); + } else { + await this.createStandaloneWorkItem(); + } + this.loading = false; + }, + async createStandaloneWorkItem() { try { const response = await this.$apollo.mutate({ mutation: createWorkItemMutation, @@ -70,7 +111,7 @@ export default { input: { title: this.title, projectPath: this.fullPath, - workItemTypeId: this.selectedWorkItemType?.id, + workItemTypeId: this.selectedWorkItemType, }, }, update(store, { data: { workItemCreate } }) { @@ -87,32 +128,43 @@ export default { id, title, workItemType, - widgets: { - __typename: 'LocalWorkItemWidgetConnection', - nodes: [], - }, }, }, }); }, }); - const { data: { workItemCreate: { - workItem: { id, type }, + workItem: { id }, }, }, } = response; - if (!this.isModal) { - this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } }); - } else { - this.$emit('onCreate', { id, title: this.title, type }); - } + this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } }); + } catch { + this.error = this.$options.createErrorText; + } + }, + async createWorkItemFromTask() { + try { + const { data } = await this.$apollo.mutate({ + mutation: createWorkItemFromTaskMutation, + variables: { + input: { + id: this.issueGid, + workItemData: { + lockVersion: this.lockVersion, + title: this.title, + lineNumberStart: Number(this.lineNumberStart), + lineNumberEnd: Number(this.lineNumberEnd), + workItemTypeId: this.selectedWorkItemType, + }, + }, + }, + }); + this.$emit('onCreate', data.workItemCreateFromTask.workItem.descriptionHtml); } catch { - this.error = s__( - 'WorkItem|Something went wrong when creating a work item. Please try again', - ); + this.error = this.$options.createErrorText; } }, handleTitleInput(title) { @@ -125,9 +177,6 @@ export default { } this.$emit('closeModal'); }, - selectWorkItemType(type) { - this.selectedWorkItemType = type; - }, }, }; </script> @@ -136,28 +185,19 @@ export default { <form @submit.prevent="createWorkItem"> <gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert> <div :class="{ 'gl-px-5': isModal }" data-testid="content"> - <item-title - :initial-title="title" - data-testid="title-input" - @title-input="handleTitleInput" - /> + <item-title :title="title" data-testid="title-input" @title-input="handleTitleInput" /> <div> - <gl-dropdown :text="dropdownButtonText"> - <gl-loading-icon - v-if="$apollo.queries.workItemTypes.loading" - size="md" - data-testid="loading-types" - /> - <template v-else> - <gl-dropdown-item - v-for="type in workItemTypes" - :key="type.id" - @click="selectWorkItemType(type)" - > - {{ type.name }} - </gl-dropdown-item> - </template> - </gl-dropdown> + <gl-loading-icon + v-if="$apollo.queries.workItemTypes.loading" + size="md" + data-testid="loading-types" + /> + <gl-form-select + v-else + v-model="selectedWorkItemType" + :options="formOptions" + class="gl-max-w-26" + /> </div> </div> <div @@ -166,8 +206,9 @@ export default { > <gl-button variant="confirm" - :disabled="title.length === 0" + :disabled="isButtonDisabled" :class="{ 'gl-mr-3': !isModal }" + :loading="loading" data-testid="create-button" type="submit" > diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 32b6fc231a8..b8f2bcff25d 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,98 +1,26 @@ <script> -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import Tracking from '~/tracking'; -import workItemQuery from '../graphql/work_item.query.graphql'; -import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import { WI_TITLE_TRACK_LABEL } from '../constants'; - -import ItemTitle from '../components/item_title.vue'; - -const trackingMixin = Tracking.mixin(); +import WorkItemDetail from '../components/work_item_detail.vue'; export default { - titleUpdatedEvent: 'updated_title', components: { - ItemTitle, - GlAlert, - GlLoadingIcon, + WorkItemDetail, }, - mixins: [trackingMixin], props: { id: { type: String, required: true, }, }, - data() { - return { - workItem: {}, - error: false, - }; - }, - apollo: { - workItem: { - query: workItemQuery, - variables() { - return { - id: this.gid, - }; - }, - }, - }, computed: { - tracking() { - return { - category: 'workItems:show', - action: 'updated_title', - label: WI_TITLE_TRACK_LABEL, - property: '[type_work_item]', - }; - }, gid() { - return convertToGraphQLId('WorkItem', this.id); - }, - }, - methods: { - async updateWorkItem(updatedTitle) { - try { - await this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: this.gid, - title: updatedTitle, - }, - }, - }); - this.track(); - } catch { - this.error = true; - } + return convertToGraphQLId(TYPE_WORK_ITEM, this.id); }, }, }; </script> <template> - <section> - <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{ - __('Something went wrong while updating work item. Please try again') - }}</gl-alert> - <!-- Title widget placeholder --> - <div> - <gl-loading-icon - v-if="$apollo.queries.workItem.loading" - size="md" - data-testid="loading-types" - /> - <template v-else> - <item-title - :initial-title="workItem.title" - data-testid="title" - @title-changed="updateWorkItem" - /> - </template> - </div> - </section> + <work-item-detail :work-item-id="gid" /> </template> diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index 27ddff181c5..598ef70297c 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -1,318 +1,6 @@ -$text-color: $gl-text-color; - -$brand-primary: $blue-500; -$brand-success: $green-500; -$brand-info: $blue-500; -$brand-warning: $orange-500; -$brand-danger: $red-500; - -$border-radius-base: $gl-border-radius-base; - -$modal-body-bg: $white; -$input-border: $border-color; - -$padding-base-vertical: $gl-vert-padding; -$padding-base-horizontal: $gl-padding; - -/* - * Scss to help with bootstrap 3 to 4 migration - */ -body, -.form-control, -.search form { - // Override default font size used in non-csslab UI - // Use rem to keep default font-size at 14px on body so 1rem still - // fits 8px grid, but also allow users to change browser font size - font-size: 0.875rem; -} - -legend { - border-bottom: 1px solid $border-color; - margin-bottom: 20px; -} - -button, -html [type='button'], -[type='reset'], -[type='submit'], -[role='button'] { - // Override bootstrap reboot - /* stylelint-disable-next-line property-no-vendor-prefix */ - -webkit-appearance: inherit; - cursor: pointer; -} - -h1, -.h1, -h2, -.h2, -h3, -.h3 { - margin-top: 20px; - margin-bottom: 10px; -} - -h4, -.h4, -h5, -.h5, -h6, -.h6 { - margin-top: 10px; - margin-bottom: 10px; -} - -/* Our adjustments to hx & .hx above add unnecessary margins to modal-title - and page-title in modals, so we set them to 0 in order to have properly - formatted modal headers. */ -.modal-header { - .modal-title, - .page-title { - margin-top: 0; - margin-bottom: 0; - } -} - -h5, -.h5 { - font-size: $gl-font-size; -} - -input[type='file'] { - // Bootstrap 4 file input height is taller by default - // which makes them look ugly - line-height: 1; -} - -b, -strong { - font-weight: bold; -} - -a { - color: $blue-600; -} - -hr { - overflow: hidden; -} - -.form-group.row .col-form-label { - // Bootstrap 4 aligns labels to the left - // for horizontal forms - @include media-breakpoint-up(md) { - text-align: right; - } -} - -code { - padding: 2px 4px; - color: $code-color; - background-color: $gray-50; - border-radius: $border-radius-default; - - .code > &, - .build-log & { - background-color: inherit; - padding: unset; - } -} - -table { - // Remove any table border lines - border-spacing: 0; -} - -@each $breakpoint in map-keys($grid-breakpoints) { - @include media-breakpoint-up($breakpoint) { - $infix: breakpoint-infix($breakpoint, $grid-breakpoints); - - .d#{$infix}-table-header-group { - display: table-header-group !important; - } - } -} - -.text-secondary { - // Override Bootstrap's light secondary color - // We have to use !important because bootstrap has that set as well - color: $gl-text-color-secondary !important; -} - -.bg-success, -.bg-primary, -.bg-info, -.bg-danger, -.bg-warning { - .card-header { - color: $white; - } -} - -// Polyfill deprecated selectors - -.hidden { - display: none !important; - visibility: hidden !important; -} - -.hide { - display: none; -} - -.dropdown-toggle::after, -.dropright .dropdown-menu-toggle::after { - // Remove bootstrap's dropdown caret - display: none; -} - -// Add to .label so that old system notes that are saved to the db -// will still receive the correct styling -.badge:not(.gl-badge), -.label { - padding: 4px 5px; - font-size: 12px; - font-style: normal; - font-weight: $gl-font-weight-normal; - display: inline-block; - - &.badge-gray { - background-color: $label-gray-bg; - color: $gl-text-color; - text-shadow: none; - } - - &.badge-inverse { - background-color: $label-inverse-bg; - } -} - -.divider { - // copied rules from node_modules/bootstrap/scss/_dropdown.scss:116 - // this might be safe to just remove instead - // most places that use divider add overrides to undo these things - // there is also a probably-unintentional use in deprecated_dropdown_divider.scss - // so we would end up with .gl-dropdown .dropdown-divider - height: 0; - margin: 4px 0; - overflow: hidden; - border-top: 1px solid $border-color; -} - -.info-well { - background: $gray-10; - color: $gl-text-color; - border: 1px solid $border-color; - border-radius: 4px; - margin-bottom: 16px; - - .well-segment { - padding: 16px; - - &:not(:last-of-type) { - border-bottom: 1px solid $well-inner-border; - } - - p, - ol, - ul, - .form-group { - &:last-of-type { - margin-bottom: 0; - } - } - } - - .badge.badge-gray { - background-color: $well-expand-item; - } -} - -.card { - &.card-without-border, - &.bg-light { - border: 0 !important; - } -} - -.nav-tabs { - // Override bootstrap's default border - border-bottom: 0; - - .nav-link { - border-top: 0; - border-left: 0; - border-right: 0; - } - - .nav-item { - margin-bottom: 0; - } -} - -pre code { - white-space: pre-wrap; -} - -.alert { - border-radius: 0; -} - -.alert-success { - background-color: $green-500; - border-color: $green-500; -} - -.alert-info { - background-color: $blue-500; - border-color: $blue-500; -} - -.alert-warning { - background-color: $orange-500; - border-color: $orange-500; -} - -.alert-danger { - background-color: $red-500; - border-color: $red-500; -} - -.alert-success, -.alert-info, -.alert-warning, -.alert-danger { - color: $white; - - h4, - .alert-link { - color: $white; - } -} - -input[type=color].form-control { - height: $input-height; -} - -.toggle-sidebar-button { - .collapse-text, - .icon-chevron-double-lg-left, - .icon-chevron-double-lg-right { - color: $gl-text-color-secondary; - } -} - -.project-templates-buttons { - .btn { - vertical-align: unset; - } -} - -/* - Bootstrap 4.1.2 introduced a new default vertical alignment which breaks our icons, - so we need to reset the vertical alignment to the default value. See: - - https://gitlab.com/gitlab-org/gitlab-foss/issues/51362 - */ -svg { - vertical-align: baseline; -} +// --- +// Scss to help with bootstrap 3 to 4 migration +// --- +@import 'bootstrap_migration_variables'; +@import 'bootstrap_migration_reset'; +@import 'bootstrap_migration_components'; diff --git a/app/assets/stylesheets/bootstrap_migration_components.scss b/app/assets/stylesheets/bootstrap_migration_components.scss new file mode 100644 index 00000000000..b6cecbe5806 --- /dev/null +++ b/app/assets/stylesheets/bootstrap_migration_components.scss @@ -0,0 +1,216 @@ +// --- +// Scss to help with bootstrap 3 to 4 migration of bootstrap components +// --- +.form-control, +.search form { + // Override default font size used in non-csslab UI + // Use rem to keep default font-size at 14px on body so 1rem still + // fits 8px grid, but also allow users to change browser font size + font-size: 0.875rem; +} + +/* Our adjustments to hx & .hx above add unnecessary margins to modal-title + and page-title in modals, so we set them to 0 in order to have properly + formatted modal headers. */ +.modal-header { + .modal-title, + .page-title { + margin-top: 0; + margin-bottom: 0; + } +} + +input[type='file'] { + // Bootstrap 4 file input height is taller by default + // which makes them look ugly + line-height: 1; +} + +.form-group.row .col-form-label { + // Bootstrap 4 aligns labels to the left + // for horizontal forms + @include media-breakpoint-up(md) { + text-align: right; + } +} + +@each $breakpoint in map-keys($grid-breakpoints) { + @include media-breakpoint-up($breakpoint) { + $infix: breakpoint-infix($breakpoint, $grid-breakpoints); + + .d#{$infix}-table-header-group { + display: table-header-group !important; + } + } +} + +.text-secondary { + // Override Bootstrap's light secondary color + // We have to use !important because bootstrap has that set as well + color: $gl-text-color-secondary !important; +} + +.bg-success, +.bg-primary, +.bg-info, +.bg-danger, +.bg-warning { + .card-header { + color: $white; + } +} + +// Polyfill deprecated selectors + +.hidden { + display: none !important; + visibility: hidden !important; +} + +.hide { + display: none; +} + +.dropdown-toggle::after, +.dropright .dropdown-menu-toggle::after { + // Remove bootstrap's dropdown caret + display: none; +} + +// Add to .label so that old system notes that are saved to the db +// will still receive the correct styling +.badge:not(.gl-badge), +.label { + padding: 4px 5px; + font-size: 12px; + font-style: normal; + font-weight: $gl-font-weight-normal; + display: inline-block; + + &.badge-gray { + background-color: $label-gray-bg; + color: $gl-text-color; + text-shadow: none; + } + + &.badge-inverse { + background-color: $label-inverse-bg; + } +} + +.divider { + // copied rules from node_modules/bootstrap/scss/_dropdown.scss:116 + // this might be safe to just remove instead + // most places that use divider add overrides to undo these things + // there is also a probably-unintentional use in deprecated_dropdown_divider.scss + // so we would end up with .gl-dropdown .dropdown-divider + height: 0; + margin: 4px 0; + overflow: hidden; + border-top: 1px solid $border-color; +} + +.info-well { + background: $gray-10; + color: $gl-text-color; + border: 1px solid $border-color; + border-radius: 4px; + margin-bottom: 16px; + + .well-segment { + padding: 16px; + + &:not(:last-of-type) { + border-bottom: 1px solid $well-inner-border; + } + + p, + ol, + ul, + .form-group { + &:last-of-type { + margin-bottom: 0; + } + } + } + + .badge.badge-gray { + background-color: $well-expand-item; + } +} + +.card { + &.card-without-border, + &.bg-light { + border: 0 !important; + } +} + +.nav-tabs { + // Override bootstrap's default border + border-bottom: 0; + + .nav-link { + border-top: 0; + border-left: 0; + border-right: 0; + } + + .nav-item { + margin-bottom: 0; + } +} + +.alert { + border-radius: 0; +} + +.alert-success { + background-color: $green-500; + border-color: $green-500; +} + +.alert-info { + background-color: $blue-500; + border-color: $blue-500; +} + +.alert-warning { + background-color: $orange-500; + border-color: $orange-500; +} + +.alert-danger { + background-color: $red-500; + border-color: $red-500; +} + +.alert-success, +.alert-info, +.alert-warning, +.alert-danger { + color: $white; + + h4, + .alert-link { + color: $white; + } +} + +input[type=color].form-control { + height: $input-height; +} + +.toggle-sidebar-button { + .collapse-text, + .icon-chevron-double-lg-left, + .icon-chevron-double-lg-right { + color: $gl-text-color-secondary; + } +} + +.project-templates-buttons { + .btn { + vertical-align: unset; + } +} diff --git a/app/assets/stylesheets/bootstrap_migration_reset.scss b/app/assets/stylesheets/bootstrap_migration_reset.scss new file mode 100644 index 00000000000..ad315c4ada1 --- /dev/null +++ b/app/assets/stylesheets/bootstrap_migration_reset.scss @@ -0,0 +1,94 @@ +// --- +// Scss to help with bootstrap 3 to 4 migration of core elements +// --- +body { + // Override default font size used in non-csslab UI + // Use rem to keep default font-size at 14px on body so 1rem still + // fits 8px grid, but also allow users to change browser font size + font-size: 0.875rem; +} + +legend { + border-bottom: 1px solid $border-color; + margin-bottom: 20px; +} + +button, +html [type='button'], +[type='reset'], +[type='submit'], +[role='button'] { + // Override bootstrap reboot + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-appearance: inherit; + cursor: pointer; +} + +h1, +.h1, +h2, +.h2, +h3, +.h3 { + margin-top: 20px; + margin-bottom: 10px; +} + +h4, +.h4, +h5, +.h5, +h6, +.h6 { + margin-top: 10px; + margin-bottom: 10px; +} + +h5, +.h5 { + font-size: $gl-font-size; +} + +b, +strong { + font-weight: bold; +} + +a { + color: $blue-600; +} + +hr { + overflow: hidden; +} + +code { + padding: 2px 4px; + color: $code-color; + background-color: $gray-50; + border-radius: $border-radius-default; + + .code > &, + .build-log & { + background-color: inherit; + padding: unset; + } +} + +table { + // Remove any table border lines + border-spacing: 0; +} + +pre code { + white-space: pre-wrap; +} + +/* + Bootstrap 4.1.2 introduced a new default vertical alignment which breaks our icons, + so we need to reset the vertical alignment to the default value. See: + - https://gitlab.com/gitlab-org/gitlab-foss/issues/51362 + */ +svg { + vertical-align: baseline; +} diff --git a/app/assets/stylesheets/bootstrap_migration_variables.scss b/app/assets/stylesheets/bootstrap_migration_variables.scss new file mode 100644 index 00000000000..f3888de4ed8 --- /dev/null +++ b/app/assets/stylesheets/bootstrap_migration_variables.scss @@ -0,0 +1,15 @@ +$text-color: $gl-text-color; + +$brand-primary: $blue-500; +$brand-success: $green-500; +$brand-info: $blue-500; +$brand-warning: $orange-500; +$brand-danger: $red-500; + +$border-radius-base: $gl-border-radius-base; + +$modal-body-bg: $white; +$input-border: $border-color; + +$padding-base-vertical: $gl-vert-padding; +$padding-base-horizontal: $gl-padding; diff --git a/app/assets/stylesheets/components/milestone_combobox.scss b/app/assets/stylesheets/components/milestone_combobox.scss index 5d1709c22ec..94d295c324b 100644 --- a/app/assets/stylesheets/components/milestone_combobox.scss +++ b/app/assets/stylesheets/components/milestone_combobox.scss @@ -1,13 +1,4 @@ .milestone-combobox { - .selected-item { - /* stylelint-disable-next-line function-url-quotes */ - background: url(asset_path('checkmark.png')) no-repeat 0 2px; - } - - .dropdown-item-space { - padding: 8px 12px; - } - .dropdown-menu.show { overflow: hidden; } diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 2c72c4b0f65..1192c51b9aa 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -244,9 +244,13 @@ // above will be deprecated once all instances of "award emoji" are // migrated to Vue. -.gl-button .award-emoji-block gl-emoji { - margin-top: -1px; - margin-bottom: -1px; +.gl-button .award-emoji-block { + display: contents; + + gl-emoji { + margin-top: -1px; + margin-bottom: -1px; + } } .add-reaction-button { diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss index b8934d2797a..58f986ec0ae 100644 --- a/app/assets/stylesheets/framework/broadcast_messages.scss +++ b/app/assets/stylesheets/framework/broadcast_messages.scss @@ -15,6 +15,10 @@ .broadcast-banner-message { text-align: center; + + p { + margin-bottom: 0; + } } .broadcast-notification-message { diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 36a0d3ca3ca..be8a890320f 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -254,10 +254,9 @@ li.note { } img.emoji { - height: 20px; + height: 16px; vertical-align: top; width: 20px; - margin-top: 1px; } .chart { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 7f960e3da51..5c6d9266f7c 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -503,6 +503,7 @@ &.dropdown-menu-user-link::before { top: 50%; + transform: translateY(-50%); } } @@ -520,8 +521,22 @@ } &.is-active { - /* stylelint-disable-next-line function-url-quotes */ - background: url(asset_path('checkmark.png')) no-repeat 14px center; + position: relative; + + &::before { + content: ''; + display: block; + position: absolute; + top: 0.5rem; + left: 1rem; + width: 1rem; + height: 1rem; + mask-image: asset_url('icons-stacked.svg#check'); + mask-repeat: no-repeat; + mask-size: cover; + mask-position: center center; + background: $black-normal; + } } } } @@ -692,7 +707,7 @@ .dropdown-label-box { position: relative; - top: 3px; + top: 0; margin-right: 5px; display: inline-block; width: 15px; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 1004383cfd3..f44123fc2ed 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -1,5 +1,3 @@ -$top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important; - .navbar-gitlab { padding: 0 16px; z-index: $header-zindex; @@ -54,7 +52,7 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important white-space: nowrap; img { - height: 28px; + height: 24px; + .logo-text { margin-left: 8px; @@ -460,7 +458,6 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important vertical-align: text-top; } - a.upgrade-plan-link gl-emoji, a.ci-minutes-emoji gl-emoji, a.trial-link gl-emoji { font-size: $gl-font-size; @@ -574,7 +571,7 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important } .frequent-items-list-item-container > a:hover { - background-color: $top-nav-hover-bg; + background-color: $nav-active-bg; } } @@ -589,11 +586,9 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important } .top-nav-menu-item { - color: var(--indigo-900, $theme-indigo-900) !important; - &.active, &:hover { - background-color: $top-nav-hover-bg; + background-color: $nav-active-bg; } .gl-icon { @@ -603,7 +598,6 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important .top-nav-responsive { @include gl-display-none; - color: var(--indigo-900, $theme-indigo-900); } .top-nav-responsive-open { diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 7731ec751c9..7522f791b8e 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -72,7 +72,6 @@ @include media-breakpoint-down(xs) { .nav-item { flex: 1; - border-bottom: 1px solid $border-color; } .gl-tab-nav-item { @@ -84,7 +83,8 @@ width: 100%; display: flex; flex-wrap: wrap; - margin-top: $gl-padding-8; + padding-top: $gl-padding-8; + border-top: 1px solid $border-color; } } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index e77971d5280..d270f802c56 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -226,3 +226,29 @@ .edit-link { margin-right: -$gl-spacing-scale-2; } + +.assignee-grid { + grid-template-areas: ' attention user'; + grid-template-columns: min-content 1fr; +} + +.reviewer-grid { + grid-template-areas: ' user approval rerequest'; + grid-template-columns: 1fr min-content min-content; + + &.attention-requests { + grid-template-areas: ' attention user approval'; + grid-template-columns: min-content 1fr min-content; + } +} + +.assignee-grid, +.reviewer-grid { + [data-css-area='attention'] { + grid-area: attention; + } + + [data-css-area='user'] { + grid-area: user; + } +} diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index c6bc8fa0eac..6348703e9e1 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -48,7 +48,7 @@ table { th { padding: 10px $gl-padding; line-height: 20px; - vertical-align: middle; + vertical-align: top; } th { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 31ef5ae0646..8e3b34e4eaf 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -363,6 +363,7 @@ $well-expand-item: #e8f2f7 !default; $well-inner-border: #eef0f2 !default; $well-light-border: #f1f1f1; $well-light-text-color: #5b6169; +$nav-active-bg: var(--nav-active-bg, rgba($black, 0.08)) !important; /* * Text diff --git a/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss b/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss new file mode 100644 index 00000000000..30895a55711 --- /dev/null +++ b/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss @@ -0,0 +1,36 @@ +/** +* CSS variables used below are declared in `app/views/layouts/_diffs_colors_css.haml` +*/ +.diff-custom-addition-color { + .code { + .line_holder { + .diff-line-num, + .line-coverage, + .line-codequality, + .line_content { + &.new { + &:not(.hll) { + background: var(--diff-addition-color); + } + + &.line_content span.idiff { + background: var(--diff-addition-color) !important; + } + + &::before, + a { + mix-blend-mode: luminosity; + } + } + } + } + + .gd { + background-color: var(--diff-addition-color); + } + } + + .idiff.addition { + background: var(--diff-addition-color) !important; + } +} diff --git a/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss b/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss new file mode 100644 index 00000000000..a8ab43909eb --- /dev/null +++ b/app/assets/stylesheets/highlight/diff_custom_colors_deletion.scss @@ -0,0 +1,36 @@ +/** +* CSS variables used below are declared in `app/views/layouts/_diffs_colors_css.haml` +*/ +.diff-custom-deletion-color { + .code { + .line_holder { + .diff-line-num, + .line-coverage, + .line-codequality, + .line_content { + &.old { + &:not(.hll) { + background: var(--diff-deletion-color); + } + + &.line_content span.idiff { + background: var(--diff-deletion-color) !important; + } + + &::before, + a { + mix-blend-mode: luminosity; + } + } + } + } + + .gd { + background-color: var(--diff-deletion-color); + } + } + + .idiff.deletion { + background: var(--diff-deletion-color) !important; + } +} diff --git a/app/assets/stylesheets/highlight/hljs.scss b/app/assets/stylesheets/highlight/hljs.scss new file mode 100644 index 00000000000..2e31e7c1f6d --- /dev/null +++ b/app/assets/stylesheets/highlight/hljs.scss @@ -0,0 +1,125 @@ +.code.highlight { + .hljs-comment { + color: var(--color-hljs-comment); + } + + .hljs-link { + color: var(--color-hljs-link); + } + + .hljs-meta { + color: var(--color-hljs-meta); + } + + .hljs-keyword { + color: var(--color-hljs-keyword); + } + + .hljs-type { + color: var(--color-hljs-type); + } + + .hljs-attr, + .hljs-property { + color: var(--color-hljs-attr); + } + + .hljs-built_in { + color: var(--color-hljs-builtin); + } + + .hljs-literal { + color: var(--color-hljs-literal); + } + + .hljs-title { + color: var(--color-hljs-title); + + &.class_ { + color: var(--color-hljs-class); + } + + &.function_ { + color: var(--color-hljs-function); + } + } + + .hljs-tag , + .hljs-name { + color: var(--color-hljs-tag); + } + + .hljs-number { + color: var(--color-hljs-number); + } + + .hljs-subst { + color: var(--color-hljs-subst); + } + + .hljs-string, + .hljs-section, + .hljs-bullet { + color: var(--color-hljs-string); + } + + .hljs-symbol { + color: var(--color-hljs-symbol); + } + + .hljs-variable { + color: var(--color-hljs-variable); + + &.language_ { + color: var(--color-hljs-language); + } + + &.constant_ { + color: var(--color-hljs-constant); + } + } + + .hljs-attribute { + color: var(--color-hljs-attribute); + } + + .hljs-operator { + color: var(--color-hljs-operator); + } + + .hljs-punctuation { + color: var(--color-hljs-punctuation); + } + + .hljs-regexp { + color: var(--color-hljs-regexp); + } + + .hljs-params { + color: var(--color-hljs-params); + } + + .hljs-doctag { + color: var(--color-hljs-doctag); + } + + .hljs-selector-tag { + color: var(--color-hljs-selector-tag); + } + + .hljs-selector-class { + color: var(--color-hljs-selector-class); + } + + .hljs-selector-id { + color: var(--color-hljs-selector-id); + } + + .hljs-selector-attr { + color: var(--color-hljs-selector-attr); + } + + .hljs-selector-pseudo { + color: var(--color-hljs-selector-pseudo); + } +} diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 28878280d24..c51b1f04757 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -1,6 +1,7 @@ /* https://github.com/MozMorris/tomorrow-pygments */ @import '../common'; +@import '../hljs'; /* * Dark syntax colors @@ -88,6 +89,41 @@ $dark-vg: #c66; $dark-vi: #c66; $dark-il: #de935f; +:root { + --color-hljs-comment: #{$dark-c}; + --color-hljs-variable: #{$dark-k}; + --color-hljs-link: #{$dark-l}; + --color-hljs-meta: #{$dark-cp}; + --color-hljs-keyword: #{$dark-kd}; + --color-hljs-type: #{$dark-kt}; + --color-hljs-attr: #{$dark-na}; + --color-hljs-builtin: #{$dark-nb}; + --color-hljs-title: #{$dark-n}; + --color-hljs-class: #{$dark-nc}; + --color-hljs-function: #{$dark-nf}; + --color-hljs-tag: #{$dark-nt}; + --color-hljs-number: #{$dark-mi}; + --color-hljs-subst: #{$dark-sc}; + --color-hljs-string: #{$dark-s1}; + --color-hljs-symbol: #{$dark-ss}; + --color-hljs-variable: #{$dark-vi}; + --color-hljs-operator: #{$dark-o}; + --color-hljs-punctuation: #{$dark-p}; + --color-hljs-regexp: #{$dark-sr}; + --color-hljs-constant: #{$dark-nx}; + --color-hljs-literal: #{$dark-kc}; + --color-hljs-language: #{$dark-nx}; + --color-hljs-params: #{$dark-nx}; + --color-hljs-selector-doctag: #{$dark-cm}; + --color-hljs-selector-tag: #{$dark-nt}; + --color-hljs-selector-class: #{$dark-nc}; + --color-hljs-selector-id: #{$dark-nn}; + --color-hljs-selector-attr: #{$dark-nt}; + --color-hljs-selector-pseudo: #{$dark-nd}; + --default-diff-color-deletion: #ff3333; + --default-diff-color-addition: #288f2a; +} + .code.dark { // Line numbers .file-line-num { diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 6faf1cffdef..226bb44f0e7 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -89,6 +89,11 @@ $monokai-gd: #f92672; $monokai-gi: #a6e22e; $monokai-gh: #75715e; +:root { + --default-diff-color-deletion: #c87872; + --default-diff-color-addition: #678528; +} + .code.monokai { // Line numbers .file-line-num { diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index 9c28d9463dc..7a36aba8be7 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -9,6 +9,11 @@ background-color: $white-normal; } +:root { + --default-diff-color-deletion: #b4b4b4; + --default-diff-color-addition: #b4b4b4; +} + .code.none { // Line numbers .file-line-num { diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index c9f889c79fc..acd401e1694 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -92,6 +92,11 @@ $solarized-dark-vg: #268bd2; $solarized-dark-vi: #268bd2; $solarized-dark-il: #2aa198; +:root { + --default-diff-color-deletion: #ff362c; + --default-diff-color-addition: #647e0e; +} + .code.solarized-dark { // Line numbers .file-line-num { diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 0108d7e496f..ddcecc4cbcf 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -94,6 +94,11 @@ $solarized-light-vg: #268bd2; $solarized-light-vi: #268bd2; $solarized-light-il: #2aa198; +:root { + --default-diff-color-deletion: #dc322f; + --default-diff-color-addition: #859900; +} + @mixin match-line { color: $black-transparent; background: $solarized-light-matchline-bg; diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss index ed1d9c924c0..8698e448c94 100644 --- a/app/assets/stylesheets/highlight/themes/white.scss +++ b/app/assets/stylesheets/highlight/themes/white.scss @@ -3,3 +3,8 @@ @include conflict-colors('white'); } + +:root { + --default-diff-color-deletion: #eb919b; + --default-diff-color-addition: #a0f5b4; +}
\ No newline at end of file diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 91d8f4a1ba5..20a36d2e8b1 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -149,7 +149,6 @@ pre.code, .diff-line-num { &.old { background-color: $line-number-old; - border-color: $line-removed-dark; a { color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); @@ -158,7 +157,6 @@ pre.code, &.new { background-color: $line-number-new; - border-color: $line-added-dark; a { color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); diff --git a/app/assets/stylesheets/notify_base.scss b/app/assets/stylesheets/notify_base.scss index 8c6f9a27077..0ca1398c609 100644 --- a/app/assets/stylesheets/notify_base.scss +++ b/app/assets/stylesheets/notify_base.scss @@ -1,5 +1,6 @@ -@import 'framework/mixins'; @import 'framework/variables'; +@import 'framework/variables_overrides'; +@import 'framework/mixins'; img { max-width: 100%; diff --git a/app/assets/stylesheets/notify_enhanced.scss b/app/assets/stylesheets/notify_enhanced.scss index 5df5a8592bf..a366498ea03 100644 --- a/app/assets/stylesheets/notify_enhanced.scss +++ b/app/assets/stylesheets/notify_enhanced.scss @@ -2,20 +2,30 @@ // keep parts that affect elements that can appear in emails; // omit Bootstrap Reboot since it adds unnecessary styles to every element. @import 'notify_base'; + +// bootstrap variables, mixins, functions @import 'bootstrap/scss/functions'; @import 'bootstrap/scss/variables'; @import 'bootstrap/scss/mixins'; + +// bootstrap styles @import 'bootstrap/scss/code'; + +// @gitlab/ui variables, mixins, functions @import '@gitlab/ui/src/scss/variables'; @import '@gitlab/ui/src/scss/utility-mixins/index'; -@import '@gitlab/ui/src/scss/components'; -@import 'bootstrap_migration'; -@import 'framework/common'; + +// @gitlab/ui styles +@import '@gitlab/ui/src/components/base/icon/icon'; +@import '@gitlab/ui/src/components/base/label/label'; + +// gitlab styles +@import 'bootstrap_migration_variables'; +@import 'bootstrap_migration_reset'; @import 'framework/gfm'; @import 'framework/kbd'; @import 'framework/tables'; @import 'framework/typography'; -@import 'framework/emojis'; body { font-family: $regular-font; @@ -26,11 +36,15 @@ a { text-decoration: none; } -.content { - .md { - padding: 1rem 0; - } +.gl-mb-5 { + @include gl-mb-5; +} +.gl-mt-5 { + @include gl-mt-5; +} + +.content { hr { border: 1px solid #e1e1e1; } diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index f91ca489bdf..eecd4954e39 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -198,7 +198,7 @@ border-bottom: 1px solid var(--gray-100, $gray-100); height: 3rem; - .js-max-issue-size::before { + .max-issue-size::before { content: '/'; } } diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss index b7a4d9564fe..cd5e6d32e4e 100644 --- a/app/assets/stylesheets/page_bundles/import.scss +++ b/app/assets/stylesheets/page_bundles/import.scss @@ -1,18 +1,22 @@ @import 'mixins_and_variables_and_functions'; +.import-jobs-from-col { + width: 37%; +} + + .import-jobs-to-col { - width: 39%; + width: 37%; } .import-jobs-status-col { - width: 15%; + width: 25%; } .import-jobs-cta-col { width: 1%; } - .import-entities-target-select { &.disabled { .import-entities-target-select-separator { diff --git a/app/assets/stylesheets/page_bundles/jira_connect_users.scss b/app/assets/stylesheets/page_bundles/jira_connect_users.scss index 6725bf8f1a1..602910adad9 100644 --- a/app/assets/stylesheets/page_bundles/jira_connect_users.scss +++ b/app/assets/stylesheets/page_bundles/jira_connect_users.scss @@ -1,13 +1 @@ -@import 'mixins_and_variables_and_functions'; - -.jira-connect-users-container { - margin-left: auto; - margin-right: auto; - width: px-to-rem(350px); -} - -.devise-layout-html body .navless-container { - @include media-breakpoint-down(xs) { - padding-top: 65px; - } -} +@import '../themes/theme_indigo'; diff --git a/app/assets/stylesheets/page_bundles/learn_gitlab.scss b/app/assets/stylesheets/page_bundles/learn_gitlab.scss index 10a4a210d41..189aefb330b 100644 --- a/app/assets/stylesheets/page_bundles/learn_gitlab.scss +++ b/app/assets/stylesheets/page_bundles/learn_gitlab.scss @@ -1,11 +1,3 @@ .learn-gitlab-info-card-content { height: 200px; } - -.learn-gitlab-section-card { - height: 400px; -} - -.learn-gitlab-section-card-header { - height: 165px; -} diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss index 989219552a6..aa582db10d2 100644 --- a/app/assets/stylesheets/page_bundles/milestone.scss +++ b/app/assets/stylesheets/page_bundles/milestone.scss @@ -7,21 +7,7 @@ $status-box-line-height: 26px; } .milestones { - padding: $gl-padding-8; - margin-top: $gl-padding-8; - border-radius: $border-radius-default; - background-color: var(--gray-100, $gray-100); - .milestone { - border: 0; - padding: $gl-padding-top $gl-padding; - border-radius: $border-radius-default; - background-color: var(--white, $white); - - &:not(:last-child) { - margin-bottom: $gl-padding-4; - } - h4 { font-weight: $gl-font-weight-bold; } diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index d9ad82d4e4b..27d81d8e53b 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -1,12 +1,4 @@ .clusters-container { - .empty-state .svg-content { - @include gl-pb-0; - - img { - width: 100px; - } - } - @include media-breakpoint-down(xs) { .nav-controls { @include gl-w-full; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 9bb4c5357e7..f127b0dc66c 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -311,7 +311,7 @@ ul.related-merge-requests > li gl-emoji { .description.work-items-enabled { ul.task-list { > li.task-list-item { - padding-inline-start: 2.25rem; + padding-inline-start: 2.5rem; .js-add-task { svg { @@ -324,7 +324,7 @@ ul.related-merge-requests > li gl-emoji { } > input.task-list-item-checkbox { - left: 0.875rem; + left: 1.25rem; } &:hover, diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index c84a83c1fab..18a0f119edf 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -45,6 +45,36 @@ input[type='checkbox']:hover { transition: border-color ease-in-out $default-transition-duration, background-color ease-in-out $default-transition-duration; } + + &.is-not-active { + .btn.gl-clear-icon-button { + display: none; + } + + &::after { + content: '/'; + display: inline-block; + position: absolute; + top: 0; + right: 8px; + transform: translateY(calc(50% - 4px)); + padding: 4px 5px; + font-size: $gl-font-size-small; + font-family: $monospace-font; + line-height: 1; + vertical-align: middle; + border-width: 0; + border-style: solid; + border-image: none; + border-radius: 3px; + box-shadow: none; + white-space: pre-wrap; + // Safari + word-wrap: break-word; + overflow-wrap: break-word; + word-break: keep-all; + } + } } .header-search-dropdown-menu { diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 5956368a977..0c7b74684cc 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -110,9 +110,14 @@ .bs-callout, .form-check:first-child, - .form-text.text-muted { + .form-check .form-text.text-muted, + .form-check + .form-text.text-muted { margin-top: 0; } + + .form-check .form-text.text-muted { + margin-bottom: $grid-size; + } } .settings-list-icon { diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss index d38c1818f53..f79237eee3d 100644 --- a/app/assets/stylesheets/snippets.scss +++ b/app/assets/stylesheets/snippets.scss @@ -33,6 +33,10 @@ text-align: left; } + .file-holder { + margin: 0; + } + .file-content.code { border: $border-style; border-radius: 0 0 $border-radius-default $border-radius-default; diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 00195f553dc..62d45332204 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -9,7 +9,6 @@ body.gl-dark { --gray-900: #fafafa; --green-100: #0d532a; --green-700: #91d4a8; - --indigo-900-alpha-008: rgba(235, 235, 250, 0.08); --gl-text-color: #fafafa; --border-color: #4f4f4f; --black: #fff; @@ -453,9 +452,12 @@ a.gl-badge.badge-warning:active { .gl-form-input.form-control:disabled, .gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only { background-color: #1f1f1f; - color: #868686; box-shadow: inset 0 0 0 1px #404040; +} +.gl-form-input:disabled, +.gl-form-input.form-control:disabled { cursor: not-allowed; + color: #868686; } .gl-form-input::placeholder, .gl-form-input.form-control::placeholder { @@ -544,9 +546,7 @@ a.gl-badge.badge-warning:active { padding-right: 2rem; padding-left: 1.75rem; } -body, -.form-control, -.search form { +body { font-size: 0.875rem; } button, @@ -564,6 +564,13 @@ strong { a { color: #63a6e9; } +svg { + vertical-align: baseline; +} +.form-control, +.search form { + font-size: 0.875rem; +} .hidden { display: none !important; visibility: hidden !important; @@ -588,9 +595,6 @@ a { .toggle-sidebar-button .icon-chevron-double-lg-left { color: #999; } -svg { - vertical-align: baseline; -} html { overflow-y: scroll; } @@ -804,7 +808,7 @@ input { white-space: nowrap; } .navbar-gitlab .header-content .title img { - height: 28px; + height: 24px; } .navbar-gitlab .header-content .title img + .logo-text { margin-left: 8px; @@ -1512,6 +1516,29 @@ svg.s16 { .header-search { width: 320px; } +.header-search.is-not-active::after { + content: "/"; + display: inline-block; + position: absolute; + top: 0; + right: 8px; + transform: translateY(calc(50% - 4px)); + padding: 4px 5px; + font-size: 12px; + font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", + "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; + line-height: 1; + vertical-align: middle; + border-width: 0; + border-style: solid; + border-image: none; + border-radius: 3px; + box-shadow: none; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: keep-all; +} .search { margin: 0 8px; } @@ -1739,7 +1766,6 @@ body.gl-dark { --indigo-800: #d1d1f0; --indigo-900: #ebebfa; --indigo-950: #f7f7ff; - --indigo-900-alpha-008: rgba(235, 235, 250, 0.08); --purple-50: #232150; --purple-100: #2f2a6b; --purple-200: #453894; @@ -1769,7 +1795,7 @@ body.gl-dark { box-shadow: none; } .nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) { - background-color: var(--indigo-900-alpha-008); + background-color: var(--nav-active-bg); } body.gl-dark { --gl-theme-accent: #868686; @@ -1851,6 +1877,10 @@ body.gl-dark .header-search input::placeholder { body.gl-dark .header-search input:active::placeholder { color: #868686; } +body.gl-dark .header-search.is-not-active::after { + color: #fafafa; + background-color: rgba(250, 250, 250, 0.2); +} body.gl-dark .search form { background-color: rgba(250, 250, 250, 0.2); } @@ -1972,7 +2002,6 @@ body.gl-dark { --indigo-800: #d1d1f0; --indigo-900: #ebebfa; --indigo-950: #f7f7ff; - --indigo-900-alpha-008: rgba(235, 235, 250, 0.08); --purple-50: #232150; --purple-100: #2f2a6b; --purple-200: #453894; diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 6d66e207bdc..a8b7299b935 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -438,9 +438,12 @@ a.gl-badge.badge-warning:active { .gl-form-input.form-control:disabled, .gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only { background-color: #fafafa; - color: #868686; box-shadow: inset 0 0 0 1px #dbdbdb; +} +.gl-form-input:disabled, +.gl-form-input.form-control:disabled { cursor: not-allowed; + color: #868686; } .gl-form-input::placeholder, .gl-form-input.form-control::placeholder { @@ -529,9 +532,7 @@ a.gl-badge.badge-warning:active { padding-right: 2rem; padding-left: 1.75rem; } -body, -.form-control, -.search form { +body { font-size: 0.875rem; } button, @@ -549,6 +550,13 @@ strong { a { color: #1068bf; } +svg { + vertical-align: baseline; +} +.form-control, +.search form { + font-size: 0.875rem; +} .hidden { display: none !important; visibility: hidden !important; @@ -573,9 +581,6 @@ a { .toggle-sidebar-button .icon-chevron-double-lg-left { color: #666; } -svg { - vertical-align: baseline; -} html { overflow-y: scroll; } @@ -789,7 +794,7 @@ input { white-space: nowrap; } .navbar-gitlab .header-content .title img { - height: 28px; + height: 24px; } .navbar-gitlab .header-content .title img + .logo-text { margin-left: 8px; @@ -1497,6 +1502,29 @@ svg.s16 { .header-search { width: 320px; } +.header-search.is-not-active::after { + content: "/"; + display: inline-block; + position: absolute; + top: 0; + right: 8px; + transform: translateY(calc(50% - 4px)); + padding: 4px 5px; + font-size: 12px; + font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", + "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; + line-height: 1; + vertical-align: middle; + border-width: 0; + border-style: solid; + border-image: none; + border-radius: 3px; + box-shadow: none; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: keep-all; +} .search { margin: 0 8px; } diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index 213d1c013a0..751ad26ca21 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -301,9 +301,12 @@ fieldset:disabled a.btn { .gl-form-input.form-control:disabled, .gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only { background-color: #fafafa; - color: #868686; box-shadow: inset 0 0 0 1px #dbdbdb; +} +.gl-form-input:disabled, +.gl-form-input.form-control:disabled { cursor: not-allowed; + color: #868686; } .gl-form-input::placeholder, .gl-form-input.form-control::placeholder { @@ -369,8 +372,7 @@ fieldset:disabled a.btn { outline: none; background-color: #0b5cad; } -body, -.form-control { +body { font-size: 0.875rem; } [type="submit"] { @@ -387,6 +389,12 @@ a { hr { overflow: hidden; } +svg { + vertical-align: baseline; +} +.form-control { + font-size: 0.875rem; +} .hidden { display: none !important; visibility: hidden !important; @@ -394,9 +402,6 @@ hr { .hide { display: none; } -svg { - vertical-align: baseline; -} html { overflow-y: scroll; } diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index 3cb8c58a380..550e3981401 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -70,7 +70,6 @@ $indigo-700: #a6a6de; $indigo-800: #d1d1f0; $indigo-900: #ebebfa; $indigo-950: #f7f7ff; -$indigo-900-alpha-008: rgba($indigo-900, 0.08); $purple-50: #232150; $purple-100: #2f2a6b; @@ -174,7 +173,6 @@ body.gl-dark { --indigo-800: #{$indigo-800}; --indigo-900: #{$indigo-900}; --indigo-950: #{$indigo-950}; - --indigo-900-alpha-008: #{$indigo-900-alpha-008}; --purple-50: #{$purple-50}; --purple-100: #{$purple-100}; diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index bb9a9cf0497..83254fe1a52 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -48,7 +48,7 @@ } > a:hover { - background-color: var(--indigo-900-alpha-008); + background-color: var(--nav-active-bg); } &.active { @@ -56,7 +56,7 @@ &:not(.fly-out-top-item) { > a:not(.has-sub-items) { - background-color: var(--indigo-900-alpha-008); + background-color: var(--nav-active-bg); } } } diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index c6e29c7f8b0..07194e2b532 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -176,6 +176,11 @@ } } } + + &.is-not-active::after { + color: $search-and-nav-links; + background-color: rgba($search-and-nav-links, 0.2); + } } .search { diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 0511a179980..d7a5e21e303 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -366,8 +366,3 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 /* stylelint-disable property-no-vendor-prefix */ -webkit-backdrop-filter: blur(2px); // still required by Safari } - -// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2708 -.gl-inset-border-l-3-red-600 { - box-shadow: inset $gl-border-size-3 0 0 0 $red-600; -} |