diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-07-20 09:55:51 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-07-20 09:55:51 +0000 |
commit | e8d2c2579383897a1dd7f9debd359abe8ae8373d (patch) | |
tree | c42be41678c2586d49a75cabce89322082698334 /app/assets/javascripts | |
parent | fc845b37ec3a90aaa719975f607740c22ba6a113 (diff) | |
download | gitlab-ce-e8d2c2579383897a1dd7f9debd359abe8ae8373d.tar.gz |
Add latest changes from gitlab-org/gitlab@14-1-stable-eev14.1.0-rc42
Diffstat (limited to 'app/assets/javascripts')
759 files changed, 10388 insertions, 7806 deletions
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index b671d038ce8..f45af5fe08e 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -42,7 +42,7 @@ export default class Activities { } updateTooltips() { - localTimeAgo($('.js-timeago', '.content_list')); + localTimeAgo(document.querySelectorAll('.content_list .js-timeago')); } reloadActivities() { diff --git a/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js b/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js index a357d5d2f1f..cfa2f4b8762 100644 --- a/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js +++ b/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js @@ -1,3 +1,4 @@ +import initSetHelperText from '~/pages/admin/application_settings/metrics_and_profiling/usage_statistics'; import PayloadPreviewer from '~/pages/admin/application_settings/payload_previewer'; export default () => { @@ -5,3 +6,5 @@ export default () => { new PayloadPreviewer(trigger).init(); }); }; + +initSetHelperText(); diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue index 99c260bf11e..74e9c60a57b 100644 --- a/app/assets/javascripts/admin/users/components/actions/activate.vue +++ b/app/assets/javascripts/admin/users/components/actions/activate.vue @@ -1,6 +1,16 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { sprintf, s__, __ } from '~/locale'; +import { I18N_USER_ACTIONS } from '../../constants'; + +// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 +const messageHtml = ` + <p>${s__('AdminUsers|Reactivating a user will:')}</p> + <ul> + <li>${s__('AdminUsers|Restore user access to the account, including web, Git and API.')}</li> + </ul> + <p>${s__('AdminUsers|You can always deactivate their account again if needed.')}</p> +`; export default { components: { @@ -25,9 +35,14 @@ export default { title: sprintf(s__('AdminUsers|Activate user %{username}?'), { username: this.username, }), - message: s__('AdminUsers|You can always deactivate their account again if needed.'), - okVariant: 'confirm', - okTitle: s__('AdminUsers|Activate'), + messageHtml, + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.activate, + attributes: [{ variant: 'confirm' }], + }, }), }; }, @@ -36,9 +51,7 @@ export default { </script> <template> - <div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> - <gl-dropdown-item> - <slot></slot> - </gl-dropdown-item> - </div> + <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <slot></slot> + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue index 6fc43c246ea..77a9be8eec2 100644 --- a/app/assets/javascripts/admin/users/components/actions/approve.vue +++ b/app/assets/javascripts/admin/users/components/actions/approve.vue @@ -1,21 +1,60 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; +import { I18N_USER_ACTIONS } from '../../constants'; + +// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 +const messageHtml = ` + <p>${s__('AdminUsers|Approved users can:')}</p> + <ul> + <li>${s__('AdminUsers|Log in')}</li> + <li>${s__('AdminUsers|Access Git repositories')}</li> + <li>${s__('AdminUsers|Access the API')}</li> + <li>${s__('AdminUsers|Be added to groups and projects')}</li> + </ul> +`; export default { components: { GlDropdownItem, }, props: { + username: { + type: String, + required: true, + }, path: { type: String, required: true, }, }, + computed: { + attributes() { + return { + 'data-path': this.path, + 'data-method': 'put', + 'data-modal-attributes': JSON.stringify({ + title: sprintf(s__('AdminUsers|Approve user %{username}?'), { + username: this.username, + }), + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.approve, + attributes: [{ variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' }], + }, + messageHtml, + }), + 'data-qa-selector': 'approve_user_button', + }; + }, + }, }; </script> <template> - <gl-dropdown-item :href="path" data-method="put"> + <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...attributes }"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue new file mode 100644 index 00000000000..4e9cefbfdd7 --- /dev/null +++ b/app/assets/javascripts/admin/users/components/actions/ban.vue @@ -0,0 +1,69 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { sprintf, s__, __ } from '~/locale'; +import { I18N_USER_ACTIONS } from '../../constants'; + +// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 +const messageHtml = ` + <p>${s__('AdminUsers|When banned, users:')}</p> + <ul> + <li>${s__("AdminUsers|Can't log in.")}</li> + <li>${s__("AdminUsers|Can't access Git repositories.")}</li> + </ul> + <p>${s__('AdminUsers|You can unban their account in the future. Their data remains intact.')}</p> + <p>${sprintf( + s__('AdminUsers|Learn more about %{link_start}banned users.%{link_end}'), + { + link_start: `<a href="${helpPagePath('user/admin_area/moderate_users', { + anchor: 'ban-a-user', + })}" target="_blank">`, + link_end: '</a>', + }, + false, + )}</p> +`; + +export default { + components: { + GlDropdownItem, + }, + props: { + username: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + computed: { + modalAttributes() { + return { + 'data-path': this.path, + 'data-method': 'put', + 'data-modal-attributes': JSON.stringify({ + title: sprintf(s__('AdminUsers|Ban user %{username}?'), { + username: this.username, + }), + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.ban, + attributes: [{ variant: 'confirm' }], + }, + messageHtml, + }), + }; + }, + }, +}; +</script> + +<template> + <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <slot></slot> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue index 68dfefe14c2..03557008a89 100644 --- a/app/assets/javascripts/admin/users/components/actions/block.vue +++ b/app/assets/javascripts/admin/users/components/actions/block.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { sprintf, s__, __ } from '~/locale'; +import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 const messageHtml = ` @@ -11,6 +12,7 @@ const messageHtml = ` <li>${s__('AdminUsers|Personal projects will be left')}</li> <li>${s__('AdminUsers|Owned groups will be left')}</li> </ul> + <p>${s__('AdminUsers|You can always unblock their account, their data will remain intact.')}</p> `; export default { @@ -34,8 +36,13 @@ export default { 'data-method': 'put', 'data-modal-attributes': JSON.stringify({ title: sprintf(s__('AdminUsers|Block user %{username}?'), { username: this.username }), - okVariant: 'confirm', - okTitle: s__('AdminUsers|Block'), + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.block, + attributes: [{ variant: 'confirm' }], + }, messageHtml, }), }; @@ -45,9 +52,7 @@ export default { </script> <template> - <div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> - <gl-dropdown-item> - <slot></slot> - </gl-dropdown-item> - </div> + <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <slot></slot> + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue index 7e0c17ba296..640c8fefc20 100644 --- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue +++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { sprintf, s__, __ } from '~/locale'; +import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 const messageHtml = ` @@ -16,6 +17,9 @@ const messageHtml = ` )}</li> <li>${s__('AdminUsers|Personal projects, group and user history will be left intact')}</li> </ul> + <p>${s__( + 'AdminUsers|You can always re-activate their account, their data will remain intact.', + )}</p> `; export default { @@ -41,8 +45,13 @@ export default { title: sprintf(s__('AdminUsers|Deactivate user %{username}?'), { username: this.username, }), - okVariant: 'confirm', - okTitle: s__('AdminUsers|Deactivate'), + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.deactivate, + attributes: [{ variant: 'confirm' }], + }, messageHtml, }), }; @@ -52,9 +61,7 @@ export default { </script> <template> - <div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> - <gl-dropdown-item> - <slot></slot> - </gl-dropdown-item> - </div> + <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <slot></slot> + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/index.js b/app/assets/javascripts/admin/users/components/actions/index.js index e34b01346b9..4e63a85df89 100644 --- a/app/assets/javascripts/admin/users/components/actions/index.js +++ b/app/assets/javascripts/admin/users/components/actions/index.js @@ -1,20 +1,24 @@ import Activate from './activate.vue'; import Approve from './approve.vue'; +import Ban from './ban.vue'; import Block from './block.vue'; import Deactivate from './deactivate.vue'; import Delete from './delete.vue'; import DeleteWithContributions from './delete_with_contributions.vue'; import Reject from './reject.vue'; +import Unban from './unban.vue'; import Unblock from './unblock.vue'; import Unlock from './unlock.vue'; export default { Activate, Approve, + Ban, Block, Deactivate, Delete, DeleteWithContributions, + Unban, Unblock, Unlock, Reject, diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue index a80c1ff5458..901306455fa 100644 --- a/app/assets/javascripts/admin/users/components/actions/reject.vue +++ b/app/assets/javascripts/admin/users/components/actions/reject.vue @@ -1,21 +1,70 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { sprintf, s__, __ } from '~/locale'; +import { I18N_USER_ACTIONS } from '../../constants'; + +// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 +const messageHtml = ` + <p>${s__('AdminUsers|Rejected users:')}</p> + <ul> + <li>${s__('AdminUsers|Cannot sign in or access instance information')}</li> + <li>${s__('AdminUsers|Will be deleted')}</li> + </ul> + <p>${sprintf( + s__( + 'AdminUsers|For more information, please refer to the %{link_start}user account deletion documentation.%{link_end}', + ), + { + link_start: `<a href="${helpPagePath('user/profile/account/delete_account', { + anchor: 'associated-records', + })}" target="_blank">`, + link_end: '</a>', + }, + false, + )}</p> +`; export default { components: { GlDropdownItem, }, props: { + username: { + type: String, + required: true, + }, path: { type: String, required: true, }, }, + computed: { + modalAttributes() { + return { + 'data-path': this.path, + 'data-method': 'delete', + 'data-modal-attributes': JSON.stringify({ + title: sprintf(s__('AdminUsers|Reject user %{username}?'), { + username: this.username, + }), + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.reject, + attributes: [{ variant: 'danger' }], + }, + messageHtml, + }), + }; + }, + }, }; </script> <template> - <gl-dropdown-item :href="path" data-method="delete"> + <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue new file mode 100644 index 00000000000..8083e26177e --- /dev/null +++ b/app/assets/javascripts/admin/users/components/actions/unban.vue @@ -0,0 +1,53 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; +import { I18N_USER_ACTIONS } from '../../constants'; + +// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 +const messageHtml = `<p>${s__( + 'AdminUsers|You can ban their account in the future if necessary.', +)}</p>`; + +export default { + components: { + GlDropdownItem, + }, + props: { + username: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + computed: { + modalAttributes() { + return { + 'data-path': this.path, + 'data-method': 'put', + 'data-modal-attributes': JSON.stringify({ + title: sprintf(s__('AdminUsers|Unban user %{username}?'), { + username: this.username, + }), + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.unban, + attributes: [{ variant: 'confirm' }], + }, + messageHtml, + }), + }; + }, + }, +}; +</script> + +<template> + <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <slot></slot> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue index d4c0f900c94..7de6653e0cd 100644 --- a/app/assets/javascripts/admin/users/components/actions/unblock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { sprintf, s__, __ } from '~/locale'; +import { I18N_USER_ACTIONS } from '../../constants'; export default { components: { @@ -24,8 +25,13 @@ export default { 'data-modal-attributes': JSON.stringify({ title: sprintf(s__('AdminUsers|Unblock user %{username}?'), { username: this.username }), message: s__('AdminUsers|You can always block their account again if needed.'), - okVariant: 'confirm', - okTitle: s__('AdminUsers|Unblock'), + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.unblock, + attributes: [{ variant: 'confirm' }], + }, }), }; }, @@ -34,9 +40,7 @@ export default { </script> <template> - <div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> - <gl-dropdown-item> - <slot></slot> - </gl-dropdown-item> - </div> + <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <slot></slot> + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue index 294aaade7c1..10d4fb06d61 100644 --- a/app/assets/javascripts/admin/users/components/actions/unlock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import { I18N_USER_ACTIONS } from '../../constants'; export default { components: { @@ -24,8 +25,13 @@ export default { 'data-modal-attributes': JSON.stringify({ title: sprintf(s__('AdminUsers|Unlock user %{username}?'), { username: this.username }), message: __('Are you sure?'), - okVariant: 'confirm', - okTitle: s__('AdminUsers|Unlock'), + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.unlock, + attributes: [{ variant: 'confirm' }], + }, }), }; }, @@ -34,9 +40,7 @@ export default { </script> <template> - <div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> - <gl-dropdown-item> - <slot></slot> - </gl-dropdown-item> - </div> + <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <slot></slot> + </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue index a3b78da6ef5..413163c8536 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue @@ -58,7 +58,7 @@ export default { }, computed: { modalTitle() { - return sprintf(this.title, { username: this.username }); + return sprintf(this.title, { username: this.username }, false); }, secondaryButtonLabel() { return s__('AdminUsers|Block user'); @@ -112,7 +112,7 @@ export default { </gl-sprintf> </p> - <oncall-schedules-list v-if="schedules.length" :schedules="schedules" /> + <oncall-schedules-list v-if="schedules.length" :schedules="schedules" :user-name="username" /> <p> <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> diff --git a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue b/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue index 1dfea3f1e7b..1dfea3f1e7b 100644 --- a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue +++ b/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index b782526e6be..c076e0bedf0 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -5,6 +5,7 @@ import { GlDropdownItem, GlDropdownSectionHeader, GlDropdownDivider, + GlTooltipDirective, } from '@gitlab/ui'; import { convertArrayToCamelCase } from '~/lib/utils/common_utils'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; @@ -21,6 +22,9 @@ export default { GlDropdownDivider, ...Actions, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { user: { type: Object, @@ -30,6 +34,11 @@ export default { type: Object, required: true, }, + showButtonLabels: { + type: Boolean, + required: false, + default: false, + }, }, computed: { userActions() { @@ -56,6 +65,13 @@ export default { userPaths() { return generateUserPaths(this.paths, this.user.username); }, + editButtonAttrs() { + return { + 'data-testid': 'edit', + icon: 'pencil-square', + href: this.userPaths.edit, + }; + }, }, methods: { isLdapAction(action) { @@ -70,51 +86,68 @@ export default { </script> <template> - <div class="gl-display-flex gl-justify-content-end" :data-testid="`user-actions-${user.id}`"> - <gl-button v-if="hasEditAction" data-testid="edit" :href="userPaths.edit">{{ - $options.i18n.edit - }}</gl-button> + <div + class="gl-display-flex gl-justify-content-end gl-my-n2 gl-mx-n2" + :data-testid="`user-actions-${user.id}`" + > + <div v-if="hasEditAction" class="gl-p-2"> + <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs">{{ + $options.i18n.edit + }}</gl-button> + <gl-button + v-else + v-gl-tooltip="$options.i18n.edit" + v-bind="editButtonAttrs" + :aria-label="$options.i18n.edit" + /> + </div> - <gl-dropdown - v-if="hasDropdownActions" - data-testid="dropdown-toggle" - right - class="gl-ml-2" - icon="settings" - > - <gl-dropdown-section-header>{{ $options.i18n.settings }}</gl-dropdown-section-header> + <div v-if="hasDropdownActions" class="gl-p-2"> + <gl-dropdown + data-testid="dropdown-toggle" + right + :text="$options.i18n.userAdministration" + :text-sr-only="!showButtonLabels" + icon="settings" + data-qa-selector="user_actions_dropdown_toggle" + :data-qa-username="user.username" + > + <gl-dropdown-section-header>{{ + $options.i18n.userAdministration + }}</gl-dropdown-section-header> - <template v-for="action in dropdownSafeActions"> - <component - :is="getActionComponent(action)" - v-if="getActionComponent(action)" - :key="action" - :path="userPaths[action]" - :username="user.name" - :data-testid="action" - > - {{ $options.i18n[action] }} - </component> - <gl-dropdown-item v-else-if="isLdapAction(action)" :key="action" :data-testid="action"> - {{ $options.i18n[action] }} - </gl-dropdown-item> - </template> + <template v-for="action in dropdownSafeActions"> + <component + :is="getActionComponent(action)" + v-if="getActionComponent(action)" + :key="action" + :path="userPaths[action]" + :username="user.name" + :data-testid="action" + > + {{ $options.i18n[action] }} + </component> + <gl-dropdown-item v-else-if="isLdapAction(action)" :key="action" :data-testid="action"> + {{ $options.i18n[action] }} + </gl-dropdown-item> + </template> - <gl-dropdown-divider v-if="hasDeleteActions" /> + <gl-dropdown-divider v-if="hasDeleteActions" /> - <template v-for="action in dropdownDeleteActions"> - <component - :is="getActionComponent(action)" - v-if="getActionComponent(action)" - :key="action" - :paths="userPaths" - :username="user.name" - :oncall-schedules="user.oncallSchedules" - :data-testid="`delete-${action}`" - > - {{ $options.i18n[action] }} - </component> - </template> - </gl-dropdown> + <template v-for="action in dropdownDeleteActions"> + <component + :is="getActionComponent(action)" + v-if="getActionComponent(action)" + :key="action" + :paths="userPaths" + :username="user.name" + :oncall-schedules="user.oncallSchedules" + :data-testid="`delete-${action}`" + > + {{ $options.i18n[action] }} + </component> + </template> + </gl-dropdown> + </div> </div> </template> diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js index c55edefe607..4636c8705a5 100644 --- a/app/assets/javascripts/admin/users/constants.js +++ b/app/assets/javascripts/admin/users/constants.js @@ -6,7 +6,7 @@ export const LENGTH_OF_USER_NOTE_TOOLTIP = 100; export const I18N_USER_ACTIONS = { edit: __('Edit'), - settings: __('Settings'), + userAdministration: s__('AdminUsers|User administration'), unlock: __('Unlock'), block: s__('AdminUsers|Block'), unblock: s__('AdminUsers|Unblock'), @@ -17,4 +17,12 @@ export const I18N_USER_ACTIONS = { ldapBlocked: s__('AdminUsers|Cannot unblock LDAP blocked users'), delete: s__('AdminUsers|Delete user'), deleteWithContributions: s__('AdminUsers|Delete user and contributions'), + 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 54c8edc080b..852b253d25a 100644 --- a/app/assets/javascripts/admin/users/index.js +++ b/app/assets/javascripts/admin/users/index.js @@ -2,7 +2,15 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; 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 UserActions from './components/user_actions.vue'; +import { + CONFIRM_DELETE_BUTTON_SELECTOR, + MODAL_TEXTS_CONTAINER_SELECTOR, + MODAL_MANAGER_SELECTOR, +} from './constants'; Vue.use(VueApollo); @@ -10,22 +18,71 @@ const apolloProvider = new VueApollo({ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), }); -export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => { +const initApp = (el, component, userPropKey, props = {}) => { if (!el) { return false; } - const { users, paths } = el.dataset; + const { [userPropKey]: user, paths } = el.dataset; return new Vue({ el, apolloProvider, render: (createElement) => - createElement(AdminUsersApp, { + createElement(component, { props: { - users: convertObjectPropsToCamelCase(JSON.parse(users), { deep: true }), + [userPropKey]: convertObjectPropsToCamelCase(JSON.parse(user), { deep: true }), paths: convertObjectPropsToCamelCase(JSON.parse(paths)), + ...props, }, }), }); }; + +export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => + initApp(el, AdminUsersApp, 'users'); + +export const initAdminUserActions = (el = document.querySelector('#js-admin-user-actions')) => + 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, + functional: true, + methods: { + show(...args) { + this.$refs.manager.show(...args); + }, + }, + render(h) { + return h(ModalManager, { + ref: 'manager', + props: { + selector: CONFIRM_DELETE_BUTTON_SELECTOR, + modalConfiguration, + csrfToken: csrf.token, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue index c0ad814172d..7c14cf3767f 100644 --- a/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue +++ b/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue @@ -25,28 +25,33 @@ export default { }; </script> <template> - <gl-empty-state class="js-empty-state" :title="__('Usage ping is off')" :svg-path="svgPath"> + <gl-empty-state :title="s__('ServicePing|Service ping is off')" :svg-path="svgPath"> <template #description> <gl-sprintf v-if="!isAdmin" :message=" - __( - 'To view instance-level analytics, ask an admin to turn on %{docLinkStart}usage ping%{docLinkEnd}.', + s__( + 'ServicePing|To view instance-level analytics, ask an admin to turn on %{docLinkStart}service ping%{docLinkEnd}.', ) " > <template #docLink="{ content }"> - <gl-link :href="docsLink" target="_blank">{{ content }}</gl-link> + <gl-link :href="docsLink" target="_blank" data-testid="docs-link">{{ content }}</gl-link> </template> </gl-sprintf> - <template v-else - ><p> - {{ __('Turn on usage ping to review instance-level analytics.') }} + <template v-else> + <p> + {{ s__('ServicePing|Turn on service ping to review instance-level analytics.') }} </p> - <gl-button category="primary" variant="success" :href="primaryButtonPath"> - {{ __('Turn on usage ping') }}</gl-button + <gl-button + category="primary" + variant="success" + :href="primaryButtonPath" + data-testid="power-on-button" > + {{ s__('ServicePing|Turn on service ping') }} + </gl-button> </template> </template> </gl-empty-state> diff --git a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js index 0131407e723..63b36f35247 100644 --- a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js +++ b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js @@ -1,27 +1,33 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import UserCallout from '~/user_callout'; -import UsagePingDisabled from './components/usage_ping_disabled.vue'; +import ServicePingDisabled from './components/service_ping_disabled.vue'; export default () => { // eslint-disable-next-line no-new new UserCallout(); - const emptyStateContainer = document.getElementById('js-devops-usage-ping-disabled'); + const emptyStateContainer = document.getElementById('js-devops-service-ping-disabled'); if (!emptyStateContainer) return false; - const { emptyStateSvgPath, enableUsagePingLink, docsLink, isAdmin } = emptyStateContainer.dataset; + const { + isAdmin, + emptyStateSvgPath, + enableServicePingPath, + docsLink, + } = emptyStateContainer.dataset; return new Vue({ el: emptyStateContainer, provide: { - isAdmin: Boolean(isAdmin), + isAdmin: parseBoolean(isAdmin), svgPath: emptyStateSvgPath, - primaryButtonPath: enableUsagePingLink, + primaryButtonPath: enableServicePingPath, docsLink, }, render(h) { - return h(UsagePingDisabled); + return h(ServicePingDisabled); }, }); }; diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue new file mode 100644 index 00000000000..a5b9c40b9c9 --- /dev/null +++ b/app/assets/javascripts/analytics/shared/components/daterange.vue @@ -0,0 +1,121 @@ +<script> +import { GlDaterangePicker, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { getDayDifference } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; +import { OFFSET_DATE_BY_ONE } from '../constants'; + +export default { + components: { + GlDaterangePicker, + GlSprintf, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + show: { + type: Boolean, + required: false, + default: true, + }, + startDate: { + type: Date, + required: false, + default: null, + }, + endDate: { + type: Date, + required: false, + default: null, + }, + minDate: { + type: Date, + required: false, + default: null, + }, + maxDate: { + type: Date, + required: false, + default() { + return new Date(); + }, + }, + maxDateRange: { + type: Number, + required: false, + default: 0, + }, + includeSelectedDate: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + maxDateRangeTooltip: sprintf( + __( + 'Showing data for workflow items created in this date range. Date range cannot exceed %{maxDateRange} days.', + ), + { + maxDateRange: this.maxDateRange, + }, + ), + }; + }, + computed: { + dateRange: { + get() { + return { startDate: this.startDate, endDate: this.endDate }; + }, + set({ startDate, endDate }) { + this.$emit('change', { startDate, endDate }); + }, + }, + numberOfDays() { + const dayDifference = getDayDifference(this.startDate, this.endDate); + return this.includeSelectedDate ? dayDifference + OFFSET_DATE_BY_ONE : dayDifference; + }, + }, +}; +</script> +<template> + <div + v-if="show" + class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row align-items-lg-center justify-content-lg-end" + > + <gl-daterange-picker + v-model="dateRange" + class="d-flex flex-column flex-lg-row" + :default-start-date="startDate" + :default-end-date="endDate" + :default-min-date="minDate" + :max-date-range="maxDateRange" + :default-max-date="maxDate" + :same-day-selection="includeSelectedDate" + theme="animate-picker" + start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0" + end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center" + label-class="gl-mb-2 gl-lg-mb-0" + /> + <div + v-if="maxDateRange" + class="daterange-indicator d-flex flex-row flex-lg-row align-items-flex-start align-items-lg-center" + > + <span class="number-of-days pl-2 pr-1"> + <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)"> + <template #numberOfDays>{{ numberOfDays }}</template> + </gl-sprintf> + </span> + <gl-icon + v-gl-tooltip + data-testid="helper-icon" + :title="maxDateRangeTooltip" + name="question" + :size="14" + class="text-secondary" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue deleted file mode 100644 index e6e12821bec..00000000000 --- a/app/assets/javascripts/analytics/shared/components/metric_card.vue +++ /dev/null @@ -1,80 +0,0 @@ -<script> -import { - GlCard, - GlDeprecatedSkeletonLoading as GlSkeletonLoading, - GlLink, - GlIcon, - GlTooltipDirective, -} from '@gitlab/ui'; - -export default { - name: 'MetricCard', - components: { - GlCard, - GlSkeletonLoading, - GlLink, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - title: { - type: String, - required: true, - }, - metrics: { - type: Array, - required: true, - }, - isLoading: { - type: Boolean, - required: false, - default: false, - }, - }, - methods: { - valueText(metric) { - const { value = null, unit = null } = metric; - if (!value || value === '-') return '-'; - return unit && value ? `${value} ${unit}` : value; - }, - }, -}; -</script> -<template> - <gl-card class="gl-mb-5"> - <template #header> - <strong ref="title">{{ title }}</strong> - </template> - <template #default> - <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3" /> - <div v-else ref="metricsWrapper" class="gl-display-flex"> - <div - v-for="metric in metrics" - :key="metric.key" - ref="metricItem" - class="js-metric-card-item gl-flex-grow-1 gl-text-center" - > - <gl-link v-if="metric.link" :href="metric.link"> - <h3 class="gl-my-2 gl-text-blue-700">{{ valueText(metric) }}</h3> - </gl-link> - <h3 v-else class="gl-my-2">{{ valueText(metric) }}</h3> - <p class="text-secondary gl-font-sm gl-mb-2"> - {{ metric.label }} - <span v-if="metric.tooltipText"> - - <gl-icon - v-gl-tooltip="{ title: metric.tooltipText }" - :size="14" - class="gl-vertical-align-middle" - name="question" - data-testid="tooltip" - /> - </span> - </p> - </div> - </div> - </template> - </gl-card> -</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 new file mode 100644 index 00000000000..a490111e13b --- /dev/null +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -0,0 +1,241 @@ +<script> +import { + GlIcon, + GlLoadingIcon, + GlAvatar, + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { filterBySearchTerm } from '~/analytics/shared/utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { n__, s__, __ } from '~/locale'; +import getProjects from '../graphql/projects.query.graphql'; + +export default { + name: 'ProjectsDropdownFilter', + components: { + GlIcon, + GlLoadingIcon, + GlAvatar, + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, + GlSearchBoxByType, + }, + props: { + groupId: { + type: Number, + required: true, + }, + groupNamespace: { + type: String, + required: true, + }, + multiSelect: { + type: Boolean, + required: false, + default: false, + }, + label: { + type: String, + required: false, + default: s__('CycleAnalytics|project dropdown filter'), + }, + queryParams: { + type: Object, + required: false, + default: () => ({}), + }, + defaultProjects: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + loading: true, + projects: [], + selectedProjects: this.defaultProjects || [], + searchTerm: '', + isDirty: false, + }; + }, + computed: { + selectedProjectsLabel() { + if (this.selectedProjects.length === 1) { + return this.selectedProjects[0].name; + } else if (this.selectedProjects.length > 1) { + return n__( + 'CycleAnalytics|Project selected', + 'CycleAnalytics|%d projects selected', + this.selectedProjects.length, + ); + } + + return this.selectedProjectsPlaceholder; + }, + selectedProjectsPlaceholder() { + return this.multiSelect ? __('Select projects') : __('Select a project'); + }, + isOnlyOneProjectSelected() { + return this.selectedProjects.length === 1; + }, + selectedProjectIds() { + return this.selectedProjects.map((p) => p.id); + }, + availableProjects() { + return filterBySearchTerm(this.projects, this.searchTerm); + }, + noResultsAvailable() { + const { loading, availableProjects } = this; + return !loading && !availableProjects.length; + }, + }, + watch: { + searchTerm() { + this.search(); + }, + }, + mounted() { + this.search(); + }, + methods: { + search: debounce(function debouncedSearch() { + this.fetchData(); + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + getSelectedProjects(selectedProject, isMarking) { + return isMarking + ? this.selectedProjects.concat([selectedProject]) + : this.selectedProjects.filter((project) => project.id !== selectedProject.id); + }, + singleSelectedProject(selectedObj, isMarking) { + return isMarking ? [selectedObj] : []; + }, + setSelectedProjects(selectedObj, isMarking) { + this.selectedProjects = this.multiSelect + ? this.getSelectedProjects(selectedObj, isMarking) + : this.singleSelectedProject(selectedObj, isMarking); + }, + onClick({ project, isSelected }) { + this.setSelectedProjects(project, !isSelected); + this.$emit('selected', this.selectedProjects); + }, + onMultiSelectClick({ project, isSelected }) { + this.setSelectedProjects(project, !isSelected); + this.isDirty = true; + }, + onSelected(ev) { + if (this.multiSelect) { + this.onMultiSelectClick(ev); + } else { + this.onClick(ev); + } + }, + onHide() { + if (this.multiSelect && this.isDirty) { + this.$emit('selected', this.selectedProjects); + } + this.searchTerm = ''; + this.isDirty = false; + }, + fetchData() { + this.loading = true; + + return this.$apollo + .query({ + query: getProjects, + variables: { + groupFullPath: this.groupNamespace, + search: this.searchTerm, + ...this.queryParams, + }, + }) + .then((response) => { + const { + data: { + group: { + projects: { nodes }, + }, + }, + } = response; + + this.loading = false; + this.projects = nodes; + }); + }, + isProjectSelected(id) { + return this.selectedProjects ? this.selectedProjectIds.includes(id) : false; + }, + getEntityId(project) { + return getIdFromGraphQLId(project.id); + }, + }, +}; +</script> +<template> + <gl-dropdown + ref="projectsDropdown" + class="dropdown dropdown-projects" + toggle-class="gl-shadow-none" + @hide="onHide" + > + <template #button-content> + <div class="gl-display-flex gl-flex-grow-1"> + <gl-avatar + v-if="isOnlyOneProjectSelected" + :src="selectedProjects[0].avatarUrl" + :entity-id="getEntityId(selectedProjects[0])" + :entity-name="selectedProjects[0].name" + :size="16" + shape="rect" + :alt="selectedProjects[0].name" + class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2" + /> + {{ selectedProjectsLabel }} + </div> + <gl-icon class="gl-ml-2" name="chevron-down" /> + </template> + <template #header> + <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header> + <gl-search-box-by-type v-model.trim="searchTerm" /> + </template> + <gl-dropdown-item + v-for="project in availableProjects" + :key="project.id" + :is-check-item="true" + :is-checked="isProjectSelected(project.id)" + @click.native.capture.stop=" + onSelected({ project, isSelected: isProjectSelected(project.id) }) + " + > + <div class="gl-display-flex"> + <gl-avatar + class="gl-mr-2 vertical-align-middle" + :alt="project.name" + :size="16" + :entity-id="getEntityId(project)" + :entity-name="project.name" + :src="project.avatarUrl" + shape="rect" + /> + <div> + <div data-testid="project-name">{{ project.name }}</div> + <div class="gl-text-gray-500" data-testid="project-full-path"> + {{ project.fullPath }} + </div> + </div> + </div> + </gl-dropdown-item> + <gl-dropdown-item v-show="noResultsAvailable" class="gl-pointer-events-none text-secondary">{{ + __('No matching results') + }}</gl-dropdown-item> + <gl-dropdown-item v-if="loading"> + <gl-loading-icon size="lg" /> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js new file mode 100644 index 00000000000..44d9b4b4262 --- /dev/null +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -0,0 +1,12 @@ +import { masks } from 'dateformat'; + +export const DATE_RANGE_LIMIT = 180; +export const OFFSET_DATE_BY_ONE = 1; +export const PROJECTS_PER_PAGE = 50; + +const { isoDate, mediumDate } = masks; +export const dateFormats = { + isoDate, + defaultDate: mediumDate, + defaultDateTime: 'mmm d, yyyy h:MMtt', +}; diff --git a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql new file mode 100644 index 00000000000..63e95d6804c --- /dev/null +++ b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql @@ -0,0 +1,22 @@ +query getGroupProjects( + $groupFullPath: ID! + $search: String! + $first: Int! + $includeSubgroups: Boolean = false +) { + group(fullPath: $groupFullPath) { + projects( + search: $search + first: $first + includeSubgroups: $includeSubgroups + sort: SIMILARITY + ) { + nodes { + id + name + avatarUrl + fullPath + } + } + } +} diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js new file mode 100644 index 00000000000..84189b675f2 --- /dev/null +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -0,0 +1,4 @@ +export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => { + if (!searchTerm?.length) return data; + return data.filter((item) => item[filterByKey].toLowerCase().includes(searchTerm.toLowerCase())); +}; diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue index 0b4fa879b03..1eb4832a2a3 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue @@ -1,5 +1,6 @@ <script> -import MetricCard from '~/analytics/shared/components/metric_card.vue'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; import createFlash from '~/flash'; import { number } from '~/lib/utils/unit_format'; import { s__ } from '~/locale'; @@ -10,7 +11,8 @@ const defaultPrecision = 0; export default { name: 'UsageCounts', components: { - MetricCard, + GlSkeletonLoading, + GlSingleStat, }, data() { return { @@ -56,10 +58,24 @@ export default { </script> <template> - <metric-card - :title="__('Usage Trends')" - :metrics="counts" - :is-loading="$apollo.queries.counts.loading" - class="gl-mt-4" - /> + <div> + <h2> + {{ __('Usage Trends') }} + </h2> + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-my-6 gl-align-items-flex-start" + > + <gl-skeleton-loading v-if="$apollo.queries.counts.loading" /> + <template v-else> + <gl-single-stat + v-for="count in counts" + :key="count.key" + class="gl-pr-9 gl-my-4 gl-md-mt-0 gl-md-mb-0" + :value="`${count.value}`" + :title="count.label" + :should-animate="true" + /> + </template> + </div> + </div> </template> diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 41cc2036a6b..84a5d5ae4b3 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -3,7 +3,7 @@ import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { joinPaths } from './lib/utils/url_utility'; -const DEFAULT_PER_PAGE = 20; +export const DEFAULT_PER_PAGE = 20; /** * Slow deprecation Notice: Please rather use for new calls @@ -83,8 +83,8 @@ const Api = { tagsPath: '/api/:version/projects/:id/repository/tags', freezePeriodsPath: '/api/:version/projects/:id/freeze_periods', freezePeriodPath: '/api/:version/projects/:id/freeze_periods/:freeze_period_id', - usageDataIncrementCounterPath: '/api/:version/usage_data/increment_counter', - usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users', + serviceDataIncrementCounterPath: '/api/:version/usage_data/increment_counter', + serviceDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users', featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists', featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid', containerRegistryDetailsPath: '/api/:version/registry/repositories/:id/', @@ -875,7 +875,7 @@ const Api = { return null; } - const url = Api.buildUrl(this.usageDataIncrementCounterPath); + const url = Api.buildUrl(this.serviceDataIncrementCounterPath); const headers = { 'Content-Type': 'application/json', }; @@ -888,7 +888,7 @@ const Api = { return null; } - const url = Api.buildUrl(this.usageDataIncrementUniqueUsersPath); + const url = Api.buildUrl(this.serviceDataIncrementUniqueUsersPath); const headers = { 'Content-Type': 'application/json', }; diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js index 58494c5a2b8..fd9b0160b0d 100644 --- a/app/assets/javascripts/api/analytics_api.js +++ b/app/assets/javascripts/api/analytics_api.js @@ -1,6 +1,8 @@ import axios from '~/lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; +const GROUP_VSA_PATH_BASE = + '/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id'; const PROJECT_VSA_PATH_BASE = '/:project_path/-/analytics/value_stream_analytics/value_streams'; const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`; @@ -13,6 +15,12 @@ const buildProjectValueStreamPath = (projectPath, valueStreamId = null) => { return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':project_path', projectPath); }; +const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) => + buildApiUrl(GROUP_VSA_PATH_BASE) + .replace(':id', groupId) + .replace(':value_stream_id', valueStreamId) + .replace(':stage_id', stageId); + export const getProjectValueStreams = (projectPath) => { const url = buildProjectValueStreamPath(projectPath); return axios.get(url); @@ -30,3 +38,14 @@ export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) export const getProjectValueStreamMetrics = (requestPath, params) => axios.get(requestPath, { params }); + +/** + * Shared group VSA paths + * We share some endpoints across and group and project level VSA + * When used for project level VSA, requests should include the `project_id` in the params object + */ + +export const getValueStreamStageMedian = ({ groupId, valueStreamId, stageId }, params = {}) => { + const stageBase = buildGroupValueStreamPath({ groupId, valueStreamId, stageId }); + return axios.get(`${stageBase}/median`, { params }); +}; diff --git a/app/assets/javascripts/api/constants.js b/app/assets/javascripts/api/constants.js deleted file mode 100644 index b6c720a85f3..00000000000 --- a/app/assets/javascripts/api/constants.js +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_PER_PAGE = 20; diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js index d6c9e1d42cc..a563afc6abb 100644 --- a/app/assets/javascripts/api/groups_api.js +++ b/app/assets/javascripts/api/groups_api.js @@ -1,6 +1,6 @@ +import { DEFAULT_PER_PAGE } from '~/api'; import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; -import { DEFAULT_PER_PAGE } from './constants'; const GROUPS_PATH = '/api/:version/groups.json'; const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups'; diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js index d9a2467cff3..1cd7fb0b954 100644 --- a/app/assets/javascripts/api/projects_api.js +++ b/app/assets/javascripts/api/projects_api.js @@ -1,6 +1,6 @@ +import { DEFAULT_PER_PAGE } from '~/api'; import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; -import { DEFAULT_PER_PAGE } from './constants'; const PROJECTS_PATH = '/api/:version/projects.json'; diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index 27901120c53..09995fad628 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -1,8 +1,8 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import { DEFAULT_PER_PAGE } from '~/api'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; -import { DEFAULT_PER_PAGE } from './constants'; const USER_COUNTS_PATH = '/api/:version/user_counts'; const USERS_PATH = '/api/:version/users.json'; @@ -52,7 +52,11 @@ export function getUserProjects(userId, query, options, callback) { params: { ...defaults, ...options }, }) .then(({ data }) => callback(data)) - .catch(() => flash(__('Something went wrong while fetching projects'))); + .catch(() => + createFlash({ + message: __('Something went wrong while fetching projects'), + }), + ); } export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) { diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 43f44370af8..43ca5b5cf89 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -7,7 +7,7 @@ import { uniq } from 'lodash'; import * as Emoji from '~/emoji'; import { scrollToElement } from '~/lib/utils/common_utils'; import { dispose, fixTitle } from '~/tooltips'; -import { deprecatedCreateFlash as flash } from './flash'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { __ } from './locale'; @@ -488,7 +488,11 @@ export class AwardsHandler { callback(); } }) - .catch(() => flash(__('Something went wrong on our end.'))); + .catch(() => + createFlash({ + message: __('Something went wrong on our end.'), + }), + ); } findEmojiIcon(votesBlock, emoji) { diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index 309af368df9..53469ac8999 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -84,7 +84,7 @@ export default { /> </a> - <gl-loading-icon v-show="isLoading" :inline="true" /> + <gl-loading-icon v-show="isLoading" size="sm" :inline="true" /> <div v-show="hasError" class="btn-group"> <div class="btn btn-default btn-sm disabled"> diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 7c4ff830a9d..7e605099655 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -221,7 +221,7 @@ export default { :link-url="renderedLinkUrl" /> <p v-show="isRendering"> - <gl-loading-icon :inline="true" /> + <gl-loading-icon size="sm" :inline="true" /> </p> <p v-show="!renderedBadge && !isRendering" class="disabled-content"> {{ s__('Badges|No image to preview') }} diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue index fda51c98e2c..d8525c15087 100644 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -73,7 +73,7 @@ export default { data-testid="delete-badge" @click="updateBadgeInModal(badge)" /> - <gl-loading-icon v-show="badge.isDeleting" :inline="true" /> + <gl-loading-icon v-show="badge.isDeleting" size="sm" :inline="true" /> </div> </div> </div> diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index e6de724512f..96c3b8276ee 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -94,9 +94,11 @@ export default { @handleUpdateNote="update" @toggleResolveStatus="toggleResolveDiscussion(draft.id)" > - <strong slot="note-header-info" class="badge draft-pending-label gl-mr-2"> - {{ __('Pending') }} - </strong> + <template #note-header-info> + <strong class="badge draft-pending-label gl-mr-2"> + {{ __('Pending') }} + </strong> + </template> </noteable-note> </ul> diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index 9ffc5ee34cf..080a5543e53 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -26,7 +26,7 @@ export default { </script> <template> <div v-show="draftsCount > 0"> - <nav class="review-bar-component"> + <nav class="review-bar-component" data-testid="review_bar_component"> <div class="review-bar-content d-flex gl-justify-content-end" data-qa-selector="review_bar_content" diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index a8c0b064595..4ee22918463 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -1,5 +1,5 @@ import { isEmpty } from 'lodash'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { scrollToElement } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants'; @@ -18,7 +18,9 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) => return res; }) .catch(() => { - flash(__('An error occurred adding a draft to the thread.')); + createFlash({ + message: __('An error occurred adding a draft to the thread.'), + }); }); export const createNewDraft = ({ commit }, { endpoint, data }) => @@ -30,7 +32,9 @@ export const createNewDraft = ({ commit }, { endpoint, data }) => return res; }) .catch(() => { - flash(__('An error occurred adding a new draft.')); + createFlash({ + message: __('An error occurred adding a new draft.'), + }); }); export const deleteDraft = ({ commit, getters }, draft) => @@ -39,7 +43,11 @@ export const deleteDraft = ({ commit, getters }, draft) => .then(() => { commit(types.DELETE_DRAFT, draft.id); }) - .catch(() => flash(__('An error occurred while deleting the comment'))); + .catch(() => + createFlash({ + message: __('An error occurred while deleting the comment'), + }), + ); export const fetchDrafts = ({ commit, getters, state, dispatch }) => service @@ -53,7 +61,11 @@ export const fetchDrafts = ({ commit, getters, state, dispatch }) => } }); }) - .catch(() => flash(__('An error occurred while fetching pending comments'))); + .catch(() => + createFlash({ + message: __('An error occurred while fetching pending comments'), + }), + ); export const publishSingleDraft = ({ commit, dispatch, getters }, draftId) => { commit(types.REQUEST_PUBLISH_DRAFT, draftId); @@ -111,7 +123,11 @@ export const updateDraft = ( .then((res) => res.data) .then((data) => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data)) .then(callback) - .catch(() => flash(__('An error occurred while updating the comment'))); + .catch(() => + createFlash({ + message: __('An error occurred while updating the comment'), + }), + ); }; export const scrollToDraft = ({ dispatch, rootGetters }, draft) => { diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 5fecadf2794..293fe9f4133 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { once, countBy } from 'lodash'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { darkModeEnabled } from '~/lib/utils/color_utils'; import { __, sprintf } from '~/locale'; @@ -78,7 +78,9 @@ function importMermaidModule() { mermaidModule = initMermaid(mermaid); }) .catch((err) => { - flash(sprintf(__("Can't load mermaid module: %{err}"), { err })); + createFlash({ + message: sprintf(__("Can't load mermaid module: %{err}"), { err }), + }); // eslint-disable-next-line no-console console.error(err); }); @@ -205,7 +207,9 @@ function renderMermaids($els) { }); }) .catch((err) => { - flash(sprintf(__('Encountered an error while rendering: %{err}'), { err })); + createFlash({ + message: sprintf(__('Encountered an error while rendering: %{err}'), { err }), + }); // eslint-disable-next-line no-console console.error(err); }); diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 5405819cfe0..a1911585f80 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -1,7 +1,7 @@ /* eslint-disable func-names */ import $ from 'jquery'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -79,7 +79,11 @@ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { }; success(data); }) - .catch(() => flash(__('An error occurred while fetching markdown preview'))); + .catch(() => + createFlash({ + message: __('An error occurred while fetching markdown preview'), + }), + ); }; MarkdownPreview.prototype.hideReferencedUsers = function ($form) { diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js index c9152db509a..af8e8a4cd3d 100644 --- a/app/assets/javascripts/blob/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -1,9 +1,11 @@ +import createFlash from '~/flash'; import { __ } from '~/locale'; -import { deprecatedCreateFlash as Flash } from '../flash'; import BalsamiqViewer from './balsamiq/balsamiq_viewer'; function onError() { - const flash = new Flash(__('Balsamiq file could not be loaded.')); + const flash = createFlash({ + message: __('Balsamiq file could not be loaded.'), + }); return flash; } diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index 60729c11002..1a74675100b 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -27,6 +27,11 @@ export default { default: false, required: false, }, + richViewer: { + type: String, + default: '', + required: false, + }, loading: { type: Boolean, default: true, @@ -71,6 +76,7 @@ export default { v-else ref="contentViewer" :content="content" + :rich-viewer="richViewer" :is-raw-content="isRawContent" :file-name="blob.name" :type="activeViewer.fileType" diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue index 73ccc3289b9..0e670bbd80a 100644 --- a/app/assets/javascripts/blob/components/blob_edit_content.vue +++ b/app/assets/javascripts/blob/components/blob_edit_content.vue @@ -1,6 +1,6 @@ <script> import { debounce } from 'lodash'; -import { initEditorLite } from '~/blob/utils'; +import { initSourceEditor } from '~/blob/utils'; import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants'; import eventHub from './eventhub'; @@ -36,7 +36,7 @@ export default { }, }, mounted() { - this.editor = initEditorLite({ + this.editor = initSourceEditor({ el: this.$refs.editor, blobPath: this.fileName, blobContent: this.value, diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue index 99fe3938046..cb441a7e491 100644 --- a/app/assets/javascripts/blob/components/blob_header_filepath.vue +++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue @@ -29,7 +29,7 @@ export default { <slot name="filepath-prepend"></slot> <template v-if="blob.path"> - <file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" /> + <file-icon :file-name="blob.path" :size="16" aria-hidden="true" css-classes="mr-2" /> <strong class="file-title-name mr-1 js-blob-header-filepath" data-qa-selector="file_title_content" diff --git a/app/assets/javascripts/blob/csv/csv_viewer.vue b/app/assets/javascripts/blob/csv/csv_viewer.vue new file mode 100644 index 00000000000..050f2785d9a --- /dev/null +++ b/app/assets/javascripts/blob/csv/csv_viewer.vue @@ -0,0 +1,55 @@ +<script> +import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import Papa from 'papaparse'; + +export default { + components: { + GlTable, + GlAlert, + GlLoadingIcon, + }, + props: { + csv: { + type: String, + required: true, + }, + }, + data() { + return { + items: [], + errorMessage: null, + loading: true, + }; + }, + mounted() { + const parsed = Papa.parse(this.csv, { skipEmptyLines: true }); + this.items = parsed.data; + + if (parsed.errors.length) { + this.errorMessage = parsed.errors.map((e) => e.message).join('. '); + } + + this.loading = false; + }, +}; +</script> + +<template> + <div class="container-fluid md gl-mt-3 gl-mb-3"> + <div v-if="loading" class="gl-text-center loading"> + <gl-loading-icon class="gl-mt-5" size="lg" /> + </div> + <div v-else> + <gl-alert v-if="errorMessage" variant="danger" :dismissible="false"> + {{ errorMessage }} + </gl-alert> + <gl-table + :empty-text="__('No CSV data to display.')" + :items="items" + :fields="$options.fields" + show-empty + thead-class="gl-display-none" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/blob/csv/index.js b/app/assets/javascripts/blob/csv/index.js new file mode 100644 index 00000000000..4cf6c169c68 --- /dev/null +++ b/app/assets/javascripts/blob/csv/index.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import CsvViewer from './csv_viewer.vue'; + +export default () => { + const el = document.getElementById('js-csv-viewer'); + + return new Vue({ + el, + render(createElement) { + return createElement(CsvViewer, { + props: { + csv: el.dataset.data, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/blob/csv_viewer.js b/app/assets/javascripts/blob/csv_viewer.js new file mode 100644 index 00000000000..64d3ba0b390 --- /dev/null +++ b/app/assets/javascripts/blob/csv_viewer.js @@ -0,0 +1,3 @@ +import renderCSV from './csv'; + +export default renderCSV; diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 59ab84bf208..136457c115d 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -2,11 +2,10 @@ import $ from 'jquery'; import Api from '~/api'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; -import { deprecatedCreateFlash as Flash } from '../flash'; - import BlobCiYamlSelector from './template_selectors/ci_yaml_selector'; import DockerfileSelector from './template_selectors/dockerfile_selector'; import GitignoreSelector from './template_selectors/gitignore_selector'; @@ -146,7 +145,7 @@ export default class FileTemplateMediator { text: __('Undo'), onClick: (e, toastObj) => { self.restoreFromCache(); - toastObj.goAway(0); + toastObj.hide(); }, }, }); @@ -155,7 +154,11 @@ export default class FileTemplateMediator { initPopover(suggestCommitChanges); } }) - .catch((err) => new Flash(`An error occurred while fetching the template: ${err}`)); + .catch((err) => + createFlash({ + message: __(`An error occurred while fetching the template: ${err}`), + }), + ); } displayMatchedTemplateSelector() { diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index e6dc463f764..cb251274b18 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -1,5 +1,5 @@ import { SwaggerUIBundle } from 'swagger-ui-dist'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; export default () => { @@ -13,7 +13,9 @@ export default () => { }); }) .catch((error) => { - flash(__('Something went wrong while initializing the OpenAPI viewer')); + createFlash({ + message: __('Something went wrong while initializing the OpenAPI viewer'), + }); throw error; }); }; diff --git a/app/assets/javascripts/blob/utils.js b/app/assets/javascripts/blob/utils.js index 8043c0bbc07..bbc061dd36e 100644 --- a/app/assets/javascripts/blob/utils.js +++ b/app/assets/javascripts/blob/utils.js @@ -1,6 +1,6 @@ -import Editor from '~/editor/editor_lite'; +import Editor from '~/editor/source_editor'; -export function initEditorLite({ el, ...args }) { +export function initSourceEditor({ el, ...args }) { const editor = new Editor({ scrollbar: { alwaysConsumeMouseWheel: false, diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 22c6b31143f..4d133659daa 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -1,14 +1,16 @@ import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import { REPO_BLOB_LOAD_VIEWER_START, REPO_BLOB_LOAD_VIEWER_FINISH, REPO_BLOB_LOAD_VIEWER, + REPO_BLOB_SWITCH_TO_VIEWER_START, + REPO_BLOB_SWITCH_VIEWER, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import { fixTitle } from '~/tooltips'; -import { deprecatedCreateFlash as Flash } from '../../flash'; import axios from '../../lib/utils/axios_utils'; import { handleLocationHash } from '../../lib/utils/common_utils'; import eventHub from '../../notes/event_hub'; @@ -21,6 +23,8 @@ const loadRichBlobViewer = (type) => { return import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer'); case 'openapi': return import(/* webpackChunkName: 'openapi_viewer' */ '../openapi_viewer'); + case 'csv': + return import(/* webpackChunkName: 'csv_viewer' */ '../csv_viewer'); case 'pdf': return import(/* webpackChunkName: 'pdf_viewer' */ '../pdf_viewer'); case 'sketch': @@ -38,13 +42,18 @@ export const handleBlobRichViewer = (viewer, type) => { loadRichBlobViewer(type) .then((module) => module?.default(viewer)) .catch((error) => { - Flash(__('Error loading file viewer.')); + createFlash({ + message: __('Error loading file viewer.'), + }); throw error; }); }; export default class BlobViewer { constructor() { + performanceMarkAndMeasure({ + mark: REPO_BLOB_LOAD_VIEWER_START, + }); const viewer = document.querySelector('.blob-viewer[data-type="rich"]'); const type = viewer?.dataset?.richType; BlobViewer.initAuxiliaryViewer(); @@ -137,7 +146,7 @@ export default class BlobViewer { switchToViewer(name) { performanceMarkAndMeasure({ - mark: REPO_BLOB_LOAD_VIEWER_START, + mark: REPO_BLOB_SWITCH_TO_VIEWER_START, }); const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`); if (this.activeViewer === newViewer) return; @@ -167,11 +176,15 @@ export default class BlobViewer { BlobViewer.loadViewer(newViewer) .then((viewer) => { $(viewer).renderGFM(); + window.requestIdleCallback(() => { + this.$fileHolder.trigger('highlight:line'); + handleLocationHash(); - this.$fileHolder.trigger('highlight:line'); - handleLocationHash(); + viewer.setAttribute('data-loaded', 'true'); + this.toggleCopyButtonState(); + eventHub.$emit('showBlobInteractionZones', viewer.dataset.path); + }); - this.toggleCopyButtonState(); performanceMarkAndMeasure({ mark: REPO_BLOB_LOAD_VIEWER_FINISH, measures: [ @@ -179,10 +192,18 @@ export default class BlobViewer { name: REPO_BLOB_LOAD_VIEWER, start: REPO_BLOB_LOAD_VIEWER_START, }, + { + name: REPO_BLOB_SWITCH_VIEWER, + start: REPO_BLOB_SWITCH_TO_VIEWER_START, + }, ], }); }) - .catch(() => new Flash(__('Error loading viewer'))); + .catch(() => + createFlash({ + message: __('Error loading viewer'), + }), + ); } static loadViewer(viewerParam) { @@ -197,9 +218,10 @@ export default class BlobViewer { return axios.get(url).then(({ data }) => { viewer.innerHTML = data.html; - viewer.setAttribute('data-loaded', 'true'); - eventHub.$emit('showBlobInteractionZones', viewer.dataset.path); + window.requestIdleCallback(() => { + viewer.removeAttribute('data-loading'); + }); return viewer; }); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 7c8d0d5ded0..7bfda46d71c 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import EditorLite from '~/editor/editor_lite'; -import { FileTemplateExtension } from '~/editor/extensions/editor_file_template_ext'; +import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; +import SourceEditor from '~/editor/source_editor'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; @@ -16,7 +16,7 @@ export default class EditBlob { this.configureMonacoEditor(); if (this.options.isMarkdown) { - import('~/editor/extensions/editor_markdown_ext') + import('~/editor/extensions/source_editor_markdown_ext') .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { this.editor.use(new MarkdownExtension()); addEditorMarkdownListeners(this.editor); @@ -40,7 +40,7 @@ export default class EditBlob { const fileContentEl = document.getElementById('file-content'); const form = document.querySelector('.js-edit-blob-form'); - const rootEditor = new EditorLite(); + const rootEditor = new SourceEditor(); this.editor = rootEditor.createInstance({ el: editorEl, diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index e14a770411e..46f97e09385 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -54,6 +54,7 @@ export function formatListIssues(listIssues) { const listIssue = { ...i, id, + fullId: i.id, labels: i.labels?.nodes || [], assignees: i.assignees?.nodes || [], }; @@ -106,8 +107,8 @@ export function formatIssueInput(issueInput, boardConfig) { const { labels, assigneeId, milestoneId } = boardConfig; return { - milestoneId: milestoneId ? fullMilestoneId(milestoneId) : null, ...issueInput, + milestoneId: milestoneId ? fullMilestoneId(milestoneId) : null, labelIds: [...labelIds, ...(labels?.map((l) => fullLabelId(l)) || [])], assigneeIds: [...assigneeIds, ...(assigneeId ? [fullUserId(assigneeId)] : [])], }; diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue index 0f92e714752..b81edb4dfe6 100644 --- a/app/assets/javascripts/boards/components/board_blocked_icon.vue +++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; -import { IssueType } from '~/graphql_shared/constants'; +import { TYPE_ISSUE } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { truncate } from '~/lib/utils/text_utility'; import { __, n__, s__, sprintf } from '~/locale'; @@ -13,7 +13,7 @@ export default { }, }, graphQLIdType: { - [issuableTypes.issue]: IssueType, + [issuableTypes.issue]: TYPE_ISSUE, }, referenceFormatter: { [issuableTypes.issue]: (r) => r.split('/')[1], @@ -163,7 +163,7 @@ export default { ><span data-testid="popover-title">{{ blockedLabel }}</span></template > <template v-if="loading"> - <gl-loading-icon /> + <gl-loading-icon size="sm" /> <p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p> </template> <template v-else> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 2f4e9044b9e..05b64ddc773 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -1,5 +1,12 @@ <script> -import { GlLabel, GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { + GlLabel, + GlTooltip, + GlTooltipDirective, + GlIcon, + GlLoadingIcon, + GlSprintf, +} from '@gitlab/ui'; import { sortBy } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; @@ -16,6 +23,7 @@ import IssueTimeEstimate from './issue_time_estimate.vue'; export default { components: { + GlTooltip, GlLabel, GlLoadingIcon, GlIcon, @@ -25,6 +33,7 @@ export default { IssueTimeEstimate, IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), BoardBlockedIcon, + GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, @@ -55,7 +64,7 @@ export default { }; }, computed: { - ...mapState(['isShowingLabels', 'issuableType']), + ...mapState(['isShowingLabels', 'issuableType', 'allowSubEpics']), ...mapGetters(['isEpicBoard']), cappedAssignees() { // e.g. maxRender is 4, @@ -99,6 +108,12 @@ export default { } return false; }, + shouldRenderEpicCountables() { + return this.isEpicBoard && this.item.hasIssues; + }, + shouldRenderEpicProgress() { + return this.totalWeight > 0; + }, showLabelFooter() { return this.isShowingLabels && this.item.labels.find(this.showLabel); }, @@ -115,6 +130,20 @@ export default { } return __('Blocked issue'); }, + totalEpicsCount() { + return this.item.descendantCounts.openedEpics + this.item.descendantCounts.closedEpics; + }, + totalIssuesCount() { + return this.item.descendantCounts.openedIssues + this.item.descendantCounts.closedIssues; + }, + totalWeight() { + return ( + this.item.descendantWeightSum.openedIssues + this.item.descendantWeightSum.closedIssues + ); + }, + totalProgress() { + return Math.round((this.item.descendantWeightSum.closedIssues / this.totalWeight) * 100); + }, }, methods: { ...mapActions(['performSearch', 'setError']), @@ -227,17 +256,93 @@ export default { {{ itemId }} </span> <span class="board-info-items gl-mt-3 gl-display-inline-block"> - <issue-due-date - v-if="item.dueDate" - :date="item.dueDate" - :closed="item.closed || Boolean(item.closedAt)" - /> - <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" /> - <issue-card-weight - v-if="validIssueWeight(item)" - :weight="item.weight" - @click="filterByWeight(item.weight)" - /> + <span v-if="shouldRenderEpicCountables" data-testid="epic-countables"> + <gl-tooltip :target="() => $refs.countBadge" data-testid="epic-countables-tooltip"> + <p v-if="allowSubEpics" class="gl-font-weight-bold gl-m-0"> + {{ __('Epics') }} • + <span class="gl-font-weight-normal"> + <gl-sprintf :message="__('%{openedEpics} open, %{closedEpics} closed')"> + <template #openedEpics>{{ item.descendantCounts.openedEpics }}</template> + <template #closedEpics>{{ item.descendantCounts.closedEpics }}</template> + </gl-sprintf> + </span> + </p> + <p class="gl-font-weight-bold gl-m-0"> + {{ __('Issues') }} • + <span class="gl-font-weight-normal"> + <gl-sprintf :message="__('%{openedIssues} open, %{closedIssues} closed')"> + <template #openedIssues>{{ item.descendantCounts.openedIssues }}</template> + <template #closedIssues>{{ item.descendantCounts.closedIssues }}</template> + </gl-sprintf> + </span> + </p> + <p class="gl-font-weight-bold gl-m-0"> + {{ __('Total weight') }} • + <span class="gl-font-weight-normal" data-testid="epic-countables-total-weight"> + {{ totalWeight }} + </span> + </p> + </gl-tooltip> + + <gl-tooltip + v-if="shouldRenderEpicProgress" + :target="() => $refs.progressBadge" + data-testid="epic-progress-tooltip" + > + <p class="gl-font-weight-bold gl-m-0"> + {{ __('Progress') }} • + <span class="gl-font-weight-normal" data-testid="epic-progress-tooltip-content"> + <gl-sprintf + :message="__('%{completedWeight} of %{totalWeight} weight completed')" + > + <template #completedWeight>{{ + item.descendantWeightSum.closedIssues + }}</template> + <template #totalWeight>{{ totalWeight }}</template> + </gl-sprintf> + </span> + </p> + </gl-tooltip> + + <span ref="countBadge" class="issue-count-badge board-card-info gl-mr-0 gl-pr-0"> + <span v-if="allowSubEpics" class="gl-mr-3"> + <gl-icon name="epic" /> + {{ totalEpicsCount }} + </span> + <span class="gl-mr-3" data-testid="epic-countables-counts-issues"> + <gl-icon name="issues" /> + {{ totalIssuesCount }} + </span> + <span class="gl-mr-3" data-testid="epic-countables-weight-issues"> + <gl-icon name="weight" /> + {{ totalWeight }} + </span> + </span> + + <span + v-if="shouldRenderEpicProgress" + ref="progressBadge" + class="issue-count-badge board-card-info gl-pl-0" + > + <span class="gl-mr-3" data-testid="epic-progress"> + <gl-icon name="progress" /> + {{ totalProgress }}% + </span> + </span> + </span> + <span v-if="!isEpicBoard"> + <issue-due-date + v-if="item.dueDate" + :date="item.dueDate" + :closed="item.closed || Boolean(item.closedAt)" + /> + <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" /> + <issue-card-weight + v-if="validIssueWeight(item)" + :weight="item.weight" + @click="filterByWeight(item.weight)" + /> + </span> </span> </div> <div class="board-card-assignee gl-display-flex"> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index cc7262f3a39..69abf886ad7 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -41,7 +41,7 @@ export default { watch: { filterParams: { handler() { - if (this.list.id) { + if (this.list.id && !this.list.collapsed) { this.fetchItemsForList({ listId: this.list.id }); } }, diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index b770ac06e89..53b071aaed1 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -12,10 +12,8 @@ import BoardColumnDeprecated from './board_column_deprecated.vue'; export default { components: { BoardAddNewColumn, - BoardColumn: - gon.features?.graphqlBoardLists || gon.features?.epicBoards - ? BoardColumn - : BoardColumnDeprecated, + BoardColumn, + BoardColumnDeprecated, BoardContentSidebar: () => import('~/boards/components/board_content_sidebar.vue'), EpicBoardContentSidebar: () => import('ee_component/boards/components/epic_board_content_sidebar.vue'), @@ -38,11 +36,14 @@ export default { computed: { ...mapState(['boardLists', 'error', 'addColumnForm']), ...mapGetters(['isSwimlanesOn', 'isEpicBoard']), + useNewBoardColumnComponent() { + return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard; + }, addColumnFormVisible() { return this.addColumnForm?.visible; }, boardListsToUse() { - return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard + return this.useNewBoardColumnComponent ? sortBy([...Object.values(this.boardLists)], 'position') : this.lists; }, @@ -65,6 +66,9 @@ export default { return this.canDragColumns ? options : {}; }, + boardColumnComponent() { + return this.useNewBoardColumnComponent ? BoardColumn : BoardColumnDeprecated; + }, }, methods: { ...mapActions(['moveList', 'unsetError']), @@ -102,7 +106,8 @@ export default { class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap" @end="handleDragOnEnd" > - <board-column + <component + :is="boardColumnComponent" v-for="(list, index) in boardListsToUse" :key="index" ref="board" @@ -125,14 +130,9 @@ export default { <board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" - class="boards-sidebar" data-testid="issue-boards-sidebar" /> - <epic-board-content-sidebar - v-else-if="isEpicBoard" - class="boards-sidebar" - data-testid="epic-boards-sidebar" - /> + <epic-board-content-sidebar v-else-if="isEpicBoard" data-testid="epic-boards-sidebar" /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 16a8a9d253f..e014b82d362 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -1,20 +1,20 @@ <script> import { GlDrawer } from '@gitlab/ui'; +import { MountingPortal } from 'portal-vue'; import { mapState, mapActions, mapGetters } from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; -import { contentTop } from '~/lib/utils/common_utils'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; +import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { - headerHeight: `${contentTop()}px`, components: { GlDrawer, BoardSidebarTitle, @@ -25,8 +25,10 @@ export default { BoardSidebarLabelsSelect, SidebarSubscriptionsWidget, SidebarDropdownWidget, - BoardSidebarWeightInput: () => - import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'), + SidebarTodoWidget, + MountingPortal, + SidebarWeightWidget: () => + import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), IterationSidebarDropdownWidget: () => import('ee_component/sidebar/components/iteration_sidebar_dropdown_widget.vue'), }, @@ -45,6 +47,7 @@ export default { default: false, }, }, + inheritAttrs: false, computed: { ...mapGetters([ 'isSidebarOpen', @@ -64,7 +67,12 @@ export default { }, }, methods: { - ...mapActions(['toggleBoardItem', 'setAssignees', 'setActiveItemConfidential']), + ...mapActions([ + 'toggleBoardItem', + 'setAssignees', + 'setActiveItemConfidential', + 'setActiveItemWeight', + ]), handleClose() { this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); }, @@ -73,87 +81,105 @@ export default { </script> <template> - <gl-drawer - v-if="showSidebar" - :open="isSidebarOpen" - :header-height="$options.headerHeight" - @close="handleClose" - > - <template #header>{{ __('Issue details') }}</template> - <template #default> - <board-sidebar-title /> - <sidebar-assignees-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :initial-assignees="activeBoardItem.assignees" - :allow-multiple-assignees="multipleAssigneesFeatureAvailable" - @assignees-updated="setAssignees" - /> - <sidebar-dropdown-widget - v-if="epicFeatureAvailable" - :iid="activeBoardItem.iid" - issuable-attribute="epic" - :workspace-path="projectPathForActiveIssue" - :attr-workspace-path="groupPathForActiveIssue" - :issuable-type="issuableType" - data-testid="sidebar-epic" - /> - <div> + <mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append> + <gl-drawer + v-if="showSidebar" + v-bind="$attrs" + :open="isSidebarOpen" + class="boards-sidebar gl-absolute" + @close="handleClose" + > + <template #title> + <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">{{ __('Issue details') }}</h2> + </template> + <template #header> + <sidebar-todo-widget + class="gl-mt-3" + :issuable-id="activeBoardItem.fullId" + :issuable-iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + /> + </template> + <template #default> + <board-sidebar-title /> + <sidebar-assignees-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :initial-assignees="activeBoardItem.assignees" + :allow-multiple-assignees="multipleAssigneesFeatureAvailable" + @assignees-updated="setAssignees" + /> <sidebar-dropdown-widget + v-if="epicFeatureAvailable" :iid="activeBoardItem.iid" - issuable-attribute="milestone" + issuable-attribute="epic" :workspace-path="projectPathForActiveIssue" - :attr-workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" :issuable-type="issuableType" - data-testid="sidebar-milestones" + data-testid="sidebar-epic" /> - <template v-if="!glFeatures.iterationCadences"> + <div> <sidebar-dropdown-widget - v-if="iterationFeatureAvailable" :iid="activeBoardItem.iid" - issuable-attribute="iteration" + issuable-attribute="milestone" :workspace-path="projectPathForActiveIssue" - :attr-workspace-path="groupPathForActiveIssue" + :attr-workspace-path="projectPathForActiveIssue" :issuable-type="issuableType" - class="gl-mt-5" - data-testid="iteration-edit" - data-qa-selector="iteration_container" + data-testid="sidebar-milestones" /> - </template> - <template v-else> - <iteration-sidebar-dropdown-widget - v-if="iterationFeatureAvailable" - :iid="activeBoardItem.iid" - :workspace-path="projectPathForActiveIssue" - :attr-workspace-path="groupPathForActiveIssue" - :issuable-type="issuableType" - class="gl-mt-5" - data-testid="iteration-edit" - data-qa-selector="iteration_container" - /> - </template> - </div> - <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> - <sidebar-date-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :issuable-type="issuableType" - data-testid="sidebar-due-date" - /> - <board-sidebar-labels-select class="labels" /> - <board-sidebar-weight-input v-if="weightFeatureAvailable" class="weight" /> - <sidebar-confidentiality-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :issuable-type="issuableType" - @confidentialityUpdated="setActiveItemConfidential($event)" - /> - <sidebar-subscriptions-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :issuable-type="issuableType" - data-testid="sidebar-notifications" - /> - </template> - </gl-drawer> + <template v-if="!glFeatures.iterationCadences"> + <sidebar-dropdown-widget + v-if="iterationFeatureAvailable" + :iid="activeBoardItem.iid" + issuable-attribute="iteration" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + data-testid="iteration-edit" + /> + </template> + <template v-else> + <iteration-sidebar-dropdown-widget + v-if="iterationFeatureAvailable" + :iid="activeBoardItem.iid" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + data-testid="iteration-edit" + /> + </template> + </div> + <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> + <sidebar-date-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + data-testid="sidebar-due-date" + /> + <board-sidebar-labels-select class="labels" /> + <sidebar-weight-widget + v-if="weightFeatureAvailable" + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + @weightUpdated="setActiveItemWeight($event)" + /> + <sidebar-confidentiality-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + @confidentialityUpdated="setActiveItemConfidential($event)" + /> + <sidebar-subscriptions-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + data-testid="sidebar-notifications" + /> + </template> + </gl-drawer> + </mounting-portal> </template> diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 13388f02f1f..cfd6b21fa66 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -27,7 +27,7 @@ export default { }, computed: { urlParams() { - const { authorUsername, labelName, search } = this.filterParams; + const { authorUsername, labelName, assigneeUsername, search } = this.filterParams; let notParams = {}; if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { @@ -35,6 +35,7 @@ export default { { 'not[label_name][]': this.filterParams.not.labelName, 'not[author_username]': this.filterParams.not.authorUsername, + 'not[assignee_username]': this.filterParams.not.assigneeUsername, }, undefined, ); @@ -44,6 +45,7 @@ export default { ...notParams, author_username: authorUsername, 'label_name[]': labelName, + assignee_username: assigneeUsername, search, }; }, @@ -62,7 +64,7 @@ export default { this.performSearch(); }, getFilteredSearchValue() { - const { authorUsername, labelName, search } = this.filterParams; + const { authorUsername, labelName, assigneeUsername, search } = this.filterParams; const filteredSearchValue = []; if (authorUsername) { @@ -72,6 +74,13 @@ export default { }); } + if (assigneeUsername) { + filteredSearchValue.push({ + type: 'assignee_username', + value: { data: assigneeUsername, operator: '=' }, + }); + } + if (labelName?.length) { filteredSearchValue.push( ...labelName.map((label) => ({ @@ -88,6 +97,13 @@ export default { }); } + if (this.filterParams['not[assigneeUsername]']) { + filteredSearchValue.push({ + type: 'assignee_username', + value: { data: this.filterParams['not[assigneeUsername]'], operator: '!=' }, + }); + } + if (this.filterParams['not[labelName]']) { filteredSearchValue.push( ...this.filterParams['not[labelName]'].map((label) => ({ @@ -121,6 +137,9 @@ export default { case 'author_username': filterParams.authorUsername = filter.value.data; break; + case 'assignee_username': + filterParams.assigneeUsername = filter.value.data; + break; case 'label_name': labels.push(filter.value.data); break; diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index aa75a0d68f5..386ed6bd0a1 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -2,9 +2,9 @@ import { GlModal, GlAlert } from '@gitlab/ui'; import { mapGetters, mapActions, mapState } from 'vuex'; import ListLabel from '~/boards/models/label'; +import { TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { getParameterByName } from '~/lib/utils/common_utils'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { getParameterByName, visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import { fullLabelId, fullBoardId } from '../boards_util'; import { formType } from '../constants'; @@ -188,21 +188,19 @@ export default { }; }, issueBoardScopeMutationVariables() { - /* eslint-disable @gitlab/require-i18n-strings */ return { weight: this.board.weight, assigneeId: this.board.assignee?.id - ? convertToGraphQLId('User', this.board.assignee.id) + ? convertToGraphQLId(TYPE_USER, this.board.assignee.id) : null, milestoneId: this.board.milestone?.id || this.board.milestone?.id === 0 - ? convertToGraphQLId('Milestone', this.board.milestone.id) + ? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id) : null, iterationId: this.board.iteration_id - ? convertToGraphQLId('Iteration', this.board.iteration_id) + ? convertToGraphQLId(TYPE_ITERATION, this.board.iteration_id) : null, }; - /* eslint-enable @gitlab/require-i18n-strings */ }, boardScopeMutationVariables() { return { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 81740b5cd17..8dca6be853f 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,6 +6,7 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt import { sprintf, __ } from '~/locale'; import defaultSortableConfig from '~/sortable/sortable_config'; import Tracking from '~/tracking'; +import { toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; import BoardNewIssue from './board_new_issue.vue'; @@ -21,6 +22,7 @@ export default { components: { BoardCard, BoardNewIssue, + BoardNewEpic: () => import('ee_component/boards/components/board_new_epic.vue'), GlLoadingIcon, GlIntersectionObserver, }, @@ -49,6 +51,7 @@ export default { scrollOffset: 250, showCount: false, showIssueForm: false, + showEpicForm: false, }; }, computed: { @@ -64,6 +67,9 @@ export default { issuableType: this.isEpicBoard ? 'epics' : 'issues', }); }, + toggleFormEventPrefix() { + return this.isEpicBoard ? toggleFormEventPrefix.epic : toggleFormEventPrefix.issue; + }, boardItemsSizeExceedsMax() { return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount; }, @@ -76,6 +82,12 @@ export default { loadingMore() { return this.listsFlags[this.list.id]?.isLoadingMore; }, + epicCreateFormVisible() { + return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm; + }, + issueCreateFormVisible() { + return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm; + }, listRef() { // When list is draggable, the reference to the list needs to be accessed differently return this.canAdminList ? this.$refs.list.$el : this.$refs.list; @@ -116,9 +128,10 @@ export default { 'list.id': { handler(id, oldVal) { if (id) { - eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$on(`${this.toggleFormEventPrefix}${this.list.id}`, this.toggleForm); eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); - eventHub.$off(`toggle-issue-form-${oldVal}`, this.toggleForm); + + eventHub.$off(`${this.toggleFormEventPrefix}${oldVal}`, this.toggleForm); eventHub.$off(`scroll-board-list-${oldVal}`, this.scrollToTop); } }, @@ -126,7 +139,7 @@ export default { }, }, beforeDestroy() { - eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$off(`${this.toggleFormEventPrefix}${this.list.id}`, this.toggleForm); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, methods: { @@ -147,7 +160,11 @@ export default { this.fetchItemsForList({ listId: this.list.id, fetchNext: true }); }, toggleForm() { - this.showIssueForm = !this.showIssueForm; + if (this.isEpicBoard) { + this.showEpicForm = !this.showEpicForm; + } else { + this.showIssueForm = !this.showIssueForm; + } }, onReachingListBottom() { if (!this.loadingMore && this.hasNextPage) { @@ -225,9 +242,10 @@ export default { :aria-label="$options.i18n.loading" data-testid="board_list_loading" > - <gl-loading-icon /> + <gl-loading-icon size="sm" /> </div> - <board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" /> + <board-new-issue v-if="issueCreateFormVisible" :list="list" /> + <board-new-epic v-if="epicCreateFormVisible" :list="list" /> <component :is="treeRootWrapper" v-show="!loading" @@ -255,6 +273,7 @@ export default { <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> <gl-loading-icon v-if="loadingMore" + size="sm" :label="$options.i18n.loadingMoreboardItems" data-testid="count-loading-icon" /> diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue index 9b3e7e1547d..fabaf7a85f5 100644 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -429,7 +429,7 @@ export default { data-qa-selector="board_list_cards_area" > <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')"> - <gl-loading-icon /> + <gl-loading-icon size="sm" /> </div> <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> <ul @@ -450,7 +450,7 @@ export default { :disabled="disabled" /> <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> - <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> + <gl-loading-icon v-show="list.loadingMore" size="sm" label="Loading more issues" /> <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> <span v-else>{{ paginatedIssueText }}</span> </li> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index bf8396f52a6..8d5f0f7eb89 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -16,13 +16,14 @@ import { n__, s__, __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; import AccessorUtilities from '../../lib/utils/accessor'; -import { inactiveId, LIST, ListType } from '../constants'; +import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; import ItemCount from './item_count.vue'; export default { i18n: { newIssue: __('New issue'), + newEpic: s__('Boards|New epic'), listSettings: __('List settings'), expand: s__('Boards|Expand'), collapse: s__('Boards|Collapse'), @@ -72,7 +73,7 @@ export default { }, computed: { ...mapState(['activeId']), - ...mapGetters(['isEpicBoard']), + ...mapGetters(['isEpicBoard', 'isSwimlanesOn']), isLoggedIn() { return Boolean(this.currentUserId); }, @@ -102,7 +103,7 @@ export default { }, showListHeaderActions() { if (this.isLoggedIn) { - return this.isNewIssueShown || this.isSettingsShown; + return this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown; } return false; }, @@ -124,6 +125,9 @@ export default { isNewIssueShown() { return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard; }, + isNewEpicShown() { + return this.isEpicBoard && this.listType !== ListType.closed; + }, isSettingsShown() { return ( this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed @@ -165,7 +169,17 @@ export default { }, showNewIssueForm() { - eventHub.$emit(`toggle-issue-form-${this.list.id}`); + if (this.isSwimlanesOn) { + eventHub.$emit('open-unassigned-lane'); + this.$nextTick(() => { + eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); + }); + } else { + eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); + } + }, + showNewEpicForm() { + eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`); }, toggleExpanded() { const collapsed = !this.list.collapsed; @@ -342,7 +356,7 @@ export default { <!-- EE end --> <div - class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500" + class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-gray-500" data-testid="issue-count-badge" :class="{ 'gl-display-none!': list.collapsed && isSwimlanesHeader, @@ -380,6 +394,17 @@ export default { /> <gl-button + v-if="isNewEpicShown" + v-show="!list.collapsed" + v-gl-tooltip.hover + :aria-label="$options.i18n.newEpic" + :title="$options.i18n.newEpic" + class="no-drag" + icon="plus" + @click="showNewEpicForm" + /> + + <gl-button v-if="isSettingsShown" ref="settingsBtn" v-gl-tooltip.hover diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index a63b49f9508..caeecb25227 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -4,13 +4,13 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue'; import { __ } from '~/locale'; +import { toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; import ProjectSelect from './project_select.vue'; export default { name: 'BoardNewIssue', i18n: { - submit: __('Create issue'), cancel: __('Cancel'), }, components: { @@ -32,7 +32,15 @@ export default { }, computed: { ...mapState(['selectedProject']), - ...mapGetters(['isGroupBoard']), + ...mapGetters(['isGroupBoard', 'isEpicBoard']), + /** + * We've extended this component in EE where + * submitButtonTitle returns a different string + * hence this is kept as a computed prop. + */ + submitButtonTitle() { + return __('Create issue'); + }, disabled() { if (this.isGroupBoard) { return this.title === '' || !this.selectedProject.name; @@ -50,9 +58,7 @@ export default { }, methods: { ...mapActions(['addListNewIssue']), - submit(e) { - e.preventDefault(); - + submit() { const { title } = this; const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; @@ -76,7 +82,7 @@ export default { }, reset() { this.title = ''; - eventHub.$emit(`toggle-issue-form-${this.list.id}`); + eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); }, }, }; @@ -85,7 +91,7 @@ export default { <template> <div class="board-new-issue-form"> <div class="board-card position-relative p-3 rounded"> - <form ref="submitForm" @submit="submit"> + <form ref="submitForm" @submit.prevent="submit"> <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label> <input :id="inputFieldId" @@ -96,7 +102,7 @@ export default { name="issue_title" autocomplete="off" /> - <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> + <project-select v-if="isGroupBoard && !isEpicBoard" :group-id="groupId" :list="list" /> <div class="clearfix gl-mt-3"> <gl-button ref="submitButton" @@ -106,7 +112,7 @@ export default { category="primary" type="submit" > - {{ $options.i18n.submit }} + {{ submitButtonTitle }} </gl-button> <gl-button ref="cancelButton" diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 75975c77df5..c089a6a39af 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlDrawer, GlLabel } from '@gitlab/ui'; +import { MountingPortal } from 'portal-vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import { LIST, ListType, ListTypeTitles } from '~/boards/constants'; import boardsStore from '~/boards/stores/boards_store'; @@ -9,14 +10,13 @@ import eventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -// NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options. export default { - headerHeight: process.env.NODE_ENV === 'development' ? '75px' : '40px', listSettingsText: __('List settings'), components: { GlButton, GlDrawer, GlLabel, + MountingPortal, BoardSettingsSidebarWipLimit: () => import('ee_component/boards/components/board_settings_wip_limit.vue'), BoardSettingsListTypes: () => @@ -24,6 +24,7 @@ export default { }, mixins: [glFeatureFlagMixin(), Tracking.mixin()], inject: ['canAdminList'], + inheritAttrs: false, data() { return { ListType, @@ -86,43 +87,45 @@ export default { </script> <template> - <gl-drawer - v-if="showSidebar" - class="js-board-settings-sidebar" - :open="isSidebarOpen" - :header-height="$options.headerHeight" - @close="unsetActiveId" - > - <template #header>{{ $options.listSettingsText }}</template> - <template v-if="isSidebarOpen"> - <div v-if="boardListType === ListType.label"> - <label class="js-list-label gl-display-block">{{ listTypeTitle }}</label> - <gl-label - :title="activeListLabel.title" - :background-color="activeListLabel.color" - :scoped="showScopedLabels(activeListLabel)" - /> - </div> + <mounting-portal mount-to="#js-right-sidebar-portal" name="board-settings-sidebar" append> + <gl-drawer + v-if="showSidebar" + v-bind="$attrs" + class="js-board-settings-sidebar gl-absolute" + :open="isSidebarOpen" + @close="unsetActiveId" + > + <template #title>{{ $options.listSettingsText }}</template> + <template v-if="isSidebarOpen"> + <div v-if="boardListType === ListType.label"> + <label class="js-list-label gl-display-block">{{ listTypeTitle }}</label> + <gl-label + :title="activeListLabel.title" + :background-color="activeListLabel.color" + :scoped="showScopedLabels(activeListLabel)" + /> + </div> - <board-settings-list-types - v-else - :active-list="activeList" - :board-list-type="boardListType" - /> - <board-settings-sidebar-wip-limit - v-if="isWipLimitsOn" - :max-issue-count="activeList.maxIssueCount" - /> - <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-mt-4"> - <gl-button - variant="danger" - category="secondary" - icon="remove" - data-testid="remove-list" - @click.stop="deleteBoard" - >{{ __('Remove list') }} - </gl-button> - </div> - </template> - </gl-drawer> + <board-settings-list-types + v-else + :active-list="activeList" + :board-list-type="boardListType" + /> + <board-settings-sidebar-wip-limit + v-if="isWipLimitsOn" + :max-issue-count="activeList.maxIssueCount" + /> + <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-mt-4"> + <gl-button + variant="danger" + category="secondary" + icon="remove" + data-testid="remove-list" + @click.stop="deleteBoard" + >{{ __('Remove list') }} + </gl-button> + </div> + </template> + </gl-drawer> + </mounting-portal> </template> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 55bc91cbcff..21a34182369 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -105,7 +105,7 @@ export default Vue.extend({ closeSidebar() { this.detail.issue = {}; }, - setAssignees(assignees) { + setAssignees({ assignees }) { boardsStore.detail.issue.setAssignees(assignees); }, showScopedLabels(label) { diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 5124467136e..98027917221 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -327,7 +327,7 @@ export default { :class="scrollFadeClass" ></div> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <div v-if="canAdminBoard"> <gl-dropdown-divider /> diff --git a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue index 85c7b27336b..c1536dff2c6 100644 --- a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue +++ b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue @@ -316,7 +316,7 @@ export default { :class="scrollFadeClass" ></div> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <div v-if="canAdminBoard"> <gl-dropdown-divider /> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue new file mode 100644 index 00000000000..d8dac17d326 --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -0,0 +1,102 @@ +<script> +import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; +import issueBoardFilters from '~/boards/issue_board_filters'; +import { TYPE_USER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { __ } from '~/locale'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; + +export default { + i18n: { + search: __('Search'), + label: __('Label'), + author: __('Author'), + assignee: __('Assignee'), + is: __('is'), + isNot: __('is not'), + }, + components: { BoardFilteredSearch }, + props: { + fullPath: { + type: String, + required: true, + }, + boardType: { + type: String, + required: true, + }, + }, + computed: { + tokens() { + const { label, is, isNot, author, assignee } = this.$options.i18n; + const { fetchAuthors, fetchLabels } = issueBoardFilters( + this.$apollo, + this.fullPath, + this.boardType, + ); + + return [ + { + icon: 'labels', + title: label, + type: 'label_name', + operators: [ + { value: '=', description: is }, + { value: '!=', description: isNot }, + ], + token: LabelToken, + unique: false, + symbol: '~', + fetchLabels, + }, + { + icon: 'pencil', + title: author, + type: 'author_username', + operators: [ + { value: '=', description: is }, + { value: '!=', description: isNot }, + ], + symbol: '@', + token: AuthorToken, + unique: true, + fetchAuthors, + preloadedAuthors: this.preloadedAuthors(), + }, + { + icon: 'user', + title: assignee, + type: 'assignee_username', + operators: [ + { value: '=', description: is }, + { value: '!=', description: isNot }, + ], + token: AuthorToken, + unique: true, + fetchAuthors, + preloadedAuthors: this.preloadedAuthors(), + }, + ]; + }, + }, + methods: { + preloadedAuthors() { + return gon?.current_user_id + ? [ + { + id: convertToGraphQLId(TYPE_USER, gon.current_user_id), + name: gon.current_user_fullname, + username: gon.current_username, + avatarUrl: gon.current_user_avatar_url, + }, + ] + : []; + }, + }, +}; +</script> + +<template> + <board-filtered-search data-testid="issue-board-filtered-search" :tokens="tokens" /> +</template> diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 2fd16f06455..6eb1dbfb46a 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import store from '~/boards/stores'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -53,7 +53,9 @@ export default function initNewListDropdown() { data(term, callback) { const reqFailed = () => { $dropdownToggle.data('bs.dropdown').hide(); - flash(__('Error fetching labels.')); + createFlash({ + message: __('Error fetching labels.'), + }); }; if (store.getters.shouldUseGraphQL) { diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 77b6af77652..1412411c275 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -126,7 +126,7 @@ export default { v-show="groupProjectsFlags.isLoading" data-testid="dropdown-text-loading-icon" > - <gl-loading-icon class="gl-mx-auto" /> + <gl-loading-icon class="gl-mx-auto" size="sm" /> </gl-dropdown-text> <gl-dropdown-text v-if="isFetchResultEmpty && !groupProjectsFlags.isLoading" diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue index afe161d9c54..fc95ba0461d 100644 --- a/app/assets/javascripts/boards/components/project_select_deprecated.vue +++ b/app/assets/javascripts/boards/components/project_select_deprecated.vue @@ -136,7 +136,7 @@ export default { {{ project.namespacedName }} </gl-dropdown-item> <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon"> - <gl-loading-icon class="gl-mx-auto" /> + <gl-loading-icon class="gl-mx-auto" size="sm" /> </gl-dropdown-text> <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message"> <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue index 352a25ef6d9..84802650dad 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -93,7 +93,7 @@ export default { <slot name="title"> <span data-testid="title">{{ title }}</span> </slot> - <gl-loading-icon v-if="loading" inline class="gl-ml-2" /> + <gl-loading-icon v-if="loading" size="sm" inline class="gl-ml-2" /> </span> <gl-button v-if="canUpdate" diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 80a8fc99895..21ef70582a4 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -45,6 +45,11 @@ export const formType = { edit: 'edit', }; +export const toggleFormEventPrefix = { + epic: 'toggle-epic-form-', + issue: 'toggle-issue-form-', +}; + export const inactiveId = 0; export const ISSUABLE = 'issuable'; diff --git a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql index 3c5f4b3e3bd..70eb1dfbf7e 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql @@ -1,6 +1,7 @@ mutation issueSetLabels($input: UpdateIssueInput!) { updateIssue(input: $input) { issue { + id labels { nodes { id diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index fb347ce852d..de7c8a3bd6b 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,4 +1,5 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import PortalVue from 'portal-vue'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { mapActions, mapGetters } from 'vuex'; @@ -24,6 +25,7 @@ import '~/boards/filters/due_date_filters'; import { issuableTypes } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; import FilteredSearchBoards from '~/boards/filtered_search_boards'; +import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards'; import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import toggleFocusMode from '~/boards/toggle_focus'; @@ -41,6 +43,7 @@ import boardConfigToggle from './config_toggle'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; Vue.use(VueApollo); +Vue.use(PortalVue); const fragmentMatcher = new IntrospectionFragmentMatcher({ introspectionQueryResultData, @@ -76,6 +79,10 @@ export default () => { issueBoardsApp.$destroy(true); } + if (gon?.features?.issueBoardsFilteredSearch) { + initBoardsFilteredSearch(apolloProvider); + } + if (!gon?.features?.graphqlBoardLists) { boardsStore.create(); boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours); @@ -182,9 +189,14 @@ export default () => { eventHub.$off('initialBoardLoad', this.initialBoardLoad); }, mounted() { - this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); - - this.filterManager.setup(); + if (!gon?.features?.issueBoardsFilteredSearch) { + this.filterManager = new FilteredSearchBoards( + boardsStore.filter, + true, + boardsStore.cantEdit, + ); + this.filterManager.setup(); + } this.performSearch(); @@ -304,9 +316,11 @@ export default () => { // eslint-disable-next-line no-new, @gitlab/no-runtime-template-compiler new Vue({ el: document.getElementById('js-add-list'), - data: { - filters: boardsStore.state.filters, - ...getMilestoneTitle($boardApp), + data() { + return { + filters: boardsStore.state.filters, + ...getMilestoneTitle($boardApp), + }; }, mounted() { initNewListDropdown(); diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js new file mode 100644 index 00000000000..699d7e12de4 --- /dev/null +++ b/app/assets/javascripts/boards/issue_board_filters.js @@ -0,0 +1,47 @@ +import groupBoardMembers from '~/boards/graphql/group_board_members.query.graphql'; +import projectBoardMembers from '~/boards/graphql/project_board_members.query.graphql'; +import { BoardType } from './constants'; +import boardLabels from './graphql/board_labels.query.graphql'; + +export default function issueBoardFilters(apollo, fullPath, boardType) { + const isGroupBoard = boardType === BoardType.group; + const isProjectBoard = boardType === BoardType.project; + const transformLabels = ({ data }) => { + return isGroupBoard ? data.group?.labels.nodes || [] : data.project?.labels.nodes || []; + }; + + const boardAssigneesQuery = () => { + return isGroupBoard ? groupBoardMembers : projectBoardMembers; + }; + + const fetchAuthors = (authorsSearchTerm) => { + return apollo + .query({ + query: boardAssigneesQuery(), + variables: { + fullPath, + search: authorsSearchTerm, + }, + }) + .then(({ data }) => data.workspace?.assignees.nodes.map(({ user }) => user)); + }; + + const fetchLabels = (labelSearchTerm) => { + return apollo + .query({ + query: boardLabels, + variables: { + fullPath, + searchTerm: labelSearchTerm, + isGroup: isGroupBoard, + isProject: isProjectBoard, + }, + }) + .then(transformLabels); + }; + + return { + fetchLabels, + fetchAuthors, + }; +} diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index a95d749d71c..1bb0ee5b7e3 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -1,6 +1,6 @@ /* global DocumentTouch */ -import sortableConfig from 'ee_else_ce/sortable/sortable_config'; +import sortableConfig from '~/sortable/sortable_config'; export function sortableStart() { document.body.classList.add('is-dragging'); diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 6c6e2522d92..ab24532d87f 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,5 +1,5 @@ /* eslint-disable class-methods-use-this */ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import boardsStore from '../stores/boards_store'; import ListAssignee from './assignee'; @@ -127,7 +127,11 @@ class List { moveBeforeId, moveAfterId, }) - .catch(() => flash(__('Something went wrong while moving issues.'))); + .catch(() => + createFlash({ + message: __('Something went wrong while moving issues.'), + }), + ); } updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) { @@ -145,7 +149,11 @@ class List { moveBeforeId, moveAfterId, }) - .catch(() => flash(__('Something went wrong while moving issues.'))); + .catch(() => + createFlash({ + message: __('Something went wrong while moving issues.'), + }), + ); } findIssue(id) { diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js new file mode 100644 index 00000000000..7732091ef34 --- /dev/null +++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import IssueBoardFilteredSearch from '~/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) => { + 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, + provide: { + initialFilterParams, + }, + 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/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index d4893f9eca7..0f1b72146c9 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -18,7 +18,9 @@ import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; -import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +// eslint-disable-next-line import/no-deprecated +import { urlParamsToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { formatBoardLists, @@ -74,6 +76,7 @@ export default { performSearch({ dispatch }) { dispatch( 'setFilters', + // eslint-disable-next-line import/no-deprecated convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)), ); @@ -170,8 +173,9 @@ export default { addList: ({ commit, dispatch, getters }, list) => { commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list)); + dispatch('fetchItemsForList', { - listId: getters.getListByTitle(ListTypeTitles.backlog).id, + listId: getters.getListByTitle(ListTypeTitles.backlog)?.id, }); }, @@ -237,7 +241,7 @@ export default { }, updateList: ( - { commit, state: { issuableType } }, + { commit, state: { issuableType, boardItemsByListId = {} }, dispatch }, { listId, position, collapsed, backupList }, ) => { gqlClient @@ -252,6 +256,12 @@ export default { .then(({ data }) => { if (data?.updateBoardList?.errors.length) { commit(types.UPDATE_LIST_FAILURE, backupList); + return; + } + + // Only fetch when board items havent been fetched on a collapsed list + if (!boardItemsByListId[listId]) { + dispatch('fetchItemsForList', { listId }); } }) .catch(() => { @@ -285,7 +295,7 @@ export default { commit(types.REMOVE_LIST_FAILURE, listsBackup); } else { dispatch('fetchItemsForList', { - listId: getters.getListByTitle(ListTypeTitles.backlog).id, + listId: getters.getListByTitle(ListTypeTitles.backlog)?.id, }); } }, @@ -296,6 +306,8 @@ export default { }, fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => { + if (!listId) return null; + if (!fetchNext) { commit(types.RESET_ITEMS_FOR_LIST, listId); } @@ -469,11 +481,11 @@ export default { } }, - setAssignees: ({ commit, getters }, assigneeUsernames) => { + setAssignees: ({ commit }, { id, assignees }) => { commit('UPDATE_BOARD_ITEM_BY_ID', { - itemId: getters.activeBoardItem.id, + itemId: id, prop: 'assignees', - value: assigneeUsernames, + value: assignees, }); }, @@ -701,4 +713,7 @@ export default { unsetError: ({ commit }) => { commit(types.SET_ERROR, undefined); }, + + // EE action needs CE empty equivalent + setActiveItemWeight: () => {}, }; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 092f81ad279..49c40c7776a 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -7,13 +7,9 @@ import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; -import { - urlParamsToObject, - getUrlParamsArray, - parseBoolean, - convertObjectPropsToCamelCase, -} from '~/lib/utils/common_utils'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +// eslint-disable-next-line import/no-deprecated +import { mergeUrlParams, urlParamsToObject, getUrlParamsArray } from '~/lib/utils/url_utility'; import { ListType, flashAnimationDuration } from '../constants'; import eventHub from '../eventhub'; import ListAssignee from '../models/assignee'; @@ -601,6 +597,7 @@ const boardsStore = { getListIssues(list, emptyIssues = true) { const data = { + // eslint-disable-next-line import/no-deprecated ...urlParamsToObject(this.filter.path), page: list.page, }; diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index b61ecc5ccb6..140c9ef7ac4 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -16,7 +16,7 @@ export default { }, activeBoardItem: (state) => { - return state.boardItems[state.activeId] || {}; + return state.boardItems[state.activeId] || { iid: '', id: '', fullId: '' }; }, groupPathForActiveIssue: (_, getters) => { diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 6cd0a62657e..a32a100fa11 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -35,13 +35,23 @@ export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId export default { [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { - const { boardType, disabled, boardId, fullBoardId, fullPath, boardConfig, issuableType } = data; + const { + allowSubEpics, + boardConfig, + boardId, + boardType, + disabled, + fullBoardId, + fullPath, + issuableType, + } = data; + state.allowSubEpics = allowSubEpics; + state.boardConfig = boardConfig; state.boardId = boardId; - state.fullBoardId = fullBoardId; - state.fullPath = fullPath; state.boardType = boardType; state.disabled = disabled; - state.boardConfig = boardConfig; + state.fullBoardId = fullBoardId; + state.fullPath = fullPath; state.issuableType = issuableType; }, diff --git a/app/assets/javascripts/branches/components/delete_branch_button.vue b/app/assets/javascripts/branches/components/delete_branch_button.vue index 5a5f49e25e7..6a6d4d48c52 100644 --- a/app/assets/javascripts/branches/components/delete_branch_button.vue +++ b/app/assets/javascripts/branches/components/delete_branch_button.vue @@ -47,12 +47,6 @@ export default { }, }, computed: { - variant() { - if (this.disabled) { - return 'default'; - } - return 'danger'; - }, title() { if (this.isProtectedBranch && this.disabled) { return s__('Branches|Only a project maintainer or owner can delete a protected branch'); @@ -83,7 +77,7 @@ export default { class="js-delete-branch-button" data-qa-selector="delete_branch_button" :disabled="disabled" - :variant="variant" + variant="default" :title="title" :aria-label="title" @click="openModal" diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js index b88c056b00f..31cf9a18077 100644 --- a/app/assets/javascripts/branches/divergence_graph.js +++ b/app/assets/javascripts/branches/divergence_graph.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import createFlash from '../flash'; +import createFlash from '~/flash'; import axios from '../lib/utils/axios_utils'; import { __ } from '../locale'; import DivergenceGraph from './components/divergence_graph.vue'; diff --git a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js index c9eac44eb28..fdab188f6be 100644 --- a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js +++ b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js @@ -1,4 +1,33 @@ -const supportedMethods = ['patch', 'post', 'put']; +const SUPPORTED_METHODS = ['patch', 'post', 'put']; + +function needsCaptchaResponse(err) { + return ( + SUPPORTED_METHODS.includes(err?.config?.method) && err?.response?.data?.needs_captcha_response + ); +} + +const showCaptchaModalAndResubmit = async (axios, data, errConfig) => { + // NOTE: We asynchronously import and unbox the module. Since this is included globally, we don't + // do a regular import because that would increase the size of the webpack bundle. + const { waitForCaptchaToBeSolved } = await import('~/captcha/wait_for_captcha_to_be_solved'); + + // show the CAPTCHA modal and wait for it to be solved or closed + const captchaResponse = await waitForCaptchaToBeSolved(data.captcha_site_key); + + // resubmit the original request with the captcha_response and spam_log_id in the headers + const originalData = JSON.parse(errConfig.data); + const originalHeaders = errConfig.headers; + return axios({ + method: errConfig.method, + url: errConfig.url, + headers: { + ...originalHeaders, + 'X-GitLab-Captcha-Response': captchaResponse, + 'X-GitLab-Spam-Log-Id': data.spam_log_id, + }, + data: originalData, + }); +}; export function registerCaptchaModalInterceptor(axios) { return axios.interceptors.response.use( @@ -6,29 +35,8 @@ export function registerCaptchaModalInterceptor(axios) { return response; }, (err) => { - if ( - supportedMethods.includes(err?.config?.method) && - err?.response?.data?.needs_captcha_response - ) { - const { data } = err.response; - const captchaSiteKey = data.captcha_site_key; - const spamLogId = data.spam_log_id; - // eslint-disable-next-line promise/no-promise-in-callback - return import('~/captcha/wait_for_captcha_to_be_solved') - .then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey)) - .then((captchaResponse) => { - const errConfig = err.config; - const originalData = JSON.parse(errConfig.data); - return axios({ - method: errConfig.method, - url: errConfig.url, - data: { - ...originalData, - captcha_response: captchaResponse, - spam_log_id: spamLogId, - }, - }); - }); + if (needsCaptchaResponse(err)) { + return showCaptchaModalAndResubmit(axios, err.response.data, err.config); } return Promise.reject(err); diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue index ced07dea7be..bc8a1f05ef5 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue @@ -2,7 +2,7 @@ import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui'; import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; import lintCiMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql'; -import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; export default { components: { @@ -12,7 +12,7 @@ export default { GlLink, GlAlert, CiLintResults, - EditorLite, + SourceEditor, }, props: { endpoint: { @@ -93,7 +93,7 @@ export default { <div class="js-file-title file-title clearfix"> {{ __('Contents of .gitlab-ci.yml') }} </div> - <editor-lite v-model="content" file-name="*.yml" /> + <source-editor v-model="content" file-name="*.yml" /> </div> </div> diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 762b37a8216..c2c035963f4 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,18 +1,14 @@ import { GlToast } from '@gitlab/ui'; import Visibility from 'visibilityjs'; import Vue from 'vue'; +import createFlash from '~/flash'; import AccessorUtilities from '~/lib/utils/accessor'; import initProjectSelectDropdown from '~/project_select'; -import initServerlessSurveyBanner from '~/serverless/survey_banner'; -import { deprecatedCreateFlash as Flash } from '../flash'; import Poll from '../lib/utils/poll'; -import { s__, sprintf } from '../locale'; +import { s__ } from '../locale'; import PersistentUserCallout from '../persistent_user_callout'; import initSettingsPanels from '../settings_panels'; -import Applications from './components/applications.vue'; import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue'; -import { APPLICATION_STATUS, CROSSPLANE, KNATIVE } from './constants'; -import eventHub from './event_hub'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; @@ -20,46 +16,20 @@ const Environments = () => import('ee_component/clusters/components/environments Vue.use(GlToast); -/** - * Cluster page has 2 separate parts: - * Toggle button and applications section - * - * - Polling status while creating or scheduled - * - Update status area with the response result - */ - export default class Clusters { constructor() { const { statusPath, - installHelmPath, - installIngressPath, - installCertManagerPath, - installRunnerPath, - installJupyterPath, - installKnativePath, - updateKnativePath, - installElasticStackPath, - installCrossplanePath, - installPrometheusPath, - managePrometheusPath, clusterEnvironmentsPath, hasRbac, providerType, - preInstalledKnative, - clusterType, clusterStatus, clusterStatusReason, helpPath, - helmHelpPath, - ingressHelpPath, - ingressDnsHelpPath, environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, - cloudRunHelpPath, clusterId, - ciliumHelpPath, } = document.querySelector('.js-edit-cluster-form').dataset; this.clusterId = clusterId; @@ -69,38 +39,19 @@ export default class Clusters { this.store = new ClustersStore(); this.store.setHelpPaths({ helpPath, - helmHelpPath, - ingressHelpPath, - ingressDnsHelpPath, environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, - cloudRunHelpPath, - ciliumHelpPath, }); - this.store.setManagePrometheusPath(managePrometheusPath); this.store.updateStatus(clusterStatus); this.store.updateStatusReason(clusterStatusReason); this.store.updateProviderType(providerType); - this.store.updatePreInstalledKnative(preInstalledKnative); this.store.updateRbac(hasRbac); this.service = new ClustersService({ endpoint: statusPath, - installHelmEndpoint: installHelmPath, - installIngressEndpoint: installIngressPath, - installCertManagerEndpoint: installCertManagerPath, - installCrossplaneEndpoint: installCrossplanePath, - installRunnerEndpoint: installRunnerPath, - installPrometheusEndpoint: installPrometheusPath, - installJupyterEndpoint: installJupyterPath, - installKnativeEndpoint: installKnativePath, - updateKnativeEndpoint: updateKnativePath, - installElasticStackEndpoint: installElasticStackPath, clusterEnvironmentsEndpoint: clusterEnvironmentsPath, }); - this.installApplication = this.installApplication.bind(this); - this.errorContainer = document.querySelector('.js-cluster-error'); this.successContainer = document.querySelector('.js-cluster-success'); this.creatingContainer = document.querySelector('.js-cluster-creating'); @@ -109,14 +60,12 @@ export default class Clusters { '.js-cluster-authentication-failure', ); this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); - this.successApplicationContainer = document.querySelector('.js-cluster-application-notice'); this.tokenField = document.querySelector('.js-cluster-token'); initProjectSelectDropdown(); Clusters.initDismissableCallout(); initSettingsPanels(); - this.initApplications(clusterType); this.initEnvironments(); if (clusterEnvironmentsPath && this.environments) { @@ -143,38 +92,6 @@ export default class Clusters { this.initRemoveClusterActions(); } - initApplications(type) { - const { store } = this; - const el = document.querySelector('#js-cluster-applications'); - - this.applications = new Vue({ - el, - data() { - return { - state: store.state, - }; - }, - render(createElement) { - return createElement(Applications, { - props: { - type, - applications: this.state.applications, - helpPath: this.state.helpPath, - helmHelpPath: this.state.helmHelpPath, - ingressHelpPath: this.state.ingressHelpPath, - managePrometheusPath: this.state.managePrometheusPath, - ingressDnsHelpPath: this.state.ingressDnsHelpPath, - cloudRunHelpPath: this.state.cloudRunHelpPath, - providerType: this.state.providerType, - preInstalledKnative: this.state.preInstalledKnative, - rbac: this.state.rbac, - ciliumHelpPath: this.state.ciliumHelpPath, - }, - }); - }, - }); - } - initEnvironments() { const { store } = this; const el = document.querySelector('#js-cluster-environments'); @@ -242,30 +159,11 @@ export default class Clusters { } addListeners() { - eventHub.$on('installApplication', this.installApplication); - eventHub.$on('updateApplication', (data) => this.updateApplication(data)); - eventHub.$on('saveKnativeDomain', (data) => this.saveKnativeDomain(data)); - eventHub.$on('setKnativeDomain', (data) => this.setKnativeDomain(data)); - eventHub.$on('uninstallApplication', (data) => this.uninstallApplication(data)); - eventHub.$on('setCrossplaneProviderStack', (data) => this.setCrossplaneProviderStack(data)); // Add event listener to all the banner close buttons this.addBannerCloseHandler(this.unreachableContainer, 'unreachable'); this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure'); } - removeListeners() { - eventHub.$off('installApplication', this.installApplication); - eventHub.$off('updateApplication', this.updateApplication); - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('saveKnativeDomain'); - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('setKnativeDomain'); - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('setCrossplaneProviderStack'); - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('uninstallApplication'); - } - initPolling(method, successCallback, errorCallback) { this.poll = new Poll({ resource: this.service, @@ -298,21 +196,17 @@ export default class Clusters { } static handleError() { - Flash(s__('ClusterIntegration|Something went wrong on our end.')); + createFlash({ + message: s__('ClusterIntegration|Something went wrong on our end.'), + }); } handleClusterStatusSuccess(data) { const prevStatus = this.store.state.status; - const prevApplicationMap = { ...this.store.state.applications }; this.store.updateStateFromServer(data.data); - this.checkForNewInstalls(prevApplicationMap, this.store.state.applications); this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason); - - if (this.store.state.applications[KNATIVE]?.status === APPLICATION_STATUS.INSTALLED) { - initServerlessSurveyBanner(); - } } hideAll() { @@ -323,27 +217,6 @@ export default class Clusters { this.authenticationFailureContainer.classList.add('hidden'); } - checkForNewInstalls(prevApplicationMap, newApplicationMap) { - const appTitles = Object.keys(newApplicationMap) - .filter( - (appId) => - newApplicationMap[appId].status === APPLICATION_STATUS.INSTALLED && - prevApplicationMap[appId].status !== APPLICATION_STATUS.INSTALLED && - prevApplicationMap[appId].status !== null, - ) - .map((appId) => newApplicationMap[appId].title); - - if (appTitles.length > 0) { - const text = sprintf( - s__('ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster'), - { - appList: appTitles.join(', '), - }, - ); - Flash(text, 'notice', this.successApplicationContainer); - } - } - setBannerDismissedState(status, isDismissed) { if (AccessorUtilities.isLocalStorageAccessSafe()) { window.localStorage.setItem(this.clusterBannerDismissedKey, `${status}_${isDismissed}`); @@ -416,91 +289,9 @@ export default class Clusters { } } - installApplication({ id: appId, params }) { - return Clusters.validateInstallation(appId, params) - .then(() => { - this.store.updateAppProperty(appId, 'requestReason', null); - this.store.updateAppProperty(appId, 'statusReason', null); - this.store.installApplication(appId); - - // eslint-disable-next-line promise/no-nesting - this.service.installApplication(appId, params).catch(() => { - this.store.notifyInstallFailure(appId); - this.store.updateAppProperty( - appId, - 'requestReason', - s__('ClusterIntegration|Request to begin installing failed'), - ); - }); - }) - .catch((error) => this.store.updateAppProperty(appId, 'validationError', error)); - } - - static validateInstallation(appId, params) { - return new Promise((resolve, reject) => { - if (appId === CROSSPLANE && !params.stack) { - reject(s__('ClusterIntegration|Select a stack to install Crossplane.')); - return; - } - - if (appId === KNATIVE && !params.hostname && !params.pages_domain_id) { - reject(s__('ClusterIntegration|You must specify a domain before you can install Knative.')); - return; - } - - resolve(); - }); - } - - uninstallApplication({ id: appId }) { - this.store.updateAppProperty(appId, 'requestReason', null); - this.store.updateAppProperty(appId, 'statusReason', null); - - this.store.uninstallApplication(appId); - - return this.service.uninstallApplication(appId).catch(() => { - this.store.notifyUninstallFailure(appId); - this.store.updateAppProperty( - appId, - 'requestReason', - s__('ClusterIntegration|Request to begin uninstalling failed'), - ); - }); - } - - updateApplication({ id: appId, params }) { - this.store.updateApplication(appId); - this.service.installApplication(appId, params).catch(() => { - this.store.notifyUpdateFailure(appId); - }); - } - - saveKnativeDomain(data) { - const appId = data.id; - this.store.updateApplication(appId); - this.service.updateApplication(appId, data.params).catch(() => { - this.store.notifyUpdateFailure(appId); - }); - } - - setKnativeDomain({ id: appId, domain, domainId }) { - this.store.updateAppProperty(appId, 'isEditingDomain', true); - this.store.updateAppProperty(appId, 'hostname', domain); - this.store.updateAppProperty(appId, 'pagesDomain', domainId ? { id: domainId, domain } : null); - this.store.updateAppProperty(appId, 'validationError', null); - } - - setCrossplaneProviderStack(data) { - const appId = data.id; - this.store.updateAppProperty(appId, 'stack', data.stack.code); - this.store.updateAppProperty(appId, 'validationError', null); - } - destroy() { this.destroyed = true; - this.removeListeners(); - if (this.poll) { this.poll.stop(); } @@ -508,7 +299,5 @@ export default class Clusters { if (this.environments) { this.environments.$destroy(); } - - this.applications.$destroy(); } } diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue deleted file mode 100644 index a53b63ea592..00000000000 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ /dev/null @@ -1,478 +0,0 @@ -<script> -import { GlLink, GlModalDirective, GlSprintf, GlButton, GlAlert } from '@gitlab/ui'; -import { s__, __, sprintf } from '~/locale'; -import identicon from '../../vue_shared/components/identicon.vue'; -import { APPLICATION_STATUS, ELASTIC_STACK } from '../constants'; -import eventHub from '../event_hub'; -import UninstallApplicationButton from './uninstall_application_button.vue'; -import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue'; -import UpdateApplicationConfirmationModal from './update_application_confirmation_modal.vue'; - -export default { - components: { - GlButton, - identicon, - GlLink, - GlAlert, - GlSprintf, - UninstallApplicationButton, - UninstallApplicationConfirmationModal, - UpdateApplicationConfirmationModal, - }, - directives: { - GlModalDirective, - }, - props: { - id: { - type: String, - required: true, - }, - title: { - type: String, - required: true, - }, - titleLink: { - type: String, - required: false, - default: '', - }, - manageLink: { - type: String, - required: false, - default: '', - }, - logoUrl: { - type: String, - required: false, - default: '', - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - installable: { - type: Boolean, - required: false, - default: true, - }, - uninstallable: { - type: Boolean, - required: false, - default: false, - }, - status: { - type: String, - required: false, - default: '', - }, - statusReason: { - type: String, - required: false, - default: '', - }, - requestReason: { - type: String, - required: false, - default: '', - }, - installed: { - type: Boolean, - required: false, - default: false, - }, - installFailed: { - type: Boolean, - required: false, - default: false, - }, - version: { - type: String, - required: false, - default: '', - }, - chartRepo: { - type: String, - required: false, - default: '', - }, - updateAvailable: { - type: Boolean, - required: false, - }, - updateable: { - type: Boolean, - default: true, - required: false, - }, - updateSuccessful: { - type: Boolean, - required: false, - default: false, - }, - updateFailed: { - type: Boolean, - required: false, - default: false, - }, - uninstallFailed: { - type: Boolean, - required: false, - default: false, - }, - uninstallSuccessful: { - type: Boolean, - required: false, - default: false, - }, - installApplicationRequestParams: { - type: Object, - required: false, - default: () => ({}), - }, - }, - computed: { - isUnknownStatus() { - return !this.isKnownStatus && this.status !== null; - }, - isKnownStatus() { - return Object.values(APPLICATION_STATUS).includes(this.status); - }, - isInstalling() { - return this.status === APPLICATION_STATUS.INSTALLING; - }, - isExternallyInstalled() { - return this.status === APPLICATION_STATUS.EXTERNALLY_INSTALLED; - }, - canInstall() { - return ( - this.status === APPLICATION_STATUS.NOT_INSTALLABLE || - this.status === APPLICATION_STATUS.INSTALLABLE || - this.status === APPLICATION_STATUS.UNINSTALLED || - this.isUnknownStatus - ); - }, - hasLogo() { - return Boolean(this.logoUrl); - }, - identiconId() { - // generate a deterministic integer id for the identicon background - return this.id.charCodeAt(0); - }, - rowJsClass() { - return `js-cluster-application-row-${this.id}`; - }, - displayUninstallButton() { - return this.installed && this.uninstallable; - }, - displayInstallButton() { - return !this.installed || !this.uninstallable; - }, - installButtonLoading() { - return !this.status || this.isInstalling; - }, - installButtonDisabled() { - // Applications installed through the management project can - // only be installed through the CI pipeline. Installation should - // be disable in all states. - if (!this.installable) return true; - - // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but - // we already made a request to install and are just waiting for the real-time - // to sync up. - if (this.isInstalling) return true; - - if (!this.isKnownStatus) return false; - - return ( - this.status !== APPLICATION_STATUS.INSTALLABLE && this.status !== APPLICATION_STATUS.ERROR - ); - }, - installButtonLabel() { - let label; - if (this.canInstall) { - label = __('Install'); - } else if (this.isInstalling) { - label = __('Installing'); - } else if (this.installed) { - label = __('Installed'); - } else if (this.isExternallyInstalled) { - label = __('Externally installed'); - } - - return label; - }, - buttonGridCellClass() { - return this.showManageButton || this.status === APPLICATION_STATUS.EXTERNALLY_INSTALLED - ? 'section-25' - : 'section-15'; - }, - showManageButton() { - return this.manageLink && this.status === APPLICATION_STATUS.INSTALLED; - }, - manageButtonLabel() { - return __('Manage'); - }, - hasError() { - return this.installFailed || this.uninstallFailed; - }, - generalErrorDescription() { - let errorDescription; - - if (this.installFailed) { - errorDescription = s__('ClusterIntegration|Something went wrong while installing %{title}'); - } else if (this.uninstallFailed) { - errorDescription = s__( - 'ClusterIntegration|Something went wrong while uninstalling %{title}', - ); - } - - return sprintf(errorDescription, { title: this.title }); - }, - updateFailureDescription() { - return s__('ClusterIntegration|Update failed. Please check the logs and try again.'); - }, - updateSuccessDescription() { - return sprintf(s__('ClusterIntegration|%{title} updated successfully.'), { - title: this.title, - }); - }, - updateButtonLabel() { - let label; - if (this.updateAvailable && !this.updateFailed && !this.isUpdating) { - label = __('Update'); - } else if (this.isUpdating) { - label = __('Updating'); - } else if (this.updateFailed) { - label = __('Retry update'); - } - - return label; - }, - updatingNeedsConfirmation() { - if (this.version) { - const majorVersion = parseInt(this.version.split('.')[0], 10); - - if (!Number.isNaN(majorVersion)) { - return this.id === ELASTIC_STACK && majorVersion < 3; - } - } - - return false; - }, - isUpdating() { - // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend - return this.status === APPLICATION_STATUS.UPDATING; - }, - shouldShowUpdateDetails() { - // This method only returns true when; - // Update was successful OR Update failed - // AND new update is unavailable AND version information is present. - return (this.updateSuccessful || this.updateFailed) && !this.updateAvailable && this.version; - }, - uninstallSuccessDescription() { - return sprintf(s__('ClusterIntegration|%{title} uninstalled successfully.'), { - title: this.title, - }); - }, - updateModalId() { - return `update-${this.id}`; - }, - uninstallModalId() { - return `uninstall-${this.id}`; - }, - }, - watch: { - updateSuccessful(updateSuccessful) { - if (updateSuccessful) { - this.$toast.show(this.updateSuccessDescription); - } - }, - uninstallSuccessful(uninstallSuccessful) { - if (uninstallSuccessful) { - this.$toast.show(this.uninstallSuccessDescription); - } - }, - }, - methods: { - installClicked() { - if (this.disabled || this.installButtonDisabled) return; - - eventHub.$emit('installApplication', { - id: this.id, - params: this.installApplicationRequestParams, - }); - }, - updateConfirmed() { - if (this.isUpdating) return; - - eventHub.$emit('updateApplication', { - id: this.id, - params: this.installApplicationRequestParams, - }); - }, - uninstallConfirmed() { - eventHub.$emit('uninstallApplication', { - id: this.id, - }); - }, - }, -}; -</script> - -<template> - <div - :class="[ - rowJsClass, - installed && 'cluster-application-installed', - disabled && 'cluster-application-disabled', - ]" - class="cluster-application-row gl-responsive-table-row gl-responsive-table-row-col-span" - :data-qa-selector="id" - > - <div class="gl-responsive-table-row-layout" role="row"> - <div class="table-section gl-mr-3 section-align-top" role="gridcell"> - <img - v-if="hasLogo" - :src="logoUrl" - :alt="`${title} logo`" - class="cluster-application-logo avatar s40" - /> - <identicon v-else :entity-id="identiconId" :entity-name="title" size-class="s40" /> - </div> - <div class="table-section cluster-application-description section-wrap" role="gridcell"> - <strong> - <a - v-if="titleLink" - :href="titleLink" - target="_blank" - rel="noopener noreferrer" - class="js-cluster-application-title" - >{{ title }}</a - > - <span v-else class="js-cluster-application-title">{{ title }}</span> - </strong> - <slot name="installed-via"></slot> - <div> - <slot name="description"></slot> - </div> - <div v-if="hasError" class="cluster-application-error text-danger gl-mt-3"> - <p class="js-cluster-application-general-error-message gl-mb-0"> - {{ generalErrorDescription }} - </p> - <ul v-if="statusReason || requestReason"> - <li v-if="statusReason" class="js-cluster-application-status-error-message"> - {{ statusReason }} - </li> - <li v-if="requestReason" class="js-cluster-application-request-error-message"> - {{ requestReason }} - </li> - </ul> - </div> - - <div v-if="updateable"> - <div - v-if="shouldShowUpdateDetails" - class="form-text text-muted label p-0 js-cluster-application-update-details" - > - <template v-if="updateFailed">{{ __('Update failed') }}</template> - <template v-else-if="isUpdating">{{ __('Updating') }}</template> - <template v-else> - <gl-sprintf :message="__('Updated to %{linkStart}chart v%{linkEnd}')"> - <template #link="{ content }"> - <gl-link - :href="chartRepo" - target="_blank" - class="js-cluster-application-update-version" - >{{ content }}{{ version }}</gl-link - > - </template> - </gl-sprintf> - </template> - </div> - - <gl-alert - v-if="updateFailed && !isUpdating" - variant="danger" - :dismissible="false" - class="gl-mt-3 gl-mb-0 js-cluster-application-update-details" - > - {{ updateFailureDescription }} - </gl-alert> - <template v-if="updateAvailable || updateFailed || isUpdating"> - <template v-if="updatingNeedsConfirmation"> - <gl-button - v-gl-modal-directive="updateModalId" - class="js-cluster-application-update-button mt-2" - variant="info" - category="primary" - :loading="isUpdating" - :disabled="isUpdating" - data-qa-selector="update_button_with_confirmation" - :data-qa-application="id" - > - {{ updateButtonLabel }} - </gl-button> - <update-application-confirmation-modal - :application="id" - :application-title="title" - @confirm="updateConfirmed()" - /> - </template> - - <gl-button - v-else - class="js-cluster-application-update-button mt-2" - variant="info" - category="primary" - :loading="isUpdating" - :disabled="isUpdating" - data-qa-selector="update_button" - :data-qa-application="id" - @click="updateConfirmed" - > - {{ updateButtonLabel }} - </gl-button> - </template> - </div> - </div> - <div - :class="[buttonGridCellClass, 'table-section', 'table-button-footer', 'section-align-top']" - role="gridcell" - > - <div v-if="showManageButton" class="btn-group table-action-buttons"> - <a :href="manageLink" :class="{ disabled: disabled }" class="btn">{{ - manageButtonLabel - }}</a> - </div> - <div class="btn-group table-action-buttons"> - <gl-button - v-if="displayInstallButton" - :loading="installButtonLoading" - :disabled="disabled || installButtonDisabled" - class="js-cluster-application-install-button" - variant="default" - data-qa-selector="install_button" - :data-qa-application="id" - @click="installClicked" - > - {{ installButtonLabel }} - </gl-button> - <uninstall-application-button - v-if="displayUninstallButton" - v-gl-modal-directive="uninstallModalId" - :status="status" - data-qa-selector="uninstall_button" - :data-qa-application="id" - class="js-cluster-application-uninstall-button" - /> - <uninstall-application-confirmation-modal - :application="id" - :application-title="title" - @confirm="uninstallConfirmed()" - /> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue deleted file mode 100644 index ddee1711975..00000000000 --- a/app/assets/javascripts/clusters/components/applications.vue +++ /dev/null @@ -1,662 +0,0 @@ -<script> -import { GlLoadingIcon, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; -import certManagerLogo from 'images/cluster_app_logos/cert_manager.png'; -import crossplaneLogo from 'images/cluster_app_logos/crossplane.png'; -import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png'; -import gitlabLogo from 'images/cluster_app_logos/gitlab.png'; -import helmLogo from 'images/cluster_app_logos/helm.png'; -import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png'; -import knativeLogo from 'images/cluster_app_logos/knative.png'; -import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png'; -import prometheusLogo from 'images/cluster_app_logos/prometheus.png'; -import eventHub from '~/clusters/event_hub'; -import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; -import applicationRow from './application_row.vue'; -import CrossplaneProviderStack from './crossplane_provider_stack.vue'; -import KnativeDomainEditor from './knative_domain_editor.vue'; - -export default { - components: { - applicationRow, - clipboardButton, - GlLoadingIcon, - GlSprintf, - GlLink, - KnativeDomainEditor, - CrossplaneProviderStack, - GlAlert, - }, - props: { - type: { - type: String, - required: false, - default: CLUSTER_TYPE.PROJECT, - }, - applications: { - type: Object, - required: false, - default: () => ({}), - }, - helpPath: { - type: String, - required: false, - default: '', - }, - helmHelpPath: { - type: String, - required: false, - default: '', - }, - ingressHelpPath: { - type: String, - required: false, - default: '', - }, - ingressDnsHelpPath: { - type: String, - required: false, - default: '', - }, - - cloudRunHelpPath: { - type: String, - required: false, - default: '', - }, - managePrometheusPath: { - type: String, - required: false, - default: '', - }, - providerType: { - type: String, - required: false, - default: '', - }, - preInstalledKnative: { - type: Boolean, - required: false, - default: false, - }, - rbac: { - type: Boolean, - required: false, - default: false, - }, - ciliumHelpPath: { - type: String, - required: false, - default: '', - }, - }, - computed: { - ingressId() { - return INGRESS; - }, - ingressInstalled() { - return this.applications.ingress.status === APPLICATION_STATUS.INSTALLED; - }, - ingressExternalEndpoint() { - return this.applications.ingress.externalIp || this.applications.ingress.externalHostname; - }, - certManagerInstalled() { - return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED; - }, - jupyterInstalled() { - return this.applications.jupyter.status === APPLICATION_STATUS.INSTALLED; - }, - jupyterHostname() { - return this.applications.jupyter.hostname; - }, - knative() { - return this.applications.knative; - }, - crossplane() { - return this.applications.crossplane; - }, - cloudRun() { - return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative; - }, - ingress() { - return this.applications.ingress; - }, - }, - methods: { - saveKnativeDomain() { - eventHub.$emit('saveKnativeDomain', { - id: 'knative', - params: { - hostname: this.applications.knative.hostname, - pages_domain_id: this.applications.knative.pagesDomain?.id, - }, - }); - }, - setKnativeDomain({ domainId, domain }) { - eventHub.$emit('setKnativeDomain', { - id: 'knative', - domainId, - domain, - }); - }, - setCrossplaneProviderStack(stack) { - eventHub.$emit('setCrossplaneProviderStack', { - id: 'crossplane', - stack, - }); - }, - }, - logos: { - gitlabLogo, - helmLogo, - jupyterhubLogo, - kubernetesLogo, - certManagerLogo, - crossplaneLogo, - knativeLogo, - prometheusLogo, - elasticStackLogo, - }, -}; -</script> - -<template> - <section id="cluster-applications"> - <p class="gl-mb-0"> - {{ - s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster.`) - }} - <gl-link :href="helpPath">{{ __('More information') }}</gl-link> - </p> - - <div class="cluster-application-list gl-mt-3"> - <application-row - v-if="applications.helm.installed || applications.helm.uninstalling" - id="helm" - :logo-url="$options.logos.helmLogo" - :title="applications.helm.title" - :status="applications.helm.status" - :status-reason="applications.helm.statusReason" - :request-status="applications.helm.requestStatus" - :request-reason="applications.helm.requestReason" - :installed="applications.helm.installed" - :install-failed="applications.helm.installFailed" - :uninstallable="applications.helm.uninstallable" - :uninstall-successful="applications.helm.uninstallSuccessful" - :uninstall-failed="applications.helm.uninstallFailed" - title-link="https://v2.helm.sh/" - > - <template #description> - <p> - {{ - s__(`ClusterIntegration|Can be safely removed. Prior to GitLab - 13.2, GitLab used a remote Tiller server to manage the - applications. GitLab no longer uses this server. - Uninstalling this server will not affect your other - applications. This row will disappear afterwards.`) - }} - <gl-link :href="helmHelpPath">{{ __('More information') }}</gl-link> - </p> - </template> - </application-row> - <application-row - :id="ingressId" - :logo-url="$options.logos.kubernetesLogo" - :title="applications.ingress.title" - :status="applications.ingress.status" - :status-reason="applications.ingress.statusReason" - :request-status="applications.ingress.requestStatus" - :request-reason="applications.ingress.requestReason" - :installed="applications.ingress.installed" - :install-failed="applications.ingress.installFailed" - :uninstallable="applications.ingress.uninstallable" - :uninstall-successful="applications.ingress.uninstallSuccessful" - :uninstall-failed="applications.ingress.uninstallFailed" - :updateable="false" - title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/" - > - <template #description> - <p> - {{ - s__(`ClusterIntegration|Ingress gives you a way to route - requests to services based on the request host or path, - centralizing a number of services into a single entrypoint.`) - }} - </p> - - <template v-if="ingressInstalled"> - <div class="form-group"> - <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label> - <div class="input-group"> - <template v-if="ingressExternalEndpoint"> - <input - id="ingress-endpoint" - :value="ingressExternalEndpoint" - type="text" - class="form-control js-endpoint" - readonly - /> - <span class="input-group-append"> - <clipboard-button - :text="ingressExternalEndpoint" - :title="s__('ClusterIntegration|Copy Ingress Endpoint')" - class="input-group-text js-clipboard-btn" - /> - </span> - </template> - <template v-else> - <input type="text" class="form-control js-endpoint" readonly /> - <gl-loading-icon - class="position-absolute align-self-center ml-2 js-ingress-ip-loading-icon" - /> - </template> - </div> - <p class="form-text text-muted"> - {{ - s__(`ClusterIntegration|Point a wildcard DNS to this - generated endpoint in order to access - your application after it has been deployed.`) - }} - <gl-link :href="ingressDnsHelpPath" target="_blank"> - {{ __('More information') }} - </gl-link> - </p> - </div> - - <p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message"> - {{ - s__(`ClusterIntegration|The endpoint is in - the process of being assigned. Please check your Kubernetes - cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) - }} - <gl-link :href="ingressDnsHelpPath" target="_blank"> - {{ __('More information') }} - </gl-link> - </p> - </template> - <template v-else> - <gl-alert variant="info" :dismissible="false"> - <span data-testid="ingressCostWarning"> - <gl-sprintf - :message=" - s__( - 'ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{linkStart}pricing%{linkEnd}.', - ) - " - > - <template #link="{ content }"> - <gl-link href="https://cloud.google.com/compute/pricing#lb" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </span> - </gl-alert> - </template> - </template> - </application-row> - <application-row - id="cert_manager" - :logo-url="$options.logos.certManagerLogo" - :title="applications.cert_manager.title" - :status="applications.cert_manager.status" - :status-reason="applications.cert_manager.statusReason" - :request-status="applications.cert_manager.requestStatus" - :request-reason="applications.cert_manager.requestReason" - :installed="applications.cert_manager.installed" - :install-failed="applications.cert_manager.installFailed" - :install-application-request-params="{ email: applications.cert_manager.email }" - :uninstallable="applications.cert_manager.uninstallable" - :uninstall-successful="applications.cert_manager.uninstallSuccessful" - :uninstall-failed="applications.cert_manager.uninstallFailed" - title-link="https://cert-manager.readthedocs.io/en/latest/#" - > - <template #description> - <p data-testid="certManagerDescription"> - <gl-sprintf - :message=" - s__(`ClusterIntegration|Cert-Manager is a native Kubernetes certificate management controller that helps with issuing certificates. - Installing Cert-Manager on your cluster will issue a certificate by %{linkStart}Let's Encrypt%{linkEnd} and ensure that certificates - are valid and up-to-date.`) - " - > - <template #link="{ content }"> - <gl-link href="https://letsencrypt.org/" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - <div class="form-group"> - <label for="cert-manager-issuer-email"> - {{ s__('ClusterIntegration|Issuer Email') }} - </label> - <div class="input-group"> - <!-- eslint-disable vue/no-mutating-props --> - <input - id="cert-manager-issuer-email" - v-model="applications.cert_manager.email" - :readonly="certManagerInstalled" - type="text" - class="form-control js-email" - /> - <!-- eslint-enable vue/no-mutating-props --> - </div> - <p class="form-text text-muted"> - {{ - s__(`ClusterIntegration|Issuers represent a certificate authority. - You must provide an email address for your Issuer.`) - }} - <gl-link - href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email" - target="_blank" - >{{ __('More information') }}</gl-link - > - </p> - </div> - </template> - </application-row> - <application-row - id="prometheus" - :logo-url="$options.logos.prometheusLogo" - :title="applications.prometheus.title" - :manage-link="managePrometheusPath" - :status="applications.prometheus.status" - :status-reason="applications.prometheus.statusReason" - :request-status="applications.prometheus.requestStatus" - :request-reason="applications.prometheus.requestReason" - :installed="applications.prometheus.installed" - :install-failed="applications.prometheus.installFailed" - :uninstallable="applications.prometheus.uninstallable" - :uninstall-successful="applications.prometheus.uninstallSuccessful" - :uninstall-failed="applications.prometheus.uninstallFailed" - title-link="https://prometheus.io/docs/introduction/overview/" - > - <template #description> - <span data-testid="prometheusDescription"> - <gl-sprintf - :message=" - s__(`ClusterIntegration|Prometheus is an open-source monitoring system - with %{linkStart}GitLab Integration%{linkEnd} to monitor deployed applications.`) - " - > - <template #link="{ content }"> - <gl-link - href="https://docs.gitlab.com/ee/user/project/integrations/prometheus.html" - target="_blank" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </span> - </template> - </application-row> - <application-row - id="runner" - :logo-url="$options.logos.gitlabLogo" - :title="applications.runner.title" - :status="applications.runner.status" - :status-reason="applications.runner.statusReason" - :request-status="applications.runner.requestStatus" - :request-reason="applications.runner.requestReason" - :version="applications.runner.version" - :chart-repo="applications.runner.chartRepo" - :update-available="applications.runner.updateAvailable" - :installed="applications.runner.installed" - :install-failed="applications.runner.installFailed" - :update-successful="applications.runner.updateSuccessful" - :update-failed="applications.runner.updateFailed" - :uninstallable="applications.runner.uninstallable" - :uninstall-successful="applications.runner.uninstallSuccessful" - :uninstall-failed="applications.runner.uninstallFailed" - title-link="https://docs.gitlab.com/runner/" - > - <template #description> - {{ - s__(`ClusterIntegration|GitLab Runner connects to the - repository and executes CI/CD jobs, - pushing results back and deploying - applications to production.`) - }} - </template> - </application-row> - <application-row - id="crossplane" - :logo-url="$options.logos.crossplaneLogo" - :title="applications.crossplane.title" - :status="applications.crossplane.status" - :status-reason="applications.crossplane.statusReason" - :request-status="applications.crossplane.requestStatus" - :request-reason="applications.crossplane.requestReason" - :installed="applications.crossplane.installed" - :install-failed="applications.crossplane.installFailed" - :uninstallable="applications.crossplane.uninstallable" - :uninstall-successful="applications.crossplane.uninstallSuccessful" - :uninstall-failed="applications.crossplane.uninstallFailed" - :install-application-request-params="{ stack: applications.crossplane.stack }" - title-link="https://crossplane.io" - > - <template #description> - <p data-testid="crossplaneDescription"> - <gl-sprintf - :message=" - s__( - `ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{codeStart}kubectl%{codeEnd} or %{linkStart}GitLab Integration%{linkEnd}. - Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on.`, - ) - " - > - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - <template #link="{ content }"> - <gl-link - href="https://docs.gitlab.com/ee/user/clusters/applications.html#crossplane" - target="_blank" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </p> - <div class="form-group"> - <CrossplaneProviderStack :crossplane="crossplane" @set="setCrossplaneProviderStack" /> - </div> - </template> - </application-row> - - <application-row - id="jupyter" - :logo-url="$options.logos.jupyterhubLogo" - :title="applications.jupyter.title" - :status="applications.jupyter.status" - :status-reason="applications.jupyter.statusReason" - :request-status="applications.jupyter.requestStatus" - :request-reason="applications.jupyter.requestReason" - :installed="applications.jupyter.installed" - :install-failed="applications.jupyter.installFailed" - :uninstallable="applications.jupyter.uninstallable" - :uninstall-successful="applications.jupyter.uninstallSuccessful" - :uninstall-failed="applications.jupyter.uninstallFailed" - :install-application-request-params="{ hostname: applications.jupyter.hostname }" - title-link="https://jupyterhub.readthedocs.io/en/stable/" - > - <template #description> - <p> - {{ - s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns, - manages, and proxies multiple instances of the single-user - Jupyter notebook server. JupyterHub can be used to serve - notebooks to a class of students, a corporate data science group, - or a scientific research group.`) - }} - <gl-sprintf - :message=" - s__( - 'ClusterIntegration|%{boldStart}Note:%{boldEnd} Requires Ingress to be installed.', - ) - " - > - <template #bold="{ content }"> - <b>{{ content }}</b> - </template> - </gl-sprintf> - </p> - - <template v-if="ingressExternalEndpoint"> - <div class="form-group"> - <label for="jupyter-hostname">{{ s__('ClusterIntegration|Jupyter Hostname') }}</label> - - <div class="input-group"> - <!-- eslint-disable vue/no-mutating-props --> - <input - id="jupyter-hostname" - v-model="applications.jupyter.hostname" - :readonly="jupyterInstalled" - type="text" - class="form-control js-hostname" - /> - <!-- eslint-enable vue/no-mutating-props --> - <span class="input-group-append"> - <clipboard-button - :text="jupyterHostname" - :title="s__('ClusterIntegration|Copy Jupyter Hostname')" - class="js-clipboard-btn" - /> - </span> - </div> - - <p v-if="ingressInstalled" class="form-text text-muted"> - {{ - s__(`ClusterIntegration|Replace this with your own hostname if you want. - If you do so, point hostname to Ingress IP Address from above.`) - }} - <gl-link :href="ingressDnsHelpPath" target="_blank"> - {{ __('More information') }} - </gl-link> - </p> - </div> - </template> - </template> - </application-row> - <application-row - id="knative" - :logo-url="$options.logos.knativeLogo" - :title="applications.knative.title" - :status="applications.knative.status" - :status-reason="applications.knative.statusReason" - :request-status="applications.knative.requestStatus" - :request-reason="applications.knative.requestReason" - :installed="applications.knative.installed" - :install-failed="applications.knative.installFailed" - :install-application-request-params="{ - hostname: applications.knative.hostname, - pages_domain_id: applications.knative.pagesDomain && applications.knative.pagesDomain.id, - }" - :uninstallable="applications.knative.uninstallable" - :uninstall-successful="applications.knative.uninstallSuccessful" - :uninstall-failed="applications.knative.uninstallFailed" - :updateable="false" - v-bind="applications.knative" - title-link="https://github.com/knative/docs" - > - <template #description> - <gl-alert v-if="!rbac" variant="info" class="rbac-notice gl-my-3" :dismissible="false"> - {{ - s__(`ClusterIntegration|You must have an RBAC-enabled cluster - to install Knative.`) - }} - <gl-link :href="helpPath" target="_blank">{{ __('More information') }}</gl-link> - </gl-alert> - <p> - {{ - s__(`ClusterIntegration|Knative extends Kubernetes to provide - a set of middleware components that are essential to build modern, - source-centric, and container-based applications that can run - anywhere: on premises, in the cloud, or even in a third-party data center.`) - }} - </p> - - <knative-domain-editor - v-if="(knative.installed || rbac) && !preInstalledKnative" - :knative="knative" - :ingress-dns-help-path="ingressDnsHelpPath" - @save="saveKnativeDomain" - @set="setKnativeDomain" - /> - </template> - <template v-if="cloudRun" #installed-via> - <span data-testid="installed-via"> - <gl-sprintf - :message="s__('ClusterIntegration|installed via %{linkStart}Cloud Run%{linkEnd}')" - > - <template #link="{ content }"> - <gl-link :href="cloudRunHelpPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - </template> - </application-row> - <application-row - id="elastic_stack" - :logo-url="$options.logos.elasticStackLogo" - :title="applications.elastic_stack.title" - :status="applications.elastic_stack.status" - :status-reason="applications.elastic_stack.statusReason" - :request-status="applications.elastic_stack.requestStatus" - :request-reason="applications.elastic_stack.requestReason" - :version="applications.elastic_stack.version" - :chart-repo="applications.elastic_stack.chartRepo" - :update-available="applications.elastic_stack.updateAvailable" - :installed="applications.elastic_stack.installed" - :install-failed="applications.elastic_stack.installFailed" - :update-successful="applications.elastic_stack.updateSuccessful" - :update-failed="applications.elastic_stack.updateFailed" - :uninstallable="applications.elastic_stack.uninstallable" - :uninstall-successful="applications.elastic_stack.uninstallSuccessful" - :uninstall-failed="applications.elastic_stack.uninstallFailed" - title-link="https://gitlab.com/gitlab-org/charts/elastic-stack" - > - <template #description> - <p> - {{ - s__( - `ClusterIntegration|The elastic stack collects logs from all pods in your cluster`, - ) - }} - </p> - </template> - </application-row> - - <div class="gl-mt-7 gl-border-1 gl-border-t-solid gl-border-gray-100"> - <!-- This empty div serves as a separator. The applications below can be externally installed using a cluster-management project. --> - </div> - - <application-row - id="cilium" - :title="applications.cilium.title" - :logo-url="$options.logos.gitlabLogo" - :status="applications.cilium.status" - :status-reason="applications.cilium.statusReason" - :installable="applications.cilium.installable" - :uninstallable="applications.cilium.uninstallable" - :installed="applications.cilium.installed" - :install-failed="applications.cilium.installFailed" - :title-link="ciliumHelpPath" - > - <template #description> - <p data-testid="ciliumDescription"> - <gl-sprintf - :message=" - s__( - 'ClusterIntegration|Protect your clusters with GitLab Container Network Policies by enforcing how pods communicate with each other and other network endpoints. %{linkStart}Learn more about configuring Network Policies here.%{linkEnd}', - ) - " - > - <template #link="{ content }"> - <gl-link :href="ciliumHelpPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - </template> - </application-row> - </div> - </section> -</template> diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue deleted file mode 100644 index 6b99bb09504..00000000000 --- a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue +++ /dev/null @@ -1,93 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { s__ } from '../../locale'; - -export default { - name: 'CrossplaneProviderStack', - components: { - GlDropdown, - GlDropdownItem, - GlIcon, - }, - props: { - stacks: { - type: Array, - required: false, - default: () => [ - { - name: s__('Google Cloud Platform'), - code: 'gcp', - }, - { - name: s__('Amazon Web Services'), - code: 'aws', - }, - { - name: s__('Microsoft Azure'), - code: 'azure', - }, - { - name: s__('Rook'), - code: 'rook', - }, - ], - }, - crossplane: { - type: Object, - required: true, - }, - }, - computed: { - dropdownText() { - const result = this.stacks.reduce((map, obj) => { - // eslint-disable-next-line no-param-reassign - map[obj.code] = obj.name; - return map; - }, {}); - const { stack } = this.crossplane; - if (stack !== '') { - return result[stack]; - } - return s__('Select Stack'); - }, - validationError() { - return this.crossplane.validationError; - }, - }, - methods: { - selectStack(stack) { - this.$emit('set', stack); - }, - }, -}; -</script> - -<template> - <div> - <label> - {{ s__('ClusterIntegration|Enabled stack') }} - </label> - <gl-dropdown - :disabled="crossplane.installed" - :text="dropdownText" - toggle-class="dropdown-menu-toggle gl-field-error-outline" - class="w-100" - :class="{ 'gl-show-field-errors': validationError }" - > - <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)"> - <span class="ml-1">{{ stack.name }}</span> - </gl-dropdown-item> - </gl-dropdown> - <span v-if="validationError" class="gl-field-error">{{ validationError }}</span> - <p class="form-text text-muted"> - {{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }} - <a - href="https://crossplane.io/docs/master/stacks-guide.html" - target="_blank" - rel="noopener noreferrer" - >{{ __('Crossplane') }} - <gl-icon name="external-link" class="vertical-align-middle" /> - </a> - </p> - </div> -</template> diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue deleted file mode 100644 index 89446680173..00000000000 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ /dev/null @@ -1,232 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlSprintf, - GlButton, - GlAlert, -} from '@gitlab/ui'; -import { APPLICATION_STATUS } from '~/clusters/constants'; -import { __, s__ } from '~/locale'; - -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; - -const { UPDATING, UNINSTALLING } = APPLICATION_STATUS; - -export default { - components: { - GlButton, - ClipboardButton, - GlLoadingIcon, - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - GlSearchBoxByType, - GlSprintf, - GlAlert, - }, - props: { - knative: { - type: Object, - required: true, - }, - ingressDnsHelpPath: { - type: String, - default: '', - required: false, - }, - }, - data() { - return { - searchQuery: '', - }; - }, - computed: { - saveButtonDisabled() { - return [UNINSTALLING, UPDATING].includes(this.knative.status); - }, - saving() { - return [UPDATING].includes(this.knative.status); - }, - saveButtonLabel() { - return this.saving ? __('Saving') : __('Save changes'); - }, - knativeInstalled() { - return this.knative.installed; - }, - knativeExternalEndpoint() { - return this.knative.externalIp || this.knative.externalHostname; - }, - knativeUpdateSuccessful() { - return this.knative.updateSuccessful; - }, - knativeHostname: { - get() { - return this.knative.hostname; - }, - set(hostname) { - this.selectCustomDomain(hostname); - }, - }, - domainDropdownText() { - return this.knativeHostname || s__('ClusterIntegration|Select existing domain or use new'); - }, - availableDomains() { - return this.knative.availableDomains || []; - }, - filteredDomains() { - const query = this.searchQuery.toLowerCase(); - return this.availableDomains.filter(({ domain }) => domain.toLowerCase().includes(query)); - }, - showDomainsDropdown() { - return this.availableDomains.length > 0; - }, - validationError() { - return this.knative.validationError; - }, - }, - watch: { - knativeUpdateSuccessful(updateSuccessful) { - if (updateSuccessful) { - this.$toast.show(s__('ClusterIntegration|Knative domain name was updated successfully.')); - } - }, - }, - methods: { - selectDomain({ id, domain }) { - this.$emit('set', { domain, domainId: id }); - }, - selectCustomDomain(domain) { - this.$emit('set', { domain, domainId: null }); - }, - }, -}; -</script> - -<template> - <div class="row"> - <gl-alert - v-if="knative.updateFailed" - class="gl-mb-5 col-12 js-cluster-knative-domain-name-failure-message" - variant="danger" - > - {{ s__('ClusterIntegration|Something went wrong while updating Knative domain name.') }} - </gl-alert> - - <div - :class="{ 'col-md-6': knativeInstalled, 'col-12': !knativeInstalled }" - class="form-group col-sm-12 mb-0" - > - <label for="knative-domainname"> - <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> - </label> - - <gl-dropdown - v-if="showDomainsDropdown" - :text="domainDropdownText" - toggle-class="dropdown-menu-toggle" - class="w-100 mb-2" - > - <gl-search-box-by-type - v-model.trim="searchQuery" - :placeholder="s__('ClusterIntegration|Search domains')" - /> - <gl-dropdown-item - v-for="domain in filteredDomains" - :key="domain.id" - @click="selectDomain(domain)" - > - <span class="ml-1">{{ domain.domain }}</span> - </gl-dropdown-item> - <template v-if="searchQuery"> - <gl-dropdown-divider /> - <gl-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)"> - <span class="ml-1"> - <gl-sprintf :message="s__('ClusterIntegration|Use %{query}')"> - <template #query> - <code>{{ searchQuery }}</code> - </template> - </gl-sprintf> - </span> - </gl-dropdown-item> - </template> - </gl-dropdown> - - <input - v-else - id="knative-domainname" - v-model="knativeHostname" - type="text" - class="form-control js-knative-domainname" - /> - - <span v-if="validationError" class="gl-field-error">{{ validationError }}</span> - </div> - - <template v-if="knativeInstalled"> - <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0"> - <label for="knative-endpoint"> - <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong> - </label> - <div v-if="knativeExternalEndpoint" class="input-group"> - <input - id="knative-endpoint" - :value="knativeExternalEndpoint" - type="text" - class="form-control js-knative-endpoint" - readonly - /> - <span class="input-group-append"> - <clipboard-button - :text="knativeExternalEndpoint" - :title="s__('ClusterIntegration|Copy Knative Endpoint')" - class="input-group-text js-knative-endpoint-clipboard-btn" - /> - </span> - </div> - <div v-else class="input-group"> - <input type="text" class="form-control js-endpoint" readonly /> - <gl-loading-icon - class="position-absolute align-self-center ml-2 js-knative-ip-loading-icon" - /> - </div> - </div> - - <p class="form-text text-muted col-12"> - {{ - s__( - `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`, - ) - }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{ - __('More information') - }}</a> - </p> - - <p - v-if="!knativeExternalEndpoint" - class="settings-message js-no-knative-endpoint-message mt-2 mr-3 mb-0 ml-3" - > - {{ - s__(`ClusterIntegration|The endpoint is in - the process of being assigned. Please check your Kubernetes - cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) - }} - </p> - - <gl-button - class="js-knative-save-domain-button gl-mt-5 gl-ml-5" - variant="success" - category="primary" - :loading="saving" - :disabled="saveButtonDisabled" - @click="$emit('save')" - > - {{ saveButtonLabel }} - </gl-button> - </template> - </div> -</template> diff --git a/app/assets/javascripts/clusters/components/uninstall_application_button.vue b/app/assets/javascripts/clusters/components/uninstall_application_button.vue deleted file mode 100644 index 73191d6d84d..00000000000 --- a/app/assets/javascripts/clusters/components/uninstall_application_button.vue +++ /dev/null @@ -1,36 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import { APPLICATION_STATUS } from '~/clusters/constants'; -import { __ } from '~/locale'; - -const { UPDATING, UNINSTALLING } = APPLICATION_STATUS; - -export default { - components: { - GlButton, - }, - props: { - status: { - type: String, - required: true, - }, - }, - computed: { - disabled() { - return [UNINSTALLING, UPDATING].includes(this.status); - }, - loading() { - return this.status === UNINSTALLING; - }, - label() { - return this.loading ? __('Uninstalling') : __('Uninstall'); - }, - }, -}; -</script> - -<template> - <gl-button :disabled="disabled" variant="default" :loading="loading"> - {{ label }} - </gl-button> -</template> diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue deleted file mode 100644 index 2a197e40b60..00000000000 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ /dev/null @@ -1,101 +0,0 @@ -<script> -import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click'; -import { sprintf, s__ } from '~/locale'; -import { - HELM, - INGRESS, - CERT_MANAGER, - PROMETHEUS, - RUNNER, - KNATIVE, - JUPYTER, - ELASTIC_STACK, -} from '../constants'; - -const CUSTOM_APP_WARNING_TEXT = { - [HELM]: sprintf( - s__( - 'ClusterIntegration|The associated Tiller pod will be deleted and cannot be restored. Your other applications will remain unaffected.', - ), - { - gitlabManagedAppsNamespace: '<code>gitlab-managed-apps</code>', - }, - false, - ), - [INGRESS]: s__( - 'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.', - ), - [CERT_MANAGER]: s__( - 'ClusterIntegration|The associated private key will be deleted and cannot be restored.', - ), - [PROMETHEUS]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'), - [RUNNER]: s__('ClusterIntegration|Any running pipelines will be canceled.'), - [KNATIVE]: s__( - 'ClusterIntegration|The associated IP and all deployed services will be deleted and cannot be restored. Uninstalling Knative will also remove Istio from your cluster. This will not effect any other applications.', - ), - [JUPYTER]: s__( - 'ClusterIntegration|All data not committed to GitLab will be deleted and cannot be restored.', - ), - [ELASTIC_STACK]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'), -}; - -export default { - components: { - GlModal, - }, - directives: { - SafeHtml, - }, - mixins: [trackUninstallButtonClickMixin], - props: { - application: { - type: String, - required: true, - }, - applicationTitle: { - type: String, - required: true, - }, - }, - computed: { - title() { - return sprintf(s__('ClusterIntegration|Uninstall %{appTitle}'), { - appTitle: this.applicationTitle, - }); - }, - warningText() { - return sprintf( - s__('ClusterIntegration|You are about to uninstall %{appTitle} from your cluster.'), - { - appTitle: this.applicationTitle, - }, - ); - }, - customAppWarningText() { - return CUSTOM_APP_WARNING_TEXT[this.application]; - }, - modalId() { - return `uninstall-${this.application}`; - }, - }, - methods: { - confirmUninstall() { - this.trackUninstallButtonClick(this.application); - this.$emit('confirm'); - }, - }, -}; -</script> -<template> - <gl-modal - ok-variant="danger" - cancel-variant="light" - :ok-title="title" - :modal-id="modalId" - :title="title" - @ok="confirmUninstall()" - > - {{ warningText }} <span v-safe-html="customAppWarningText"></span> - </gl-modal> -</template> diff --git a/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue deleted file mode 100644 index 0aedc6e84fa..00000000000 --- a/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue +++ /dev/null @@ -1,66 +0,0 @@ -<script> -/* eslint-disable vue/no-v-html */ -import { GlModal } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; -import { ELASTIC_STACK } from '../constants'; - -const CUSTOM_APP_WARNING_TEXT = { - [ELASTIC_STACK]: s__( - 'ClusterIntegration|Your Elasticsearch cluster will be re-created during this upgrade. Your logs will be re-indexed, and you will lose historical logs from hosts terminated in the last 30 days.', - ), -}; - -export default { - components: { - GlModal, - }, - props: { - application: { - type: String, - required: true, - }, - applicationTitle: { - type: String, - required: true, - }, - }, - computed: { - title() { - return sprintf(s__('ClusterIntegration|Update %{appTitle}'), { - appTitle: this.applicationTitle, - }); - }, - warningText() { - return sprintf( - s__('ClusterIntegration|You are about to update %{appTitle} on your cluster.'), - { - appTitle: this.applicationTitle, - }, - ); - }, - customAppWarningText() { - return CUSTOM_APP_WARNING_TEXT[this.application]; - }, - modalId() { - return `update-${this.application}`; - }, - }, - methods: { - confirmUpdate() { - this.$emit('confirm'); - }, - }, -}; -</script> -<template> - <gl-modal - ok-variant="danger" - cancel-variant="light" - :ok-title="title" - :modal-id="modalId" - :title="title" - @ok="confirmUpdate()" - > - {{ warningText }} <span v-html="customAppWarningText"></span> - </gl-modal> -</template> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 846e5950b8b..c6ca895778d 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -10,64 +10,7 @@ export const PROVIDER_TYPE = { GCP: 'gcp', }; -// These need to match what is returned from the server -export const APPLICATION_STATUS = { - NO_STATUS: null, - NOT_INSTALLABLE: 'not_installable', - INSTALLABLE: 'installable', - SCHEDULED: 'scheduled', - INSTALLING: 'installing', - INSTALLED: 'installed', - UPDATING: 'updating', - UPDATED: 'updated', - UPDATE_ERRORED: 'update_errored', - UNINSTALLING: 'uninstalling', - UNINSTALL_ERRORED: 'uninstall_errored', - ERROR: 'errored', - PRE_INSTALLED: 'pre_installed', - UNINSTALLED: 'uninstalled', - EXTERNALLY_INSTALLED: 'externally_installed', -}; - -/* - * The application cannot be in any of the following states without - * not being installed. - */ -export const APPLICATION_INSTALLED_STATUSES = [ - APPLICATION_STATUS.INSTALLED, - APPLICATION_STATUS.UPDATING, - APPLICATION_STATUS.UNINSTALLING, - APPLICATION_STATUS.PRE_INSTALLED, -]; - // These are only used client-side -export const UPDATE_EVENT = 'update'; -export const INSTALL_EVENT = 'install'; -export const UNINSTALL_EVENT = 'uninstall'; - -export const HELM = 'helm'; -export const INGRESS = 'ingress'; -export const JUPYTER = 'jupyter'; -export const KNATIVE = 'knative'; -export const RUNNER = 'runner'; -export const CERT_MANAGER = 'cert_manager'; -export const CROSSPLANE = 'crossplane'; -export const PROMETHEUS = 'prometheus'; -export const ELASTIC_STACK = 'elastic_stack'; - -export const APPLICATIONS = [ - HELM, - INGRESS, - JUPYTER, - KNATIVE, - RUNNER, - CERT_MANAGER, - PROMETHEUS, - ELASTIC_STACK, -]; - -export const INGRESS_DOMAIN_SUFFIX = '.nip.io'; - export const LOGGING_MODE = 'logging'; export const BLOCKING_MODE = 'blocking'; diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js deleted file mode 100644 index 2ff604af9a7..00000000000 --- a/app/assets/javascripts/clusters/services/application_state_machine.js +++ /dev/null @@ -1,250 +0,0 @@ -import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT, UNINSTALL_EVENT } from '../constants'; - -const { - NO_STATUS, - SCHEDULED, - NOT_INSTALLABLE, - INSTALLABLE, - INSTALLING, - INSTALLED, - ERROR, - UPDATING, - UPDATED, - UPDATE_ERRORED, - UNINSTALLING, - UNINSTALL_ERRORED, - PRE_INSTALLED, - UNINSTALLED, - EXTERNALLY_INSTALLED, -} = APPLICATION_STATUS; - -const applicationStateMachine = { - /* When the application initially loads, it will have `NO_STATUS` - * It will transition from `NO_STATUS` once the async backend call is completed - */ - [NO_STATUS]: { - on: { - [SCHEDULED]: { - target: INSTALLING, - }, - [NOT_INSTALLABLE]: { - target: NOT_INSTALLABLE, - }, - [INSTALLABLE]: { - target: INSTALLABLE, - }, - [INSTALLING]: { - target: INSTALLING, - }, - [INSTALLED]: { - target: INSTALLED, - }, - [ERROR]: { - target: INSTALLABLE, - effects: { - installFailed: true, - }, - }, - [UPDATING]: { - target: UPDATING, - }, - [UPDATED]: { - target: INSTALLED, - }, - [UPDATE_ERRORED]: { - target: INSTALLED, - effects: { - updateFailed: true, - }, - }, - [UNINSTALLING]: { - target: UNINSTALLING, - }, - [UNINSTALL_ERRORED]: { - target: INSTALLED, - effects: { - uninstallFailed: true, - }, - }, - [PRE_INSTALLED]: { - target: PRE_INSTALLED, - }, - [UNINSTALLED]: { - target: UNINSTALLED, - }, - [EXTERNALLY_INSTALLED]: { - target: EXTERNALLY_INSTALLED, - }, - }, - }, - [NOT_INSTALLABLE]: { - on: { - [INSTALLABLE]: { - target: INSTALLABLE, - }, - }, - }, - [INSTALLABLE]: { - on: { - [INSTALL_EVENT]: { - target: INSTALLING, - effects: { - installFailed: false, - }, - }, - [NOT_INSTALLABLE]: { - target: NOT_INSTALLABLE, - }, - [INSTALLED]: { - target: INSTALLED, - effects: { - installFailed: false, - }, - }, - [UNINSTALLED]: { - target: UNINSTALLED, - effects: { - installFailed: false, - }, - }, - }, - }, - [INSTALLING]: { - on: { - [INSTALLED]: { - target: INSTALLED, - }, - [ERROR]: { - target: INSTALLABLE, - effects: { - installFailed: true, - }, - }, - }, - }, - [INSTALLED]: { - on: { - [UPDATE_EVENT]: { - target: UPDATING, - effects: { - updateFailed: false, - updateSuccessful: false, - }, - }, - [NOT_INSTALLABLE]: { - target: NOT_INSTALLABLE, - }, - [UNINSTALL_EVENT]: { - target: UNINSTALLING, - effects: { - uninstallFailed: false, - uninstallSuccessful: false, - }, - }, - [UNINSTALLED]: { - target: UNINSTALLED, - }, - [ERROR]: { - target: INSTALLABLE, - effects: { - installFailed: true, - }, - }, - }, - }, - [PRE_INSTALLED]: { - on: { - [UPDATE_EVENT]: { - target: UPDATING, - effects: { - updateFailed: false, - updateSuccessful: false, - }, - }, - [NOT_INSTALLABLE]: { - target: NOT_INSTALLABLE, - }, - [UNINSTALL_EVENT]: { - target: UNINSTALLING, - effects: { - uninstallFailed: false, - uninstallSuccessful: false, - }, - }, - }, - }, - [UPDATING]: { - on: { - [UPDATED]: { - target: INSTALLED, - effects: { - updateSuccessful: true, - }, - }, - [UPDATE_ERRORED]: { - target: INSTALLED, - effects: { - updateFailed: true, - }, - }, - }, - }, - [UNINSTALLING]: { - on: { - [INSTALLABLE]: { - target: INSTALLABLE, - effects: { - uninstallSuccessful: true, - }, - }, - [NOT_INSTALLABLE]: { - target: NOT_INSTALLABLE, - effects: { - uninstallSuccessful: true, - }, - }, - [UNINSTALL_ERRORED]: { - target: INSTALLED, - effects: { - uninstallFailed: true, - }, - }, - }, - }, - [UNINSTALLED]: { - on: { - [INSTALLED]: { - target: INSTALLED, - }, - [ERROR]: { - target: INSTALLABLE, - effects: { - installFailed: true, - }, - }, - }, - }, -}; - -/** - * Determines an application new state based on the application current state - * and an event. If the application current state cannot handle a given event, - * the current state is returned. - * - * @param {*} application - * @param {*} event - */ -const transitionApplicationState = (application, event) => { - const stateMachine = applicationStateMachine[application.status]; - const newState = stateMachine !== undefined ? stateMachine.on[event] : false; - - return newState - ? { - ...application, - status: newState.target, - ...newState.effects, - } - : application; -}; - -export default transitionApplicationState; diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index 333fb293a15..7300bb3137a 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -3,38 +3,12 @@ import axios from '../../lib/utils/axios_utils'; export default class ClusterService { constructor(options = {}) { this.options = options; - this.appInstallEndpointMap = { - helm: this.options.installHelmEndpoint, - ingress: this.options.installIngressEndpoint, - cert_manager: this.options.installCertManagerEndpoint, - crossplane: this.options.installCrossplaneEndpoint, - runner: this.options.installRunnerEndpoint, - prometheus: this.options.installPrometheusEndpoint, - jupyter: this.options.installJupyterEndpoint, - knative: this.options.installKnativeEndpoint, - elastic_stack: this.options.installElasticStackEndpoint, - }; - this.appUpdateEndpointMap = { - knative: this.options.updateKnativeEndpoint, - }; } fetchClusterStatus() { return axios.get(this.options.endpoint); } - installApplication(appId, params) { - return axios.post(this.appInstallEndpointMap[appId], params); - } - - updateApplication(appId, params) { - return axios.patch(this.appUpdateEndpointMap[appId], params); - } - - uninstallApplication(appId, params) { - return axios.delete(this.appInstallEndpointMap[appId], params); - } - fetchClusterEnvironments() { return axios.get(this.options.clusterEnvironmentsEndpoint); } diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 50689a6142f..db6e7bad6cc 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -1,112 +1,16 @@ import { parseBoolean } from '../../lib/utils/common_utils'; -import { s__ } from '../../locale'; -import { - INGRESS, - JUPYTER, - KNATIVE, - CERT_MANAGER, - CROSSPLANE, - RUNNER, - APPLICATION_INSTALLED_STATUSES, - APPLICATION_STATUS, - INSTALL_EVENT, - UPDATE_EVENT, - UNINSTALL_EVENT, - ELASTIC_STACK, -} from '../constants'; -import transitionApplicationState from '../services/application_state_machine'; - -const isApplicationInstalled = (appStatus) => APPLICATION_INSTALLED_STATUSES.includes(appStatus); - -const applicationInitialState = { - status: null, - statusReason: null, - requestReason: null, - installable: true, - installed: false, - installFailed: false, - uninstallable: false, - uninstallFailed: false, - uninstallSuccessful: false, - validationError: null, -}; export default class ClusterStore { constructor() { this.state = { helpPath: null, - helmHelpPath: null, - ingressHelpPath: null, environmentsHelpPath: null, clustersHelpPath: null, deployBoardsHelpPath: null, - cloudRunHelpPath: null, status: null, providerType: null, - preInstalledKnative: false, rbac: false, statusReason: null, - applications: { - helm: { - ...applicationInitialState, - title: s__('ClusterIntegration|Legacy Helm Tiller server'), - }, - ingress: { - ...applicationInitialState, - title: s__('ClusterIntegration|Ingress'), - externalIp: null, - externalHostname: null, - updateFailed: false, - updateAvailable: false, - }, - cert_manager: { - ...applicationInitialState, - title: s__('ClusterIntegration|Cert-Manager'), - email: null, - }, - crossplane: { - ...applicationInitialState, - title: s__('ClusterIntegration|Crossplane'), - stack: null, - }, - runner: { - ...applicationInitialState, - title: s__('ClusterIntegration|GitLab Runner'), - version: null, - chartRepo: 'https://gitlab.com/gitlab-org/charts/gitlab-runner', - updateAvailable: null, - updateSuccessful: false, - updateFailed: false, - }, - prometheus: { - ...applicationInitialState, - title: s__('ClusterIntegration|Prometheus'), - }, - jupyter: { - ...applicationInitialState, - title: s__('ClusterIntegration|JupyterHub'), - hostname: null, - }, - knative: { - ...applicationInitialState, - title: s__('ClusterIntegration|Knative'), - hostname: null, - isEditingDomain: false, - externalIp: null, - externalHostname: null, - updateSuccessful: false, - updateFailed: false, - }, - elastic_stack: { - ...applicationInitialState, - title: s__('ClusterIntegration|Elastic Stack'), - }, - cilium: { - ...applicationInitialState, - title: s__('ClusterIntegration|GitLab Container Network Policies'), - installable: false, - }, - }, environments: [], fetchingEnvironments: false, }; @@ -118,10 +22,6 @@ export default class ClusterStore { }); } - setManagePrometheusPath(managePrometheusPath) { - this.state.managePrometheusPath = managePrometheusPath; - } - updateStatus(status) { this.state.status = status; } @@ -130,10 +30,6 @@ export default class ClusterStore { this.state.providerType = providerType; } - updatePreInstalledKnative(preInstalledKnative) { - this.state.preInstalledKnative = parseBoolean(preInstalledKnative); - } - updateRbac(rbac) { this.state.rbac = parseBoolean(rbac); } @@ -142,112 +38,9 @@ export default class ClusterStore { this.state.statusReason = reason; } - installApplication(appId) { - this.handleApplicationEvent(appId, INSTALL_EVENT); - } - - notifyInstallFailure(appId) { - this.handleApplicationEvent(appId, APPLICATION_STATUS.ERROR); - } - - updateApplication(appId) { - this.handleApplicationEvent(appId, UPDATE_EVENT); - } - - notifyUpdateFailure(appId) { - this.handleApplicationEvent(appId, APPLICATION_STATUS.UPDATE_ERRORED); - } - - uninstallApplication(appId) { - this.handleApplicationEvent(appId, UNINSTALL_EVENT); - } - - notifyUninstallFailure(appId) { - this.handleApplicationEvent(appId, APPLICATION_STATUS.UNINSTALL_ERRORED); - } - - handleApplicationEvent(appId, event) { - const currentAppState = this.state.applications[appId]; - - this.state.applications[appId] = transitionApplicationState(currentAppState, event); - } - - updateAppProperty(appId, prop, value) { - this.state.applications[appId][prop] = value; - } - updateStateFromServer(serverState = {}) { this.state.status = serverState.status; this.state.statusReason = serverState.status_reason; - - serverState.applications.forEach((serverAppEntry) => { - const { - name: appId, - status, - status_reason: statusReason, - version, - update_available: updateAvailable, - can_uninstall: uninstallable, - } = serverAppEntry; - const currentApplicationState = this.state.applications[appId] || {}; - const nextApplicationState = transitionApplicationState(currentApplicationState, status); - - this.state.applications[appId] = { - ...currentApplicationState, - ...nextApplicationState, - statusReason, - installed: isApplicationInstalled(nextApplicationState.status), - uninstallable, - }; - - if (appId === INGRESS) { - this.state.applications.ingress.externalIp = serverAppEntry.external_ip; - this.state.applications.ingress.externalHostname = serverAppEntry.external_hostname; - this.state.applications.ingress.updateAvailable = updateAvailable; - } else if (appId === CERT_MANAGER) { - this.state.applications.cert_manager.email = - this.state.applications.cert_manager.email || serverAppEntry.email; - } else if (appId === CROSSPLANE) { - this.state.applications.crossplane.stack = - this.state.applications.crossplane.stack || serverAppEntry.stack; - } else if (appId === JUPYTER) { - this.state.applications.jupyter.hostname = this.updateHostnameIfUnset( - this.state.applications.jupyter.hostname, - serverAppEntry.hostname, - 'jupyter', - ); - } else if (appId === KNATIVE) { - if (serverAppEntry.available_domains) { - this.state.applications.knative.availableDomains = serverAppEntry.available_domains; - } - if (!this.state.applications.knative.isEditingDomain) { - this.state.applications.knative.pagesDomain = - serverAppEntry.pages_domain || this.state.applications.knative.pagesDomain; - this.state.applications.knative.hostname = - serverAppEntry.hostname || this.state.applications.knative.hostname; - } - this.state.applications.knative.externalIp = - serverAppEntry.external_ip || this.state.applications.knative.externalIp; - this.state.applications.knative.externalHostname = - serverAppEntry.external_hostname || this.state.applications.knative.externalHostname; - } else if (appId === RUNNER) { - this.state.applications.runner.version = version; - this.state.applications.runner.updateAvailable = updateAvailable; - } else if (appId === ELASTIC_STACK) { - this.state.applications.elastic_stack.version = version; - this.state.applications.elastic_stack.updateAvailable = updateAvailable; - } - }); - } - - updateHostnameIfUnset(current, updated, fallback) { - return ( - current || - updated || - (this.state.applications.ingress.externalIp - ? `${fallback}.${this.state.applications.ingress.externalIp}.nip.io` - : '') - ); } toggleFetchEnvironments(isFetching) { diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 40a86a1e58c..5f35a0b26f3 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/browser'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; @@ -64,7 +64,9 @@ export const fetchClusters = ({ state, commit, dispatch }) => { commit(types.SET_LOADING_CLUSTERS, false); commit(types.SET_LOADING_NODES, false); - flash(__('Clusters|An error occurred while loading clusters')); + createFlash({ + message: __('Clusters|An error occurred while loading clusters'), + }); dispatch('reportSentryError', { error: response, tag: 'fetchClustersErrorCallback' }); }, diff --git a/app/assets/javascripts/code_quality_walkthrough/utils.js b/app/assets/javascripts/code_quality_walkthrough/utils.js index 97c80f6eff7..894ec9a171d 100644 --- a/app/assets/javascripts/code_quality_walkthrough/utils.js +++ b/app/assets/javascripts/code_quality_walkthrough/utils.js @@ -1,6 +1,7 @@ import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; import { getExperimentData } from '~/experimentation/utils'; -import { setCookie, getCookie, getParameterByName } from '~/lib/utils/common_utils'; +import { setCookie, getCookie } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; import { EXPERIMENT_NAME } from './constants'; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index 29ad6cc4125..8d88b682df2 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import CommitPipelinesTable from './pipelines_table.vue'; /** * Used in: @@ -14,25 +13,24 @@ export default () => { if (pipelineTableViewEl) { // Update MR and Commits tabs pipelineTableViewEl.addEventListener('update-pipelines-count', (event) => { - if ( - event.detail.pipelines && - event.detail.pipelines.count && - event.detail.pipelines.count.all - ) { + if (event.detail.pipelineCount) { const badge = document.querySelector('.js-pipelines-mr-count'); - badge.textContent = event.detail.pipelines.count.all; + badge.textContent = event.detail.pipelineCount; } }); if (pipelineTableViewEl.dataset.disableInitialization === undefined) { const table = new Vue({ + components: { + CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'), + }, provide: { artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint, artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder, }, render(createElement) { - return createElement(CommitPipelinesTable, { + return createElement('commit-pipelines-table', { props: { endpoint: pipelineTableViewEl.dataset.endpoint, emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index ddca5bc7d4f..42d46dc3d5d 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui'; -import { getParameterByName } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; import eventHub from '~/pipelines/event_hub'; import PipelinesMixin from '~/pipelines/mixins/pipelines_mixin'; @@ -133,15 +133,15 @@ export default { this.store.storePagination(resp.headers); this.setCommonData(pipelines); - const updatePipelinesEvent = new CustomEvent('update-pipelines-count', { - detail: { - pipelines: resp.data, - }, - }); + if (resp.headers?.['x-total']) { + const updatePipelinesEvent = new CustomEvent('update-pipelines-count', { + detail: { pipelineCount: resp.headers['x-total'] }, + }); - // notifiy to update the count in tabs - if (this.$el.parentElement) { - this.$el.parentElement.dispatchEvent(updatePipelinesEvent); + // notifiy to update the count in tabs + if (this.$el.parentElement) { + this.$el.parentElement.dispatchEvent(updatePipelinesEvent); + } } }, /** @@ -251,7 +251,7 @@ export default { }} </p> <gl-link - href="/help/ci/merge_request_pipelines/index.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project" + href="/help/ci/pipelines/merge_request_pipelines.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project" target="_blank" > {{ s__('Pipelines|More Information') }} diff --git a/app/assets/javascripts/commit_merge_requests.js b/app/assets/javascripts/commit_merge_requests.js index e382356841c..f973bf51b57 100644 --- a/app/assets/javascripts/commit_merge_requests.js +++ b/app/assets/javascripts/commit_merge_requests.js @@ -1,6 +1,5 @@ -/* global Flash */ - import $ from 'jquery'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { n__, s__ } from './locale'; @@ -71,5 +70,9 @@ export function fetchCommitMergeRequests() { $container.html($content); }) - .catch(() => Flash(s__('Commits|An error occurred while fetching merge requests data.'))); + .catch(() => + createFlash({ + message: s__('Commits|An error occurred while fetching merge requests data.'), + }), + ); } diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index da7fc88d8ac..39dc4a4e9e5 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -93,7 +93,7 @@ export default class CommitsList { .text(n__('%d commit', '%d commits', commitsCount)); } - localTimeAgo($processedData.find('.js-timeago')); + localTimeAgo($processedData.find('.js-timeago').get()); return processedData; } 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 6b07b7e3772..5f778af1dbb 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,7 +1,7 @@ <script> import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import createFlash from '~/flash'; import Api from '../../api'; -import createFlash from '../../flash'; import { __ } from '../../locale'; import state from '../state'; import Dropdown from './dropdown.vue'; diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index c6ab2e189ef..9a51def7075 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -1,10 +1,12 @@ <script> +import { GlAlert } from '@gitlab/ui'; import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; import { ContentEditor } from '../services/content_editor'; import TopToolbar from './top_toolbar.vue'; export default { components: { + GlAlert, TiptapEditorContent, TopToolbar, }, @@ -14,15 +16,30 @@ export default { required: true, }, }, + data() { + return { + error: '', + }; + }, + mounted() { + this.contentEditor.tiptapEditor.on('error', (error) => { + this.error = error; + }); + }, }; </script> <template> - <div - data-testid="content-editor" - class="md-area" - :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }" - > - <top-toolbar class="gl-mb-4" :content-editor="contentEditor" /> - <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> + <div> + <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="error = ''"> + {{ error }} + </gl-alert> + <div + data-testid="content-editor" + class="md-area" + :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }" + > + <top-toolbar ref="toolbar" class="gl-mb-4" :content-editor="contentEditor" /> + <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> + </div> </div> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue new file mode 100644 index 00000000000..ebeee16dbec --- /dev/null +++ b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue @@ -0,0 +1,110 @@ +<script> +import { + GlDropdown, + GlDropdownForm, + GlButton, + GlFormInputGroup, + GlDropdownDivider, + GlDropdownItem, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { Editor as TiptapEditor } from '@tiptap/vue-2'; +import { acceptedMimes } from '../extensions/image'; +import { getImageAlt } from '../services/utils'; + +export default { + components: { + GlDropdown, + GlDropdownForm, + GlFormInputGroup, + GlDropdownDivider, + GlDropdownItem, + GlButton, + }, + directives: { + GlTooltip, + }, + props: { + tiptapEditor: { + type: TiptapEditor, + required: true, + }, + }, + data() { + return { + imgSrc: '', + }; + }, + methods: { + resetFields() { + this.imgSrc = ''; + this.$refs.fileSelector.value = ''; + }, + insertImage() { + this.tiptapEditor + .chain() + .focus() + .setImage({ + src: this.imgSrc, + canonicalSrc: this.imgSrc, + alt: getImageAlt(this.imgSrc), + }) + .run(); + + this.resetFields(); + this.emitExecute(); + }, + emitExecute(source = 'url') { + this.$emit('execute', { contentType: 'image', value: source }); + }, + openFileUpload() { + this.$refs.fileSelector.click(); + }, + onFileSelect(e) { + this.tiptapEditor + .chain() + .focus() + .uploadImage({ + file: e.target.files[0], + }) + .run(); + + this.resetFields(); + this.emitExecute('upload'); + }, + }, + acceptedMimes, +}; +</script> +<template> + <gl-dropdown + v-gl-tooltip + :aria-label="__('Insert image')" + :title="__('Insert image')" + size="small" + category="tertiary" + icon="media" + @hidden="resetFields()" + > + <gl-dropdown-form class="gl-px-3!"> + <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')"> + <template #append> + <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button> + </template> + </gl-form-input-group> + </gl-dropdown-form> + <gl-dropdown-divider /> + <gl-dropdown-item @click="openFileUpload"> + {{ __('Upload image') }} + </gl-dropdown-item> + + <input + ref="fileSelector" + type="file" + name="content_editor_image" + :accept="$options.acceptedMimes" + class="gl-display-none" + @change="onFileSelect" + /> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue index f706080eaa1..8f57959a73f 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue @@ -43,14 +43,22 @@ export default { }, mounted() { this.tiptapEditor.on('selectionUpdate', ({ editor }) => { - const { href } = editor.getAttributes(linkContentType); + const { canonicalSrc, href } = editor.getAttributes(linkContentType); - this.linkHref = href; + this.linkHref = canonicalSrc || href; }); }, methods: { updateLink() { - this.tiptapEditor.chain().focus().unsetLink().setLink({ href: this.linkHref }).run(); + this.tiptapEditor + .chain() + .focus() + .unsetLink() + .setLink({ + href: this.linkHref, + canonicalSrc: this.linkHref, + }) + .run(); this.$emit('execute', { contentType: linkContentType }); }, diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue new file mode 100644 index 00000000000..49d3006e9bf --- /dev/null +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -0,0 +1,91 @@ +<script> +import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui'; +import { Editor as TiptapEditor } from '@tiptap/vue-2'; +import { __, sprintf } from '~/locale'; +import { clamp } from '../services/utils'; + +export const tableContentType = 'table'; + +const MIN_ROWS = 3; +const MIN_COLS = 3; +const MAX_ROWS = 8; +const MAX_COLS = 8; + +export default { + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownForm, + GlButton, + }, + props: { + tiptapEditor: { + type: TiptapEditor, + required: true, + }, + }, + data() { + return { + maxRows: MIN_ROWS, + maxCols: MIN_COLS, + rows: 1, + cols: 1, + }; + }, + methods: { + list(n) { + return new Array(n).fill().map((_, i) => i + 1); + }, + setRowsAndCols(rows, cols) { + this.rows = rows; + this.cols = cols; + this.maxRows = clamp(rows + 1, MIN_ROWS, MAX_ROWS); + this.maxCols = clamp(cols + 1, MIN_COLS, MAX_COLS); + }, + resetState() { + this.rows = 1; + this.cols = 1; + }, + insertTable() { + this.tiptapEditor + .chain() + .focus() + .insertTable({ + rows: this.rows, + cols: this.cols, + withHeaderRow: true, + }) + .run(); + + this.resetState(); + + this.$emit('execute', { contentType: 'table' }); + }, + getButtonLabel(rows, cols) { + return sprintf(__('Insert a %{rows}x%{cols} table.'), { rows, cols }); + }, + }, +}; +</script> +<template> + <gl-dropdown size="small" category="tertiary" icon="table"> + <gl-dropdown-form class="gl-px-3! gl-w-auto!"> + <div class="gl-w-auto!"> + <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex"> + <gl-button + v-for="c of list(maxCols)" + :key="c" + :data-testid="`table-${r}-${c}`" + :class="{ 'gl-bg-blue-50!': r <= rows && c <= cols }" + :aria-label="getButtonLabel(r, c)" + class="gl-display-inline! gl-px-0! gl-w-5! gl-h-5! gl-rounded-0!" + @mouseover="setRowsAndCols(r, c)" + @click="insertTable()" + /> + </div> + <gl-dropdown-divider /> + {{ getButtonLabel(rows, cols) }} + </div> + </gl-dropdown-form> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index d3363ce092b..fafc7a660e7 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -4,7 +4,9 @@ import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from ' import { ContentEditor } from '../services/content_editor'; import Divider from './divider.vue'; import ToolbarButton from './toolbar_button.vue'; +import ToolbarImageButton from './toolbar_image_button.vue'; import ToolbarLinkButton from './toolbar_link_button.vue'; +import ToolbarTableButton from './toolbar_table_button.vue'; import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue'; const trackingMixin = Tracking.mixin({ @@ -16,6 +18,8 @@ export default { ToolbarButton, ToolbarTextStyleDropdown, ToolbarLinkButton, + ToolbarTableButton, + ToolbarImageButton, Divider, }, mixins: [trackingMixin], @@ -87,6 +91,12 @@ export default { @execute="trackToolbarControlExecution" /> <divider /> + <toolbar-image-button + ref="imageButton" + data-testid="image" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> <toolbar-button data-testid="blockquote" content-type="blockquote" @@ -123,5 +133,23 @@ export default { :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> + <toolbar-button + data-testid="horizontal-rule" + content-type="horizontalRule" + icon-name="dash" + editor-command="setHorizontalRule" + :label="__('Add a horizontal rule')" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> + <toolbar-table-button + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> </div> </template> +<style> +.gl-spinner-container { + text-align: left; +} +</style> diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue new file mode 100644 index 00000000000..3762324a431 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue @@ -0,0 +1,31 @@ +<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" + :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/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js index dc1ba431151..756eefa875c 100644 --- a/app/assets/javascripts/content_editor/extensions/hard_break.js +++ b/app/assets/javascripts/content_editor/extensions/hard_break.js @@ -1,5 +1,13 @@ import { HardBreak } from '@tiptap/extension-hard-break'; import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; -export const tiptapExtension = HardBreak; +const ExtendedHardBreak = HardBreak.extend({ + addKeyboardShortcuts() { + return { + 'Shift-Enter': () => this.editor.commands.setHardBreak(), + }; + }, +}); + +export const tiptapExtension = ExtendedHardBreak; export const serializer = defaultMarkdownSerializer.nodes.hard_break; diff --git a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js index dcc59476518..c287938af5c 100644 --- a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js +++ b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js @@ -1,5 +1,12 @@ +import { nodeInputRule } from '@tiptap/core'; import { HorizontalRule } from '@tiptap/extension-horizontal-rule'; import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; -export const tiptapExtension = HorizontalRule; +export const hrInputRuleRegExp = /^---$/; + +export const tiptapExtension = HorizontalRule.extend({ + addInputRules() { + return [nodeInputRule(hrInputRuleRegExp, this.type)]; + }, +}); export const serializer = defaultMarkdownSerializer.nodes.horizontal_rule; diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 287216e68d5..4dd8a1376ad 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,10 +1,65 @@ import { Image } from '@tiptap/extension-image'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { __ } from '~/locale'; +import ImageWrapper from '../components/wrappers/image.vue'; +import { uploadFile } from '../services/upload_file'; +import { getImageAlt, readFileAsDataURL } from '../services/utils'; + +export const acceptedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg']; + +const resolveImageEl = (element) => + element.nodeName === 'IMG' ? element : element.querySelector('img'); + +const startFileUpload = async ({ editor, file, uploadsPath, renderMarkdown }) => { + const encodedSrc = await readFileAsDataURL(file); + const { view } = editor; + + editor.commands.setImage({ uploading: true, src: encodedSrc }); + + const { state } = view; + const position = state.selection.from - 1; + const { tr } = state; + + try { + const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown }); + + view.dispatch( + tr.setNodeMarkup(position, undefined, { + uploading: false, + src: encodedSrc, + alt: getImageAlt(src), + canonicalSrc, + }), + ); + } catch (e) { + editor.commands.deleteRange({ from: position, to: position + 1 }); + editor.emit('error', __('An error occurred while uploading the image. Please try again.')); + } +}; + +const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => { + if (acceptedMimes.includes(file?.type)) { + startFileUpload({ editor, file, uploadsPath, renderMarkdown }); + + return true; + } + + return false; +}; const ExtendedImage = Image.extend({ + defaultOptions: { + ...Image.options, + uploadsPath: null, + renderMarkdown: null, + }, addAttributes() { return { ...this.parent?.(), + uploading: { + default: false, + }, src: { default: null, /* @@ -14,17 +69,25 @@ const ExtendedImage = Image.extend({ * attribute. */ parseHTML: (element) => { - const img = element.querySelector('img'); + const img = resolveImageEl(element); return { src: img.dataset.src || img.getAttribute('src'), }; }, }, + canonicalSrc: { + default: null, + parseHTML: (element) => { + return { + canonicalSrc: element.dataset.canonicalSrc, + }; + }, + }, alt: { default: null, parseHTML: (element) => { - const img = element.querySelector('img'); + const img = resolveImageEl(element); return { alt: img.getAttribute('alt'), @@ -44,7 +107,62 @@ const ExtendedImage = Image.extend({ }, ]; }, -}).configure({ inline: true }); + addCommands() { + return { + ...this.parent(), + uploadImage: ({ file }) => () => { + const { uploadsPath, renderMarkdown } = this.options; + + handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor }); + }, + }; + }, + addProseMirrorPlugins() { + const { editor } = this; + + return [ + new Plugin({ + key: new PluginKey('handleDropAndPasteImages'), + props: { + handlePaste: (_, event) => { + const { uploadsPath, renderMarkdown } = this.options; + + return handleFileEvent({ + editor, + file: event.clipboardData.files[0], + uploadsPath, + renderMarkdown, + }); + }, + handleDrop: (_, event) => { + const { uploadsPath, renderMarkdown } = this.options; + + return handleFileEvent({ + editor, + file: event.dataTransfer.files[0], + uploadsPath, + renderMarkdown, + }); + }, + }, + }), + ]; + }, + addNodeView() { + return VueNodeViewRenderer(ImageWrapper); + }, +}); + +const serializer = (state, node) => { + const { alt, canonicalSrc, src, title } = node.attrs; + const quotedTitle = title ? ` ${state.quote(title)}` : ''; + + state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); +}; -export const tiptapExtension = ExtendedImage; -export const serializer = defaultMarkdownSerializer.nodes.image; +export const configure = ({ renderMarkdown, uploadsPath }) => { + return { + tiptapExtension: ExtendedImage.configure({ inline: true, renderMarkdown, uploadsPath }), + serializer, + }; +}; diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index 6f5f81cbf93..12019ab4636 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -1,9 +1,7 @@ import { markInputRule } from '@tiptap/core'; import { Link } from '@tiptap/extension-link'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; export const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm; - export const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim; const extractHrefFromMatch = (match) => { @@ -29,8 +27,37 @@ export const tiptapExtension = Link.extend({ markInputRule(urlSyntaxRegExp, this.type, extractHrefFromMatch), ]; }, + addAttributes() { + return { + ...this.parent?.(), + href: { + default: null, + parseHTML: (element) => { + return { + href: element.getAttribute('href'), + }; + }, + }, + canonicalSrc: { + default: null, + parseHTML: (element) => { + return { + canonicalSrc: element.dataset.canonicalSrc, + }; + }, + }, + }; + }, }).configure({ openOnClick: false, }); -export const serializer = defaultMarkdownSerializer.marks.link; +export const serializer = { + open() { + return '['; + }, + close(state, mark) { + const href = mark.attrs.canonicalSrc || mark.attrs.href; + return `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; + }, +}; diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js new file mode 100644 index 00000000000..566f7a21a85 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/table.js @@ -0,0 +1,7 @@ +import { Table } from '@tiptap/extension-table'; + +export const tiptapExtension = Table; + +export function serializer(state, node) { + state.renderContent(node); +} diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js new file mode 100644 index 00000000000..6c25b867466 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/table_cell.js @@ -0,0 +1,9 @@ +import { TableCell } from '@tiptap/extension-table-cell'; + +export const tiptapExtension = TableCell.extend({ + content: 'inline*', +}); + +export function serializer(state, node) { + state.renderInline(node); +} diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js new file mode 100644 index 00000000000..3475857b9e6 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/table_header.js @@ -0,0 +1,9 @@ +import { TableHeader } from '@tiptap/extension-table-header'; + +export const tiptapExtension = TableHeader.extend({ + content: 'inline*', +}); + +export function serializer(state, node) { + state.renderInline(node); +} diff --git a/app/assets/javascripts/content_editor/extensions/table_row.js b/app/assets/javascripts/content_editor/extensions/table_row.js new file mode 100644 index 00000000000..07d2eb4faa2 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/table_row.js @@ -0,0 +1,51 @@ +import { TableRow } from '@tiptap/extension-table-row'; + +export const tiptapExtension = TableRow.extend({ + allowGapCursor: false, +}); + +export function serializer(state, node) { + const isHeaderRow = node.child(0).type.name === 'tableHeader'; + + const renderRow = () => { + const cellWidths = []; + + state.flushClose(1); + + state.write('| '); + node.forEach((cell, _, i) => { + if (i) state.write(' | '); + + const { length } = state.out; + state.render(cell, node, i); + cellWidths.push(state.out.length - length); + }); + state.write(' |'); + + state.closeBlock(node); + + return cellWidths; + }; + + const renderHeaderRow = (cellWidths) => { + state.flushClose(1); + + state.write('|'); + node.forEach((cell, _, i) => { + if (i) state.write('|'); + + state.write(cell.attrs.align === 'center' ? ':' : '-'); + state.write(state.repeat('-', cellWidths[i])); + state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-'); + }); + state.write('|'); + + state.closeBlock(node); + }; + + if (isHeaderRow) { + renderHeaderRow(renderRow()); + } else { + renderRow(); + } +} 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 8a54da6f57d..9251fdbbdc5 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -20,35 +20,16 @@ import * as ListItem from '../extensions/list_item'; import * as OrderedList from '../extensions/ordered_list'; import * as Paragraph from '../extensions/paragraph'; import * as Strike from '../extensions/strike'; +import * as Table from '../extensions/table'; +import * as TableCell from '../extensions/table_cell'; +import * as TableHeader from '../extensions/table_header'; +import * as TableRow from '../extensions/table_row'; import * as Text from '../extensions/text'; import buildSerializerConfig from './build_serializer_config'; import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; -const builtInContentEditorExtensions = [ - Blockquote, - Bold, - BulletList, - Code, - CodeBlockHighlight, - Document, - Dropcursor, - Gapcursor, - HardBreak, - Heading, - History, - HorizontalRule, - Image, - Italic, - Link, - ListItem, - OrderedList, - Paragraph, - Strike, - Text, -]; - const collectTiptapExtensions = (extensions = []) => extensions.map(({ tiptapExtension }) => tiptapExtension); @@ -63,11 +44,43 @@ const createTiptapEditor = ({ extensions = [], ...options } = {}) => ...options, }); -export const createContentEditor = ({ renderMarkdown, extensions = [], tiptapOptions } = {}) => { +export const createContentEditor = ({ + renderMarkdown, + uploadsPath, + extensions = [], + tiptapOptions, +} = {}) => { if (!isFunction(renderMarkdown)) { throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); } + const builtInContentEditorExtensions = [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlockHighlight, + Document, + Dropcursor, + Gapcursor, + HardBreak, + Heading, + History, + HorizontalRule, + Image.configure({ uploadsPath, renderMarkdown }), + Italic, + Link, + ListItem, + OrderedList, + Paragraph, + Strike, + TableCell, + TableHeader, + TableRow, + Table, + Text, + ]; + const allExtensions = [...builtInContentEditorExtensions, ...extensions]; const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts); const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions }); diff --git a/app/assets/javascripts/content_editor/services/upload_file.js b/app/assets/javascripts/content_editor/services/upload_file.js new file mode 100644 index 00000000000..613c53144a1 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/upload_file.js @@ -0,0 +1,44 @@ +import axios from '~/lib/utils/axios_utils'; + +const extractAttachmentLinkUrl = (html) => { + const parser = new DOMParser(); + const { body } = parser.parseFromString(html, 'text/html'); + const link = body.querySelector('a'); + const src = link.getAttribute('href'); + const { canonicalSrc } = link.dataset; + + return { src, canonicalSrc }; +}; + +/** + * Uploads a file with a post request to the URL indicated + * in the uploadsPath parameter. The expected response of the + * uploads service is a JSON object that contains, at least, a + * link property. The link property should contain markdown link + * definition (i.e. [GitLab](https://gitlab.com)). + * + * This Markdown will be rendered to extract its canonical and full + * URLs using GitLab Flavored Markdown renderer in the backend. + * + * @param {Object} params + * @param {String} params.uploadsPath An absolute URL that points to a service + * that allows sending a file for uploading via POST request. + * @param {String} params.renderMarkdown A function that accepts a markdown string + * and returns a rendered version in HTML format. + * @param {File} params.file The file to upload + * + * @returns Returns an object with two properties: + * + * canonicalSrc: The URL as defined in the Markdown + * src: The absolute URL that points to the resource in the server + */ +export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => { + const formData = new FormData(); + formData.append('file', file, file.name); + + const { data } = await axios.post(uploadsPath, formData); + const { markdown } = data.link; + const rendered = await renderMarkdown(markdown); + + return extractAttachmentLinkUrl(rendered); +}; diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js index cf5234bbff8..2a2c7f617da 100644 --- a/app/assets/javascripts/content_editor/services/utils.js +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -3,3 +3,17 @@ export const hasSelection = (tiptapEditor) => { return from < to; }; + +export const getImageAlt = (src) => { + return src.replace(/^.*\/|\..*$/g, '').replace(/\W+/g, ' '); +}; + +export const readFileAsDataURL = (file) => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.addEventListener('load', (e) => resolve(e.target.result), { once: true }); + reader.readAsDataURL(file); + }); +}; + +export const clamp = (n, min, max) => Math.max(Math.min(n, max), min); diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index 25ce6500094..512f060e2ea 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -204,15 +204,16 @@ export default { <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4> <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> <resizable-chart-container> - <gl-area-chart - slot-scope="{ width }" - class="gl-mb-5" - :width="width" - :data="masterChartData" - :option="masterChartOptions" - :height="masterChartHeight" - @created="onMasterChartCreated" - /> + <template #default="{ width }"> + <gl-area-chart + class="gl-mb-5" + :width="width" + :data="masterChartData" + :option="masterChartOptions" + :height="masterChartHeight" + @created="onMasterChartCreated" + /> + </template> </resizable-chart-container> <div class="row"> @@ -226,14 +227,15 @@ export default { {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }}) </p> <resizable-chart-container> - <gl-area-chart - slot-scope="{ width }" - :width="width" - :data="contributor.dates" - :option="individualChartOptions" - :height="individualChartHeight" - @created="onIndividualChartCreated" - /> + <template #default="{ width }"> + <gl-area-chart + :width="width" + :data="contributor.dates" + :option="individualChartOptions" + :height="individualChartHeight" + @created="onIndividualChartCreated" + /> + </template> </resizable-chart-container> </div> </div> diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js index 72aae3af692..4cc0a6a6509 100644 --- a/app/assets/javascripts/contributors/stores/actions.js +++ b/app/assets/javascripts/contributors/stores/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import service from '../services/contributors_service'; import * as types from './mutation_types'; @@ -13,5 +13,9 @@ export const fetchChartData = ({ commit }, endpoint) => { commit(types.SET_CHART_DATA, data); commit(types.SET_LOADING_STATE, false); }) - .catch(() => flash(__('An error occurred while loading chart data'))); + .catch(() => + createFlash({ + message: __('An error occurred while loading chart data'), + }), + ); }; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue index 6b18455bfcc..23c477bfbfd 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue @@ -95,7 +95,7 @@ export default { </li> </ul> </div> - <div class="dropdown-loading"><gl-loading-icon /></div> + <div class="dropdown-loading"><gl-loading-icon size="sm" /></div> </div> </div> <span diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue index b6f0bdbf01d..aba6dd4b493 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue @@ -160,7 +160,7 @@ export default { </li> </ul> </div> - <div class="dropdown-loading"><gl-loading-icon /></div> + <div class="dropdown-loading"><gl-loading-icon size="sm" /></div> </div> </div> <span diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue index daab42c7e60..027ce74753e 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue @@ -84,7 +84,7 @@ export default { </li> </ul> </div> - <div class="dropdown-loading"><gl-loading-icon /></div> + <div class="dropdown-loading"><gl-loading-icon size="sm" /></div> </div> </div> <span diff --git a/app/assets/javascripts/create_cluster/gke_cluster/index.js b/app/assets/javascripts/create_cluster/gke_cluster/index.js index 4eafbdb7265..3a42b460e1c 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/index.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue'; import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue'; import GkeSubmitButton from './components/gke_submit_button.vue'; @@ -59,7 +59,9 @@ const mountGkeSubmitButton = () => { }; const gkeDropdownErrorHandler = () => { - Flash(CONSTANTS.GCP_API_ERROR); + createFlash({ + message: CONSTANTS.GCP_API_ERROR, + }); }; const initializeGapiClient = (gapi) => () => { diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue index 0aae63e1648..411e482b0ce 100644 --- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue +++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue @@ -218,7 +218,7 @@ export default { @input="debouncedValidateQuery" /> <span v-if="queryValidateInFlight" class="form-text text-muted"> - <gl-loading-icon :inline="true" class="mr-1 align-middle" /> + <gl-loading-icon size="sm" :inline="true" class="mr-1 align-middle" /> {{ s__('Metrics|Validating query') }} </span> <slot v-if="!queryValidateInFlight" name="valid-feedback"> diff --git a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue new file mode 100644 index 00000000000..5140b05e189 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue @@ -0,0 +1,142 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import { + OPERATOR_IS_ONLY, + DEFAULT_NONE_ANY, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { + prepareTokens, + processFilters, + filterToQueryObject, +} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; +import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; + +export default { + name: 'FilterBar', + components: { + FilteredSearchBar, + UrlSync, + }, + props: { + groupPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState('filters', { + selectedMilestone: (state) => state.milestones.selected, + selectedAuthor: (state) => state.authors.selected, + selectedLabelList: (state) => state.labels.selectedList, + selectedAssigneeList: (state) => state.assignees.selectedList, + milestonesData: (state) => state.milestones.data, + labelsData: (state) => state.labels.data, + authorsData: (state) => state.authors.data, + assigneesData: (state) => state.assignees.data, + }), + tokens() { + return [ + { + icon: 'clock', + title: __('Milestone'), + type: 'milestone', + token: MilestoneToken, + initialMilestones: this.milestonesData, + unique: true, + symbol: '%', + operators: OPERATOR_IS_ONLY, + fetchMilestones: this.fetchMilestones, + }, + { + icon: 'labels', + title: __('Label'), + type: 'labels', + token: LabelToken, + defaultLabels: DEFAULT_NONE_ANY, + initialLabels: this.labelsData, + unique: false, + symbol: '~', + operators: OPERATOR_IS_ONLY, + fetchLabels: this.fetchLabels, + }, + { + icon: 'pencil', + title: __('Author'), + type: 'author', + token: AuthorToken, + initialAuthors: this.authorsData, + unique: true, + operators: OPERATOR_IS_ONLY, + fetchAuthors: this.fetchAuthors, + }, + { + icon: 'user', + title: __('Assignees'), + type: 'assignees', + token: AuthorToken, + defaultAuthors: [], + initialAuthors: this.assigneesData, + unique: false, + operators: OPERATOR_IS_ONLY, + fetchAuthors: this.fetchAssignees, + }, + ]; + }, + query() { + return filterToQueryObject({ + milestone_title: this.selectedMilestone, + author_username: this.selectedAuthor, + label_name: this.selectedLabelList, + assignee_username: this.selectedAssigneeList, + }); + }, + }, + methods: { + ...mapActions('filters', [ + 'setFilters', + 'fetchMilestones', + 'fetchLabels', + 'fetchAuthors', + 'fetchAssignees', + ]), + initialFilterValue() { + return prepareTokens({ + milestone: this.selectedMilestone, + author: this.selectedAuthor, + assignees: this.selectedAssigneeList, + labels: this.selectedLabelList, + }); + }, + handleFilter(filters) { + const { labels, milestone, author, assignees } = processFilters(filters); + + this.setFilters({ + selectedAuthor: author ? author[0] : null, + selectedMilestone: milestone ? milestone[0] : null, + selectedAssigneeList: assignees || [], + selectedLabelList: labels || [], + }); + }, + }, +}; +</script> + +<template> + <div> + <filtered-search-bar + class="gl-flex-grow-1" + :namespace="groupPath" + recent-searches-storage-key="value-stream-analytics" + :search-input-placeholder="__('Filter results')" + :tokens="tokens" + :initial-filter-value="initialFilterValue()" + @onFilter="handleFilter" + /> + <url-sync :query="query" /> + </div> +</template> diff --git a/app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue b/app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue new file mode 100644 index 00000000000..b622b0441e2 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue @@ -0,0 +1,32 @@ +<script> +import { s__, n__, sprintf, formatNumber } from '~/locale'; + +export default { + props: { + stageCount: { + type: Number, + required: false, + default: null, + }, + }, + computed: { + formattedStageCount() { + if (!this.stageCount) { + return '-'; + } else if (this.stageCount > 1000) { + return sprintf(s__('ValueStreamAnalytics|%{stageCount}+ items'), { + stageCount: formatNumber(1000), + }); + } + + return sprintf(n__('%{count} item', '%{count} items', this.stageCount), { + count: formatNumber(this.stageCount), + }); + }, + }, +}; +</script> + +<template> + <span>{{ formattedStageCount }}</span> +</template> diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue index c1e33f73b13..47fafc3b90c 100644 --- a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue +++ b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue @@ -7,6 +7,7 @@ import { } from '@gitlab/ui'; import Tracking from '~/tracking'; import { OVERVIEW_STAGE_ID } from '../constants'; +import FormattedStageCount from './formatted_stage_count.vue'; export default { name: 'PathNavigation', @@ -14,6 +15,7 @@ export default { GlPath, GlSkeletonLoading, GlPopover, + FormattedStageCount, }, directives: { SafeHtml, @@ -44,9 +46,6 @@ export default { showPopover({ id }) { return id && id !== OVERVIEW_STAGE_ID; }, - hasStageCount({ stageCount = null }) { - return stageCount !== null; - }, onSelectStage($event) { this.$emit('selected', $event); this.track('click_path_navigation', { @@ -88,10 +87,7 @@ export default { {{ s__('ValueStreamEvent|Items in stage') }} </div> <div class="gl-pb-4 gl-font-weight-bold"> - <template v-if="hasStageCount(pathItem)">{{ - n__('%d item', '%d items', pathItem.stageCount) - }}</template> - <template v-else>-</template> + <formatted-stage-count :stage-count="pathItem.stageCount" /> </div> </div> </div> diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue new file mode 100644 index 00000000000..6b1e537dc77 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue @@ -0,0 +1,93 @@ +<script> +import DateRange from '~/analytics/shared/components/daterange.vue'; +import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; +import { DATE_RANGE_LIMIT, PROJECTS_PER_PAGE } from '~/analytics/shared/constants'; +import FilterBar from './filter_bar.vue'; + +export default { + name: 'ValueStreamFilters', + components: { + DateRange, + ProjectsDropdownFilter, + FilterBar, + }, + props: { + selectedProjects: { + type: Array, + required: false, + default: () => [], + }, + hasProjectFilter: { + type: Boolean, + required: false, + default: true, + }, + hasDateRangeFilter: { + type: Boolean, + required: false, + default: true, + }, + groupId: { + type: Number, + required: true, + }, + groupPath: { + type: String, + required: true, + }, + startDate: { + type: Date, + required: false, + default: null, + }, + endDate: { + type: Date, + required: false, + default: null, + }, + }, + computed: { + projectsQueryParams() { + return { + first: PROJECTS_PER_PAGE, + includeSubgroups: true, + }; + }, + }, + multiProjectSelect: true, + maxDateRange: DATE_RANGE_LIMIT, +}; +</script> +<template> + <div class="gl-mt-3 gl-py-2 gl-px-3 bg-gray-light border-top border-bottom"> + <filter-bar + class="js-filter-bar filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none" + :group-path="groupPath" + /> + <div + v-if="hasDateRangeFilter || hasProjectFilter" + class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-justify-content-space-between" + > + <projects-dropdown-filter + v-if="hasProjectFilter" + :key="groupId" + class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0" + :group-id="groupId" + :group-namespace="groupPath" + :query-params="projectsQueryParams" + :multi-select="$options.multiProjectSelect" + :default-projects="selectedProjects" + @selected="$emit('selectProject', $event)" + /> + <date-range + v-if="hasDateRangeFilter" + :start-date="startDate" + :end-date="endDate" + :max-date-range="$options.maxDateRange" + :include-selected-date="true" + class="js-daterange-picker" + @change="$emit('setDateRange', $event)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js index 96c89049e90..97f502326e5 100644 --- a/app/assets/javascripts/cycle_analytics/constants.js +++ b/app/assets/javascripts/cycle_analytics/constants.js @@ -1,3 +1,4 @@ +export const DEFAULT_DAYS_IN_PAST = 30; export const DEFAULT_DAYS_TO_DISPLAY = 30; export const OVERVIEW_STAGE_ID = 'overview'; diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js index 57cb220d9c9..615f96c3860 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -8,11 +8,24 @@ Vue.use(Translate); export default () => { const store = createStore(); const el = document.querySelector('#js-cycle-analytics'); - const { noAccessSvgPath, noDataSvgPath, requestPath, fullPath } = el.dataset; + const { + noAccessSvgPath, + noDataSvgPath, + requestPath, + fullPath, + projectId, + groupPath, + } = el.dataset; store.dispatch('initializeVsa', { + projectId: parseInt(projectId, 10), + groupPath, requestPath, fullPath, + features: { + cycleAnalyticsForGroups: + (groupPath && gon?.licensed_features?.cycleAnalyticsForGroups) || false, + }, }); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index faf1c37d86a..955f0c7271e 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -3,6 +3,7 @@ import { getProjectValueStreams, getProjectValueStreamStageData, getProjectValueStreamMetrics, + getValueStreamStageMedian, } from '~/api/analytics_api'; import createFlash from '~/flash'; import { __ } from '~/locale'; @@ -35,21 +36,33 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => { }; export const fetchValueStreams = ({ commit, dispatch, state }) => { - const { fullPath } = state; + const { + fullPath, + features: { cycleAnalyticsForGroups }, + } = state; commit(types.REQUEST_VALUE_STREAMS); + const stageRequests = ['setSelectedStage']; + if (cycleAnalyticsForGroups) { + stageRequests.push('fetchStageMedians'); + } + return getProjectValueStreams(fullPath) .then(({ data }) => dispatch('receiveValueStreamsSuccess', data)) - .then(() => dispatch('setSelectedStage')) + .then(() => Promise.all(stageRequests.map((r) => dispatch(r)))) .catch(({ response: { status } }) => { commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); }); }; -export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, commit }) => { +export const fetchCycleAnalyticsData = ({ + state: { requestPath }, + getters: { legacyFilterParams }, + commit, +}) => { commit(types.REQUEST_CYCLE_ANALYTICS_DATA); - return getProjectValueStreamMetrics(requestPath, { 'cycle_analytics[start_date]': startDate }) + return getProjectValueStreamMetrics(requestPath, legacyFilterParams) .then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data)) .catch(() => { commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR); @@ -59,13 +72,17 @@ export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, com }); }; -export const fetchStageData = ({ state: { requestPath, selectedStage, startDate }, commit }) => { +export const fetchStageData = ({ + state: { requestPath, selectedStage }, + getters: { legacyFilterParams }, + commit, +}) => { commit(types.REQUEST_STAGE_DATA); return getProjectValueStreamStageData({ requestPath, stageId: selectedStage.id, - params: { 'cycle_analytics[start_date]': startDate }, + params: legacyFilterParams, }) .then(({ data }) => { // when there's a query timeout, the request succeeds but the error is encoded in the response data @@ -78,6 +95,37 @@ export const fetchStageData = ({ state: { requestPath, selectedStage, startDate .catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR)); }; +const getStageMedians = ({ stageId, vsaParams, filterParams = {} }) => { + return getValueStreamStageMedian({ ...vsaParams, stageId }, filterParams).then(({ data }) => ({ + id: stageId, + value: data?.value || null, + })); +}; + +export const fetchStageMedians = ({ + state: { stages }, + getters: { requestParams: vsaParams, filterParams }, + commit, +}) => { + commit(types.REQUEST_STAGE_MEDIANS); + return Promise.all( + stages.map(({ id: stageId }) => + getStageMedians({ + vsaParams, + stageId, + filterParams, + }), + ), + ) + .then((data) => commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data)) + .catch((error) => { + commit(types.RECEIVE_STAGE_MEDIANS_ERROR, error); + createFlash({ + message: __('There was an error fetching median data for stages'), + }); + }); +}; + export const setSelectedStage = ({ dispatch, commit, state: { stages } }, selectedStage = null) => { const stage = selectedStage || stages[0]; commit(types.SET_SELECTED_STAGE, stage); @@ -92,6 +140,8 @@ const refetchData = (dispatch, commit) => { .finally(() => commit(types.SET_LOADING, false)); }; +export const setFilters = ({ dispatch, commit }) => refetchData(dispatch, commit); + export const setDateRange = ({ dispatch, commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => { commit(types.SET_DATE_RANGE, { startDate }); return refetchData(dispatch, commit); diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js index c60a70ef147..66971ea8a2e 100644 --- a/app/assets/javascripts/cycle_analytics/store/getters.js +++ b/app/assets/javascripts/cycle_analytics/store/getters.js @@ -1,3 +1,5 @@ +import dateFormat from 'dateformat'; +import { dateFormats } from '~/analytics/shared/constants'; import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils'; export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => { @@ -8,3 +10,30 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage selectedStage, }); }; + +export const requestParams = (state) => { + const { + selectedStage: { id: stageId = null }, + groupPath: groupId, + selectedValueStream: { id: valueStreamId }, + } = state; + return { valueStreamId, groupId, stageId }; +}; + +const dateRangeParams = ({ createdAfter, createdBefore }) => ({ + created_after: createdAfter ? dateFormat(createdAfter, dateFormats.isoDate) : null, + created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null, +}); + +export const legacyFilterParams = ({ startDate }) => { + return { + 'cycle_analytics[start_date]': startDate, + }; +}; + +export const filterParams = ({ id, ...rest }) => { + return { + project_ids: [id], + ...dateRangeParams(rest), + }; +}; diff --git a/app/assets/javascripts/cycle_analytics/store/index.js b/app/assets/javascripts/cycle_analytics/store/index.js index c6ca88ea492..76e3e835016 100644 --- a/app/assets/javascripts/cycle_analytics/store/index.js +++ b/app/assets/javascripts/cycle_analytics/store/index.js @@ -7,6 +7,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters'; import * as actions from './actions'; import * as getters from './getters'; import mutations from './mutations'; @@ -20,4 +21,5 @@ export default () => getters, mutations, state, + modules: { filters }, }); diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js index 4f3d430ec9f..11ed62a4081 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js +++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js @@ -20,3 +20,7 @@ export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA'; export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS'; export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR'; + +export const REQUEST_STAGE_MEDIANS = 'REQUEST_STAGE_MEDIANS'; +export const RECEIVE_STAGE_MEDIANS_SUCCESS = 'RECEIVE_STAGE_MEDIANS_SUCCESS'; +export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR'; diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js index 0ae80116cd2..a8b7a607b66 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/cycle_analytics/store/mutations.js @@ -1,11 +1,23 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { decorateData, decorateEvents, formatMedianValues } from '../utils'; +import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; +import { + decorateData, + decorateEvents, + formatMedianValues, + calculateFormattedDayInPast, +} from '../utils'; import * as types from './mutation_types'; export default { - [types.INITIALIZE_VSA](state, { requestPath, fullPath }) { + [types.INITIALIZE_VSA](state, { requestPath, fullPath, groupPath, projectId, features }) { state.requestPath = requestPath; state.fullPath = fullPath; + state.groupPath = groupPath; + state.id = projectId; + const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY); + state.createdBefore = now; + state.createdAfter = past; + state.features = features; }, [types.SET_LOADING](state, loadingState) { state.isLoading = loadingState; @@ -18,6 +30,9 @@ export default { }, [types.SET_DATE_RANGE](state, { startDate }) { state.startDate = startDate; + const { now, past } = calculateFormattedDayInPast(startDate); + state.createdBefore = now; + state.createdAfter = past; }, [types.REQUEST_VALUE_STREAMS](state) { state.valueStreams = []; @@ -46,17 +61,25 @@ export default { [types.REQUEST_CYCLE_ANALYTICS_DATA](state) { state.isLoading = true; state.hasError = false; + if (!state.features.cycleAnalyticsForGroups) { + state.medians = {}; + } }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { const { summary, medians } = decorateData(data); + if (!state.features.cycleAnalyticsForGroups) { + state.medians = formatMedianValues(medians); + } state.permissions = data.permissions; state.summary = summary; - state.medians = formatMedianValues(medians); state.hasError = false; }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { state.isLoading = false; state.hasError = true; + if (!state.features.cycleAnalyticsForGroups) { + state.medians = {}; + } }, [types.REQUEST_STAGE_DATA](state) { state.isLoadingStage = true; @@ -78,4 +101,13 @@ export default { state.hasError = true; state.selectedStageError = error; }, + [types.REQUEST_STAGE_MEDIANS](state) { + state.medians = {}; + }, + [types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians) { + state.medians = formatMedianValues(medians); + }, + [types.RECEIVE_STAGE_MEDIANS_ERROR](state) { + state.medians = {}; + }, }; diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js index 02f953d9517..4d61077fb99 100644 --- a/app/assets/javascripts/cycle_analytics/store/state.js +++ b/app/assets/javascripts/cycle_analytics/store/state.js @@ -1,9 +1,13 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; export default () => ({ + features: {}, + id: null, requestPath: '', fullPath: '', startDate: DEFAULT_DAYS_TO_DISPLAY, + createdAfter: null, + createdBefore: null, stages: [], summary: [], analytics: [], @@ -19,4 +23,5 @@ export default () => ({ isLoadingStage: false, isEmptyStage: false, permissions: {}, + parentPath: null, }); diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index 40ad7d8b2fc..a1690dd1513 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -1,6 +1,9 @@ +import dateFormat from 'dateformat'; import { unescape } from 'lodash'; +import { dateFormats } from '~/analytics/shared/constants'; import { sanitize } from '~/lib/dompurify'; import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility'; import { parseSeconds } from '~/lib/utils/datetime_utility'; import { s__, sprintf } from '../locale'; import DEFAULT_EVENT_OBJECTS from './default_event_objects'; @@ -115,3 +118,20 @@ export const formatMedianValues = (medians = []) => export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => stages.filter(({ hidden = false }) => hidden === isHidden); + +const toIsoFormat = (d) => dateFormat(d, dateFormats.isoDate); + +/** + * Takes an integer specifying the number of days to subtract + * from the date specified will return the 2 dates, formatted as ISO dates + * + * @param {Number} daysInPast - Number of days in the past to subtract + * @param {Date} [today=new Date] - Date to subtract days from, defaults to today + * @returns {Object} Returns 'now' and the 'past' date formatted as ISO dates + */ +export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => { + return { + now: toIsoFormat(today), + past: toIsoFormat(getDateInPast(today, daysInPast)), + }; +}; diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 02c57164f47..36d54f586f1 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { s__ } from '~/locale'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import eventHub from '../eventhub'; @@ -93,14 +93,20 @@ export default { .catch(() => { this.isLoading = false; this.store.keys = {}; - return new Flash(s__('DeployKeys|Error getting deploy keys')); + return createFlash({ + message: s__('DeployKeys|Error getting deploy keys'), + }); }); }, enableKey(deployKey) { this.service .enableKey(deployKey.id) .then(this.fetchKeys) - .catch(() => new Flash(s__('DeployKeys|Error enabling deploy key'))); + .catch(() => + createFlash({ + message: s__('DeployKeys|Error enabling deploy key'), + }), + ); }, confirmRemoveKey(deployKey, callback) { const hideModal = () => { @@ -112,7 +118,11 @@ export default { .disableKey(deployKey.id) .then(this.fetchKeys) .then(hideModal) - .catch(() => new Flash(s__('DeployKeys|Error removing deploy key'))); + .catch(() => + createFlash({ + message: s__('DeployKeys|Error removing deploy key'), + }), + ); }; this.cancel = hideModal; this.confirmModalVisible = true; 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 b1c37b0687f..78ba586ce37 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 @@ -221,7 +221,7 @@ export default { @click.stop="toggleResolvedStatus" > <gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" /> - <gl-loading-icon v-else inline /> + <gl-loading-icon v-else size="sm" inline /> </button> </template> <template v-if="discussion.resolved" #resolved-status> 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 833d7081a2c..1e1f5135290 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,6 +1,7 @@ <script> import { GlTooltipDirective, GlIcon, GlLink, GlSafeHtmlDirective } 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'; @@ -48,6 +49,9 @@ export default { author() { return this.note.author; }, + authorId() { + return getIdFromGraphQLId(this.author.id); + }, noteAnchorId() { return findNoteId(this.note.id); }, @@ -94,7 +98,7 @@ export default { v-once :href="author.webUrl" class="js-user-link" - :data-user-id="author.id" + :data-user-id="authorId" :data-username="author.username" > <span class="note-header-author-name gl-font-weight-bold">{{ author.name }}</span> diff --git a/app/assets/javascripts/design_management/components/design_todo_button.vue b/app/assets/javascripts/design_management/components/design_todo_button.vue index da492f03801..013dd1d89f3 100644 --- a/app/assets/javascripts/design_management/components/design_todo_button.vue +++ b/app/assets/javascripts/design_management/components/design_todo_button.vue @@ -1,6 +1,6 @@ <script> import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; -import TodoButton from '~/vue_shared/components/todo_button.vue'; +import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; import createDesignTodoMutation from '../graphql/mutations/create_design_todo.mutation.graphql'; import getDesignQuery from '../graphql/queries/get_design.query.graphql'; import allVersionsMixin from '../mixins/all_versions'; @@ -60,22 +60,6 @@ export default { }, }, methods: { - updateGlobalTodoCount(additionalTodoCount) { - const currentCount = parseInt(document.querySelector('.js-todos-count').innerText, 10); - const todoToggleEvent = new CustomEvent('todo:toggle', { - detail: { - count: Math.max(currentCount + additionalTodoCount, 0), - }, - }); - - document.dispatchEvent(todoToggleEvent); - }, - incrementGlobalTodoCount() { - this.updateGlobalTodoCount(1); - }, - decrementGlobalTodoCount() { - this.updateGlobalTodoCount(-1); - }, createTodo() { this.todoLoading = true; return this.$apollo @@ -92,9 +76,6 @@ export default { } }, }) - .then(() => { - this.incrementGlobalTodoCount(); - }) .catch((err) => { this.$emit('error', Error(CREATE_DESIGN_TODO_ERROR)); throw err; @@ -130,9 +111,6 @@ export default { } }, }) - .then(() => { - this.decrementGlobalTodoCount(); - }) .catch((err) => { this.$emit('error', Error(DELETE_DESIGN_TODO_ERROR)); throw err; diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index ad78433c7ce..19bfa123487 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -41,7 +41,7 @@ import { TOGGLE_TODO_ERROR, designDeletionError, } from '../../utils/error_messages'; -import { trackDesignDetailView, usagePingDesignDetailView } from '../../utils/tracking'; +import { trackDesignDetailView, servicePingDesignDetailView } from '../../utils/tracking'; const DEFAULT_SCALE = 1; @@ -292,7 +292,7 @@ export default { ); if (this.glFeatures.usageDataDesignAction) { - usagePingDesignDetailView(); + servicePingDesignDetailView(); } }, updateActiveDiscussion(id, source = ACTIVE_DISCUSSION_SOURCE_TYPES.discussion) { diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js index 905134fa985..23aec46c152 100644 --- a/app/assets/javascripts/design_management/utils/tracking.js +++ b/app/assets/javascripts/design_management/utils/tracking.js @@ -14,7 +14,7 @@ export const DESIGN_SNOWPLOW_EVENT_TYPES = { UPDATE_DESIGN: 'update_design', }; -export const DESIGN_USAGE_PING_EVENT_TYPES = { +export const DESIGN_SERVICE_PING_EVENT_TYPES = { DESIGN_ACTION: 'design_action', }; @@ -52,8 +52,8 @@ export function trackDesignUpdate() { } /** - * Track "design detail" view via usage ping + * Track "design detail" view via service ping */ -export function usagePingDesignDetailView() { - Api.trackRedisHllUserEvent(DESIGN_USAGE_PING_EVENT_TYPES.DESIGN_ACTION); +export function servicePingDesignDetailView() { + Api.trackRedisHllUserEvent(DESIGN_SERVICE_PING_EVENT_TYPES.DESIGN_ACTION); } diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 7200e6c2e3a..14d6e2db09d 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import FilesCommentButton from './files_comment_button'; @@ -77,7 +77,11 @@ export default class Diff { axios .get(link, { params }) .then(({ data }) => $target.parent().replaceWith(data)) - .catch(() => flash(__('An error occurred while loading diff'))); + .catch(() => + createFlash({ + message: __('An error occurred while loading diff'), + }), + ); } openAnchoredDiff(cb) { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 61946d345e3..e33b60f8ab5 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -14,7 +14,7 @@ import { } from '~/behaviors/shortcuts/keybindings'; import createFlash from '~/flash'; import { isSingleViewStyle } from '~/helpers/diffs_helper'; -import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; @@ -42,6 +42,7 @@ import { TRACKING_MULTIPLE_FILES_MODE, } from '../constants'; +import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; import { fileByFile } from '../utils/preferences'; @@ -52,7 +53,9 @@ import DiffFile from './diff_file.vue'; import HiddenFilesWarning from './hidden_files_warning.vue'; import MergeConflictWarning from './merge_conflict_warning.vue'; import NoChanges from './no_changes.vue'; +import PreRenderer from './pre_renderer.vue'; import TreeList from './tree_list.vue'; +import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync'; export default { name: 'DiffsApp', @@ -71,6 +74,8 @@ export default { GlSprintf, DynamicScroller, DynamicScrollerItem, + PreRenderer, + VirtualScrollerScrollSync, }, alerts: { ALERT_OVERFLOW_HIDDEN, @@ -166,6 +171,8 @@ export default { return { treeWidth, diffFilesLength: 0, + virtualScrollCurrentIndex: -1, + disableVirtualScroller: false, }; }, computed: { @@ -186,6 +193,7 @@ export default { 'showTreeList', 'isLoading', 'startVersion', + 'latestDiff', 'currentDiffFileId', 'isTreeLoaded', 'conflictResolutionPath', @@ -228,8 +236,8 @@ export default { isLimitedContainer() { return !this.renderFileTree && !this.isParallelView && !this.isFluidLayout; }, - isDiffHead() { - return parseBoolean(getParameterByName('diff_head')); + isFullChangeset() { + return this.startVersion === null && this.latestDiff; }, showFileByFileNavigation() { return this.diffFiles.length > 1 && this.viewDiffsFileByFile; @@ -252,7 +260,7 @@ export default { if (this.renderOverflowWarning) { visible = this.$options.alerts.ALERT_OVERFLOW_HIDDEN; - } else if (this.isDiffHead && this.hasConflicts) { + } else if (this.isFullChangeset && this.hasConflicts) { visible = this.$options.alerts.ALERT_MERGE_CONFLICT; } else if (this.whichCollapsedTypes.automatic && !this.viewDiffsFileByFile) { visible = this.$options.alerts.ALERT_COLLAPSED_FILES; @@ -323,6 +331,11 @@ export default { this.setHighlightedRow(id.split('diff-content').pop().slice(1)); } + if (window.gon?.features?.diffsVirtualScrolling) { + diffsEventHub.$on('scrollToFileHash', this.scrollVirtualScrollerToFileHash); + diffsEventHub.$on('scrollToIndex', this.scrollVirtualScrollerToIndex); + } + if (window.gon?.features?.diffSettingsUsageData) { if (this.renderTreeList) { api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_TREE); @@ -377,6 +390,11 @@ export default { diffsApp.deinstrument(); this.unsubscribeFromEvents(); this.removeEventListeners(); + + if (window.gon?.features?.diffsVirtualScrolling) { + diffsEventHub.$off('scrollToFileHash', this.scrollVirtualScrollerToFileHash); + diffsEventHub.$off('scrollToIndex', this.scrollVirtualScrollerToIndex); + } }, methods: { ...mapActions(['startTaskList']), @@ -458,7 +476,11 @@ export default { }, setDiscussions() { requestIdleCallback( - () => this.assignDiscussionsToDiff().then(this.$nextTick).then(this.startTaskList), + () => + this.assignDiscussionsToDiff() + .then(this.$nextTick) + .then(this.startTaskList) + .then(this.scrollVirtualScrollerToDiffNote), { timeout: 1000 }, ); }, @@ -483,12 +505,17 @@ export default { this.moveToNeighboringCommit({ direction: 'previous' }), ); } + + Mousetrap.bind(['ctrl+f', 'command+f'], () => { + this.disableVirtualScroller = true; + }); }, removeEventListeners() { Mousetrap.unbind(keysFor(MR_PREVIOUS_FILE_IN_DIFF)); Mousetrap.unbind(keysFor(MR_NEXT_FILE_IN_DIFF)); Mousetrap.unbind(keysFor(MR_COMMITS_NEXT_COMMIT)); Mousetrap.unbind(keysFor(MR_COMMITS_PREVIOUS_COMMIT)); + Mousetrap.unbind(['ctrl+f', 'command+f']); }, jumpToFile(step) { const targetIndex = this.currentDiffIndex + step; @@ -508,6 +535,36 @@ export default { return this.setShowTreeList({ showTreeList, saving: false }); }, + async scrollVirtualScrollerToFileHash(hash) { + const index = this.diffFiles.findIndex((f) => f.file_hash === hash); + + if (index !== -1) { + this.scrollVirtualScrollerToIndex(index); + } + }, + async scrollVirtualScrollerToIndex(index) { + this.virtualScrollCurrentIndex = index; + + await this.$nextTick(); + + this.virtualScrollCurrentIndex = -1; + }, + scrollVirtualScrollerToDiffNote() { + if (!window.gon?.features?.diffsVirtualScrolling) return; + + const id = window?.location?.hash; + + if (id.startsWith('#note_')) { + const noteId = id.replace('#note_', ''); + const discussion = this.$store.state.notes.discussions.find( + (d) => d.diff_file && d.notes.find((n) => n.id === noteId), + ); + + if (discussion) { + this.scrollVirtualScrollerToFileHash(discussion.diff_file.file_hash); + } + } + }, }, minTreeWidth: MIN_TREE_WIDTH, maxTreeWidth: MAX_TREE_WIDTH, @@ -571,7 +628,8 @@ export default { <div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div> <template v-else-if="renderDiffFiles"> <dynamic-scroller - v-if="isVirtualScrollingEnabled" + v-if="!disableVirtualScroller && isVirtualScrollingEnabled" + ref="virtualScroller" :items="diffs" :min-item-size="70" :buffer="1000" @@ -579,7 +637,7 @@ export default { page-mode > <template #default="{ item, index, active }"> - <dynamic-scroller-item :item="item" :active="active"> + <dynamic-scroller-item :item="item" :active="active" :class="{ active }"> <diff-file :file="item" :reviewed="fileReviews[item.id]" @@ -588,9 +646,29 @@ export default { :help-page-path="helpPagePath" :can-current-user-fork="canCurrentUserFork" :view-diffs-file-by-file="viewDiffsFileByFile" + :active="active" /> </dynamic-scroller-item> </template> + <template #before> + <pre-renderer :max-length="diffFilesLength"> + <template #default="{ item, index, active }"> + <dynamic-scroller-item :item="item" :active="active"> + <diff-file + :file="item" + :reviewed="fileReviews[item.id]" + :is-first-file="index === 0" + :is-last-file="index === diffFilesLength - 1" + :help-page-path="helpPagePath" + :can-current-user-fork="canCurrentUserFork" + :view-diffs-file-by-file="viewDiffsFileByFile" + pre-render + /> + </dynamic-scroller-item> + </template> + </pre-renderer> + <virtual-scroller-scroll-sync :index="virtualScrollCurrentIndex" /> + </template> </dynamic-scroller> <template v-else> <diff-file diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue index 0cf1cdb17f8..240f102e600 100644 --- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue +++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue @@ -1,5 +1,6 @@ <script> import { GlAlert, GlButton } from '@gitlab/ui'; +import { mapState } from 'vuex'; import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants'; import eventHub from '../event_hub'; @@ -27,11 +28,15 @@ export default { }; }, computed: { + ...mapState('diffs', ['diffFiles']), containerClasses() { return { [CENTERED_LIMITED_CONTAINER_CLASSES]: this.limited, }; }, + shouldDisplay() { + return !this.isDismissed && this.diffFiles.length > 1; + }, }, methods: { @@ -48,7 +53,7 @@ export default { </script> <template> - <div v-if="!isDismissed" data-testid="root" :class="containerClasses" class="col-12"> + <div v-if="shouldDisplay" data-testid="root" :class="containerClasses" class="col-12"> <gl-alert :dismissible="true" :title="__('Some changes are not shown')" diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index e2a1f7236c5..f098d20afd1 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -99,7 +99,7 @@ export default { v-gl-tooltip.hover variant="default" icon="file-tree" - class="gl-mr-3 js-toggle-tree-list" + class="gl-mr-3 js-toggle-tree-list btn-icon" :title="toggleFileBrowserTitle" :aria-label="toggleFileBrowserTitle" :selected="showTreeList" @@ -109,7 +109,7 @@ export default { {{ __('Viewing commit') }} <gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link> </div> - <div v-if="hasNeighborCommits" class="commit-nav-buttons ml-3"> + <div v-if="hasNeighborCommits" class="commit-nav-buttons"> <gl-button-group> <gl-button :href="previousCommitUrl" @@ -160,6 +160,15 @@ export default { /> </template> </gl-sprintf> + <gl-button + v-if="commit || startVersion" + :href="latestVersionPath" + variant="default" + class="js-latest-version" + :class="{ 'gl-ml-3': commit && !hasNeighborCommits }" + > + {{ __('Show latest version') }} + </gl-button> <div v-if="hasChanges" class="inline-parallel-buttons d-none d-md-flex ml-auto"> <diff-stats :diff-files-count-text="diffFilesCountText" @@ -167,14 +176,6 @@ export default { :removed-lines="removedLines" /> <gl-button - v-if="commit || startVersion" - :href="latestVersionPath" - variant="default" - class="gl-mr-3 js-latest-version" - > - {{ __('Show latest version') }} - </gl-button> - <gl-button v-show="whichCollapsedTypes.any" variant="default" class="gl-mr-3" diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index cb74c7dc7cd..858d9e221ae 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import { mapInline, mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils'; +import { mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils'; import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import { diffViewerModes } from '~/ide/constants'; @@ -9,7 +9,6 @@ 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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; 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'; @@ -18,14 +17,10 @@ import { getDiffMode } from '../store/utils'; import DiffDiscussions from './diff_discussions.vue'; import DiffView from './diff_view.vue'; import ImageDiffOverlay from './image_diff_overlay.vue'; -import InlineDiffView from './inline_diff_view.vue'; -import ParallelDiffView from './parallel_diff_view.vue'; export default { components: { GlLoadingIcon, - InlineDiffView, - ParallelDiffView, DiffView, DiffViewer, NoteForm, @@ -36,7 +31,7 @@ export default { userAvatarLink, DiffFileDrafts, }, - mixins: [diffLineNoteFormMixin, draftCommentsMixin, glFeatureFlagsMixin()], + mixins: [diffLineNoteFormMixin, draftCommentsMixin], props: { diffFile: { type: Object, @@ -52,7 +47,6 @@ export default { ...mapState('diffs', ['projectPath']), ...mapGetters('diffs', [ 'isInlineView', - 'isParallelView', 'getCommentFormForDiffFile', 'diffLines', 'fileLineCodequality', @@ -86,15 +80,8 @@ export default { return this.getUserData; }, mappedLines() { - if (this.glFeatures.unifiedDiffComponents) { - return this.diffLines(this.diffFile, true).map(mapParallel(this)) || []; - } - - // TODO: Everything below this line can be deleted when unifiedDiffComponents FF is removed - if (this.isInlineView) { - return this.diffFile.highlighted_diff_lines.map(mapInline(this)); - } - return this.diffLines(this.diffFile).map(mapParallel(this)); + // TODO: Do this data generation when we recieve a response to save a computed property being created + return this.diffLines(this.diffFile).map(mapParallel(this)) || []; }, }, updated() { @@ -126,7 +113,7 @@ export default { <template> <div class="diff-content"> <div class="diff-viewer"> - <template v-if="isTextFile && glFeatures.unifiedDiffComponents"> + <template v-if="isTextFile"> <diff-view :diff-file="diffFile" :diff-lines="mappedLines" @@ -135,21 +122,6 @@ export default { /> <gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" /> </template> - <template v-else-if="isTextFile"> - <inline-diff-view - v-if="isInlineView" - :diff-file="diffFile" - :diff-lines="mappedLines" - :help-page-path="helpPagePath" - /> - <parallel-diff-view - v-else-if="isParallelView" - :diff-file="diffFile" - :diff-lines="mappedLines" - :help-page-path="helpPagePath" - /> - <gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" /> - </template> <not-diffable-viewer v-else-if="notDiffable" /> <no-preview-viewer v-else-if="noPreview" /> <diff-viewer diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index ed8455f0c1c..dde5ea81e9a 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -2,6 +2,7 @@ import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml, GlSprintf } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { IdState } from 'vendor/vue-virtual-scroller'; import createFlash from '~/flash'; import { hasDiff } from '~/helpers/diffs_helper'; import { diffViewerErrors } from '~/ide/constants'; @@ -19,7 +20,7 @@ import { } from '../constants'; import eventHub from '../event_hub'; import { DIFF_FILE, GENERIC_ERROR } from '../i18n'; -import { collapsedType, isCollapsed, getShortShaFromFile } from '../utils/diff_file'; +import { collapsedType, getShortShaFromFile } from '../utils/diff_file'; import DiffContent from './diff_content.vue'; import DiffFileHeader from './diff_file_header.vue'; @@ -34,7 +35,7 @@ export default { directives: { SafeHtml, }, - mixins: [glFeatureFlagsMixin()], + mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.file.file_hash })], props: { file: { type: Object, @@ -68,12 +69,22 @@ export default { type: Boolean, required: true, }, + active: { + type: Boolean, + required: false, + default: true, + }, + preRender: { + type: Boolean, + required: false, + default: false, + }, }, - data() { + idState() { return { isLoadingCollapsedDiff: false, forkMessageVisible: false, - isCollapsed: isCollapsed(this.file), + hasToggled: false, }; }, i18n: { @@ -91,7 +102,7 @@ export default { return getShortShaFromFile(this.file); }, showLoadingIcon() { - return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); + return this.idState.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); }, hasDiff() { return hasDiff(this.file); @@ -152,45 +163,39 @@ export default { codequalityDiffForFile() { return this.codequalityDiff?.files?.[this.file.file_path] || []; }, + isCollapsed() { + if (collapsedType(this.file) !== DIFF_FILE_MANUAL_COLLAPSE) { + return this.viewDiffsFileByFile ? false : this.file.viewer?.automaticallyCollapsed; + } + + return this.file.viewer?.manuallyCollapsed; + }, }, watch: { 'file.id': { handler: function fileIdHandler() { + if (this.preRender) return; + this.manageViewedEffects(); }, }, 'file.file_hash': { handler: function hashChangeWatch(newHash, oldHash) { - this.isCollapsed = isCollapsed(this.file); - - if (newHash && oldHash && !this.hasDiff) { + if (newHash && oldHash && !this.hasDiff && !this.preRender) { this.requestDiff(); } }, - immediate: true, - }, - 'file.viewer.automaticallyCollapsed': { - handler: function autoChangeWatch(automaticValue) { - if (collapsedType(this.file) !== DIFF_FILE_MANUAL_COLLAPSE) { - this.isCollapsed = this.viewDiffsFileByFile ? false : automaticValue; - } - }, - immediate: true, - }, - 'file.viewer.manuallyCollapsed': { - handler: function manualChangeWatch(manualValue) { - if (manualValue !== null) { - this.isCollapsed = manualValue; - } - }, - immediate: true, }, }, created() { + if (this.preRender) return; + notesEventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.requestDiff); eventHub.$on(EVT_EXPAND_ALL_FILES, this.expandAllListener); }, mounted() { + if (this.preRender) return; + if (this.hasDiff) { this.postRender(); } @@ -198,6 +203,8 @@ export default { this.manageViewedEffects(); }, beforeDestroy() { + if (this.preRender) return; + eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener); }, methods: { @@ -208,8 +215,14 @@ export default { 'setFileCollapsedByUser', ]), manageViewedEffects() { - if (this.reviewed && !this.isCollapsed && this.showLocalFileReviews) { + if ( + !this.idState.hasToggled && + this.reviewed && + !this.isCollapsed && + this.showLocalFileReviews + ) { this.handleToggle(); + this.idState.hasToggled = true; } }, expandAllListener() { @@ -252,11 +265,11 @@ export default { } }, requestDiff() { - this.isLoadingCollapsedDiff = true; + this.idState.isLoadingCollapsedDiff = true; this.loadCollapsedDiff(this.file) .then(() => { - this.isLoadingCollapsedDiff = false; + this.idState.isLoadingCollapsedDiff = false; this.setRenderIt(this.file); }) .then(() => { @@ -269,17 +282,17 @@ export default { ); }) .catch(() => { - this.isLoadingCollapsedDiff = false; + this.idState.isLoadingCollapsedDiff = false; createFlash({ message: this.$options.i18n.genericError, }); }); }, showForkMessage() { - this.forkMessageVisible = true; + this.idState.forkMessageVisible = true; }, hideForkMessage() { - this.forkMessageVisible = false; + this.idState.forkMessageVisible = false; }, }, }; @@ -287,7 +300,7 @@ export default { <template> <div - :id="file.file_hash" + :id="!preRender && active && file.file_hash" :class="{ 'is-active': currentDiffFileId === file.file_hash, 'comments-disabled': Boolean(file.brokenSymlink), @@ -313,7 +326,10 @@ export default { @showForkMessage="showForkMessage" /> - <div v-if="forkMessageVisible" class="js-file-fork-suggestion-section file-fork-suggestion"> + <div + v-if="idState.forkMessageVisible" + class="js-file-fork-suggestion-section file-fork-suggestion" + > <span v-safe-html="forkMessage" class="file-fork-suggestion-note"></span> <a :href="file.fork_path" @@ -330,12 +346,13 @@ export default { </div> <template v-else> <div - :id="`diff-content-${file.file_hash}`" + :id="!preRender && active && `diff-content-${file.file_hash}`" :class="hasBodyClasses.contentByHash" data-testid="content-area" > <gl-loading-icon v-if="showLoadingIcon" + size="sm" class="diff-content loading gl-my-0 gl-pt-3" data-testid="loader-icon" /> @@ -357,7 +374,7 @@ export default { </div> <template v-else> <div - v-show="showWarning" + v-if="showWarning" class="collapsed-file-warning gl-p-7 gl-bg-orange-50 gl-text-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base" > <p class="gl-mb-5"> @@ -373,7 +390,7 @@ export default { </gl-button> </div> <diff-content - v-show="showContent" + v-if="showContent" :class="hasBodyClasses.content" :diff-file="file" :help-page-path="helpPagePath" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 45c7fe35f03..667b8745f7b 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -13,6 +13,7 @@ import { } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { IdState } from 'vendor/vue-virtual-scroller'; import { diffViewerModes } from '~/ide/constants'; import { scrollToElement } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; @@ -41,13 +42,12 @@ export default { GlDropdownDivider, GlFormCheckbox, GlLoadingIcon, - CodeQualityBadge: () => import('ee_component/diffs/components/code_quality_badge.vue'), }, directives: { GlTooltip: GlTooltipDirective, SafeHtml: GlSafeHtmlDirective, }, - mixins: [glFeatureFlagsMixin()], + mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.diffFile.file_hash })], i18n: { ...DIFF_FILE_HEADER, compareButtonLabel: s__('Compare submodule commit revisions'), @@ -102,7 +102,7 @@ export default { default: () => [], }, }, - data() { + idState() { return { moreActionsShown: false, }; @@ -202,8 +202,18 @@ export default { externalUrlLabel() { return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url }); }, - showCodequalityBadge() { - return this.codequalityDiff?.length > 0 && !this.glFeatures.codequalityMrDiffAnnotations; + }, + watch: { + 'idState.moreActionsShown': { + handler(val) { + const el = this.$el.closest('.vue-recycle-scroller__item-view'); + + if (this.glFeatures.diffsVirtualScrolling && el) { + // We can't add a style with Vue because of the way the virtual + // scroller library renders the diff files + el.style.zIndex = val ? '1' : null; + } + }, }, }, methods: { @@ -239,7 +249,7 @@ export default { } }, setMoreActionsShown(val) { - this.moreActionsShown = val; + this.idState.moreActionsShown = val; }, toggleReview(newReviewedStatus) { const autoCollapsed = @@ -268,7 +278,7 @@ export default { <template> <div ref="header" - :class="{ 'gl-z-dropdown-menu!': moreActionsShown }" + :class="{ 'gl-z-dropdown-menu!': idState.moreActionsShown }" class="js-file-title file-title file-title-flex-parent" data-qa-selector="file_title_container" :data-qa-file-name="filePath" @@ -292,7 +302,7 @@ export default { > <file-icon :file-name="filePath" - :size="18" + :size="16" aria-hidden="true" css-classes="gl-mr-2" :submodule="diffFile.submodule" @@ -336,13 +346,6 @@ export default { data-track-property="diff_copy_file" /> - <code-quality-badge - v-if="showCodequalityBadge" - :file-name="filePath" - :codequality-diff="codequalityDiff" - class="gl-mr-2" - /> - <small v-if="isModeChanged" ref="fileMode" class="mr-1"> {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> @@ -453,7 +456,7 @@ export default { :disabled="diffFile.isLoadingFullFile" @click="toggleFullDiff(diffFile.file_path)" > - <gl-loading-icon v-if="diffFile.isLoadingFullFile" inline /> + <gl-loading-icon v-if="diffFile.isLoadingFullFile" size="sm" inline /> {{ expandDiffToFullFileTitle }} </gl-dropdown-item> </template> 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 c907b5dffaf..c445989f143 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -106,10 +106,7 @@ export default { }; const getDiffLines = () => { if (this.diffViewType === PARALLEL_DIFF_VIEW_TYPE) { - return this.diffLines(this.diffFile, this.glFeatures.unifiedDiffComponents).reduce( - combineSides, - [], - ); + return this.diffLines(this.diffFile).reduce(combineSides, []); } return this.diffFile[INLINE_DIFF_LINES_KEY]; diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 37dd7941b2e..c310bd9f31a 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -1,13 +1,9 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlTooltipDirective } from '@gitlab/ui'; -import { mapActions, mapGetters, mapState } from 'vuex'; -import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { memoize } from 'lodash'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import { - CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE, - CONFLICT_MARKER_OUR, CONFLICT_MARKER_THEIR, CONFLICT_OUR, CONFLICT_THEIR, @@ -22,15 +18,8 @@ import DiffGutterAvatars from './diff_gutter_avatars.vue'; import * as utils from './diff_row_utils'; export default { - components: { - DiffGutterAvatars, - CodeQualityGutterIcon: () => - import('ee_component/diffs/components/code_quality_gutter_icon.vue'), - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [glFeatureFlagsMixin()], + DiffGutterAvatars, + CodeQualityGutterIcon: () => import('ee_component/diffs/components/code_quality_gutter_icon.vue'), props: { fileHash: { type: String, @@ -58,148 +47,109 @@ export default { type: Number, required: true, }, + isHighlighted: { + type: Boolean, + required: true, + }, + fileLineCoverage: { + type: Function, + required: true, + }, }, - data() { - return { - dragging: false, - }; - }, - computed: { - ...mapGetters('diffs', ['fileLineCoverage']), - ...mapGetters(['isLoggedIn']), - ...mapState({ - isHighlighted(state) { - const line = this.line.left?.line_code ? this.line.left : this.line.right; - return utils.isHighlighted(state, line, false); - }, - }), - classNameMap() { + classNameMap: memoize( + (props) => { return { - [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLineLeft, - [PARALLEL_DIFF_VIEW_TYPE]: !this.inline, - commented: this.isCommented, + [PARALLEL_DIFF_VIEW_TYPE]: !props.inline, + commented: props.isCommented, }; }, - parallelViewLeftLineType() { - return utils.parallelViewLeftLineType(this.line, this.isHighlighted || this.isCommented); + (props) => [!props.inline, props.isCommented].join(':'), + ), + parallelViewLeftLineType: memoize( + (props) => { + return utils.parallelViewLeftLineType(props.line, props.isHighlighted || props.isCommented); }, - coverageStateLeft() { - if (!this.inline || !this.line.left) return {}; - return this.fileLineCoverage(this.filePath, this.line.left.new_line); + (props) => + [props.line.left?.type, props.line.right?.type, props.isHighlighted, props.isCommented].join( + ':', + ), + ), + coverageStateLeft: memoize( + (props) => { + if (!props.inline || !props.line.left) return {}; + return props.fileLineCoverage(props.filePath, props.line.left.new_line); }, - coverageStateRight() { - if (!this.line.right) return {}; - return this.fileLineCoverage(this.filePath, this.line.right.new_line); + (props) => [props.inline, props.filePath, props.line.left?.new_line].join(':'), + ), + coverageStateRight: memoize( + (props) => { + if (!props.line.right) return {}; + return props.fileLineCoverage(props.filePath, props.line.right.new_line); }, - showCodequalityLeft() { - return ( - this.glFeatures.codequalityMrDiffAnnotations && - this.inline && - this.line.left?.codequality?.length > 0 - ); + (props) => [props.line.right?.new_line, props.filePath].join(':'), + ), + showCodequalityLeft: memoize( + (props) => { + return props.inline && props.line.left?.codequality?.length > 0; }, - showCodequalityRight() { - return ( - this.glFeatures.codequalityMrDiffAnnotations && - !this.inline && - this.line.right?.codequality?.length > 0 - ); + (props) => [props.inline, props.line.left?.codequality?.length].join(':'), + ), + showCodequalityRight: memoize( + (props) => { + return !props.inline && props.line.right?.codequality?.length > 0; }, - classNameMapCellLeft() { + (props) => [props.inline, props.line.right?.codequality?.length].join(':'), + ), + classNameMapCellLeft: memoize( + (props) => { return utils.classNameMapCell({ - line: this.line.left, - hll: this.isHighlighted || this.isCommented, - isLoggedIn: this.isLoggedIn, + line: props.line.left, + hll: props.isHighlighted || props.isCommented, }); }, - classNameMapCellRight() { + (props) => [props.line.left.type, props.isHighlighted, props.isCommented].join(':'), + ), + classNameMapCellRight: memoize( + (props) => { return utils.classNameMapCell({ - line: this.line.right, - hll: this.isHighlighted || this.isCommented, - isLoggedIn: this.isLoggedIn, + line: props.line.right, + hll: props.isHighlighted || props.isCommented, }); }, - addCommentTooltipLeft() { - return utils.addCommentTooltip(this.line.left, this.glFeatures.dragCommentSelection); - }, - addCommentTooltipRight() { - return utils.addCommentTooltip(this.line.right, this.glFeatures.dragCommentSelection); + (props) => [props.line.right.type, props.isHighlighted, props.isCommented].join(':'), + ), + shouldRenderCommentButton: memoize( + (props) => { + return isLoggedIn() && !props.line.isContextLineLeft && !props.line.isMetaLineLeft; }, - emptyCellRightClassMap() { - return { conflict_their: this.line.left?.type === CONFLICT_OUR }; - }, - emptyCellLeftClassMap() { - return { conflict_our: this.line.right?.type === CONFLICT_THEIR }; - }, - shouldRenderCommentButton() { - return this.isLoggedIn && !this.line.isContextLineLeft && !this.line.isMetaLineLeft; - }, - isLeftConflictMarker() { - return [CONFLICT_MARKER_OUR, CONFLICT_MARKER_THEIR].includes(this.line.left?.type); - }, - interopLeftAttributes() { - if (this.inline) { - return getInteropInlineAttributes(this.line.left); - } + (props) => [props.line.isContextLineLeft, props.line.isMetaLineLeft].join(':'), + ), + interopLeftAttributes(props) { + if (props.inline) { + return getInteropInlineAttributes(props.line.left); + } - return getInteropOldSideAttributes(this.line.left); - }, - interopRightAttributes() { - return getInteropNewSideAttributes(this.line.right); - }, + return getInteropOldSideAttributes(props.line.left); }, - mounted() { - this.scrollToLineIfNeededParallel(this.line); + interopRightAttributes(props) { + return getInteropNewSideAttributes(props.line.right); }, - methods: { - ...mapActions('diffs', [ - 'scrollToLineIfNeededParallel', - 'showCommentForm', - 'setHighlightedRow', - 'toggleLineDiscussions', - ]), - // Prevent text selecting on both sides of parallel diff view - // Backport of the same code from legacy diff notes. - handleParallelLineMouseDown(e) { - const line = e.currentTarget; - const table = line.closest('.diff-table'); - - table.classList.remove('left-side-selected', 'right-side-selected'); - const [lineClass] = ['left-side', 'right-side'].filter((name) => - line.classList.contains(name), - ); - - if (lineClass) { - table.classList.add(`${lineClass}-selected`); - } - }, - handleCommentButton(line) { - this.showCommentForm({ lineCode: line.line_code, fileHash: this.fileHash }); - }, - conflictText(line) { - return line.type === CONFLICT_MARKER_THEIR - ? this.$options.THEIR_CHANGES - : this.$options.OUR_CHANGES; - }, - onDragEnd() { - this.dragging = false; - if (!this.glFeatures.dragCommentSelection) return; - - this.$emit('stopdragging'); + conflictText: memoize( + (line) => { + return line.type === CONFLICT_MARKER_THEIR ? 'HEAD//our changes' : 'origin//their changes'; }, - onDragEnter(line, index) { - if (!this.glFeatures.dragCommentSelection) return; + (line) => line.type, + ), + lineContent: memoize( + (line) => { + if (line.isConflictMarker) { + return line.type === CONFLICT_MARKER_THEIR ? 'HEAD//our changes' : 'origin//their changes'; + } - this.$emit('enterdragging', { ...line, index }); - }, - onDragStart(line) { - this.$root.$emit(BV_HIDE_TOOLTIP); - this.dragging = true; - this.$emit('startdragging', line); + return line.rich_text; }, - }, - OUR_CHANGES: 'HEAD//our changes', - THEIR_CHANGES: 'origin//their changes', + (line) => line.line_code, + ), CONFLICT_MARKER, CONFLICT_MARKER_THEIR, CONFLICT_OUR, @@ -207,250 +157,256 @@ export default { }; </script> -<template> - <div :class="classNameMap" class="diff-grid-row diff-tr line_holder"> +<!-- eslint-disable-next-line vue/no-deprecated-functional-template --> +<template functional> + <div :class="$options.classNameMap(props)" class="diff-grid-row diff-tr line_holder"> <div + :id="props.line.left && props.line.left.line_code" data-testid="left-side" class="diff-grid-left left-side" - v-bind="interopLeftAttributes" + v-bind="$options.interopLeftAttributes(props)" @dragover.prevent - @dragenter="onDragEnter(line.left, index)" - @dragend="onDragEnd" + @dragenter="listeners.enterdragging({ ...props.line.left, index: props.index })" + @dragend="listeners.stopdragging" > - <template v-if="line.left && line.left.type !== $options.CONFLICT_MARKER"> + <template v-if="props.line.left && props.line.left.type !== $options.CONFLICT_MARKER"> <div - :class="classNameMapCellLeft" + :class="$options.classNameMapCellLeft(props)" data-testid="left-line-number" class="diff-td diff-line-num" data-qa-selector="new_diff_line_link" > - <template v-if="!isLeftConflictMarker"> - <span - v-if="shouldRenderCommentButton && !line.hasDiscussionsLeft" - v-gl-tooltip - class="add-diff-note tooltip-wrapper" - :title="addCommentTooltipLeft" - > - <div - data-testid="left-comment-button" - role="button" - tabindex="0" - :draggable="!line.left.commentsDisabled && glFeatures.dragCommentSelection" - type="button" - class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button" - data-qa-selector="diff_comment_button" - :class="{ 'gl-cursor-grab': dragging }" - :disabled="line.left.commentsDisabled" - :aria-disabled="line.left.commentsDisabled" - @click="!line.left.commentsDisabled && handleCommentButton(line.left)" - @keydown.enter="!line.left.commentsDisabled && handleCommentButton(line.left)" - @keydown.space="!line.left.commentsDisabled && handleCommentButton(line.left)" - @dragstart="!line.left.commentsDisabled && onDragStart({ ...line.left, index })" - ></div> - </span> - </template> + <span + v-if=" + !props.line.left.isConflictMarker && + $options.shouldRenderCommentButton(props) && + !props.line.hasDiscussionsLeft + " + class="add-diff-note tooltip-wrapper has-tooltip" + :title="props.line.left.addCommentTooltip" + > + <div + data-testid="left-comment-button" + role="button" + tabindex="0" + :draggable="!props.line.left.commentsDisabled" + type="button" + class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button" + data-qa-selector="diff_comment_button" + :disabled="props.line.left.commentsDisabled" + :aria-disabled="props.line.left.commentsDisabled" + @click=" + !props.line.left.commentsDisabled && + listeners.showCommentForm(props.line.left.line_code) + " + @keydown.enter=" + !props.line.left.commentsDisabled && + listeners.showCommentForm(props.line.left.line_code) + " + @keydown.space=" + !props.line.left.commentsDisabled && + listeners.showCommentForm(props.line.left.line_code) + " + @dragstart=" + !props.line.left.commentsDisabled && + listeners.startdragging({ + event: $event, + line: { ...props.line.left, index: props.index }, + }) + " + ></div> + </span> <a - v-if="line.left.old_line && line.left.type !== $options.CONFLICT_THEIR" - :data-linenumber="line.left.old_line" - :href="line.lineHrefOld" - @click="setHighlightedRow(line.lineCode)" + v-if="props.line.left.old_line && props.line.left.type !== $options.CONFLICT_THEIR" + :data-linenumber="props.line.left.old_line" + :href="props.line.lineHrefOld" + @click="listeners.setHighlightedRow(props.line.lineCode)" > </a> - <diff-gutter-avatars - v-if="line.hasDiscussionsLeft" - :discussions="line.left.discussions" - :discussions-expanded="line.left.discussionsExpanded" + <component + :is="$options.DiffGutterAvatars" + v-if="props.line.hasDiscussionsLeft" + :discussions="props.line.left.discussions" + :discussions-expanded="props.line.left.discussionsExpanded" data-testid="left-discussions" @toggleLineDiscussions=" - toggleLineDiscussions({ - lineCode: line.left.line_code, - fileHash, - expanded: !line.left.discussionsExpanded, + listeners.toggleLineDiscussions({ + lineCode: props.line.left.line_code, + expanded: !props.line.left.discussionsExpanded, }) " /> </div> - <div v-if="inline" :class="classNameMapCellLeft" class="diff-td diff-line-num"> + <div + v-if="props.inline" + :class="$options.classNameMapCellLeft(props)" + class="diff-td diff-line-num" + > <a - v-if="line.left.new_line && line.left.type !== $options.CONFLICT_OUR" - :data-linenumber="line.left.new_line" - :href="line.lineHrefOld" - @click="setHighlightedRow(line.lineCode)" + v-if="props.line.left.new_line && props.line.left.type !== $options.CONFLICT_OUR" + :data-linenumber="props.line.left.new_line" + :href="props.line.lineHrefOld" + @click="listeners.setHighlightedRow(props.line.lineCode)" > </a> </div> <div - v-gl-tooltip.hover - :title="coverageStateLeft.text" - :class="[...parallelViewLeftLineType, coverageStateLeft.class]" - class="diff-td line-coverage left-side" + :title="$options.coverageStateLeft(props).text" + :class="[ + $options.parallelViewLeftLineType(props), + $options.coverageStateLeft(props).class, + ]" + class="diff-td line-coverage left-side has-tooltip" ></div> - <div class="diff-td line-codequality left-side" :class="[...parallelViewLeftLineType]"> - <code-quality-gutter-icon - v-if="showCodequalityLeft" - :file-path="filePath" - :codequality="line.left.codequality" + <div + class="diff-td line-codequality left-side" + :class="$options.parallelViewLeftLineType(props)" + > + <component + :is="$options.CodeQualityGutterIcon" + v-if="$options.showCodequalityLeft(props)" + :codequality="props.line.left.codequality" + :file-path="props.filePath" /> </div> <div - :id="line.left.line_code" - :key="line.left.line_code" - :class="[parallelViewLeftLineType, { parallel: !inline }]" + :key="props.line.left.line_code" + :class="[ + $options.parallelViewLeftLineType(props), + { parallel: !props.inline, 'gl-font-weight-bold': props.line.left.isConflictMarker }, + ]" class="diff-td line_content with-coverage left-side" data-testid="left-content" - @mousedown="handleParallelLineMouseDown" - > - <strong v-if="isLeftConflictMarker">{{ conflictText(line.left) }}</strong> - <span v-else v-html="line.left.rich_text"></span> - </div> + v-html="$options.lineContent(props.line.left)" + ></div> </template> - <template v-else-if="!inline || (line.left && line.left.type === $options.CONFLICT_MARKER)"> - <div - data-testid="left-empty-cell" - class="diff-td diff-line-num old_line empty-cell" - :class="emptyCellLeftClassMap" - > + <template + v-else-if=" + !props.inline || (props.line.left && props.line.left.type === $options.CONFLICT_MARKER) + " + > + <div data-testid="left-empty-cell" class="diff-td diff-line-num old_line empty-cell"> </div> - <div - v-if="inline" - class="diff-td diff-line-num old_line empty-cell" - :class="emptyCellLeftClassMap" - ></div> - <div - class="diff-td line-coverage left-side empty-cell" - :class="emptyCellLeftClassMap" - ></div> - <div - v-if="inline" - class="diff-td line-codequality left-side empty-cell" - :class="emptyCellLeftClassMap" - ></div> + <div v-if="props.inline" class="diff-td diff-line-num old_line empty-cell"></div> + <div class="diff-td line-coverage left-side empty-cell"></div> + <div v-if="props.inline" class="diff-td line-codequality left-side empty-cell"></div> <div class="diff-td line_content with-coverage left-side empty-cell" - :class="[emptyCellLeftClassMap, { parallel: !inline }]" + :class="[{ parallel: !props.inline }]" ></div> </template> </div> <div - v-if="!inline" + v-if="!props.inline" + :id="props.line.right && props.line.right.line_code" data-testid="right-side" class="diff-grid-right right-side" - v-bind="interopRightAttributes" + v-bind="$options.interopRightAttributes(props)" @dragover.prevent - @dragenter="onDragEnter(line.right, index)" - @dragend="onDragEnd" + @dragenter="listeners.enterdragging({ ...props.line.right, index: props.index })" + @dragend="listeners.stopdragging" > - <template v-if="line.right"> - <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line"> - <template v-if="line.right.type !== $options.CONFLICT_MARKER_THEIR"> + <template v-if="props.line.right"> + <div :class="$options.classNameMapCellRight(props)" class="diff-td diff-line-num new_line"> + <template v-if="props.line.right.type !== $options.CONFLICT_MARKER_THEIR"> <span - v-if="shouldRenderCommentButton && !line.hasDiscussionsRight" - v-gl-tooltip - class="add-diff-note tooltip-wrapper" - :title="addCommentTooltipRight" + v-if="$options.shouldRenderCommentButton(props) && !props.line.hasDiscussionsRight" + class="add-diff-note tooltip-wrapper has-tooltip" + :title="props.line.right.addCommentTooltip" > <div data-testid="right-comment-button" role="button" tabindex="0" - :draggable="!line.right.commentsDisabled && glFeatures.dragCommentSelection" + :draggable="!props.line.right.commentsDisabled" type="button" class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button" - :class="{ 'gl-cursor-grab': dragging }" - :disabled="line.right.commentsDisabled" - :aria-disabled="line.right.commentsDisabled" - @click="!line.right.commentsDisabled && handleCommentButton(line.right)" - @keydown.enter="!line.right.commentsDisabled && handleCommentButton(line.right)" - @keydown.space="!line.right.commentsDisabled && handleCommentButton(line.right)" - @dragstart="!line.right.commentsDisabled && onDragStart({ ...line.right, index })" + :disabled="props.line.right.commentsDisabled" + :aria-disabled="props.line.right.commentsDisabled" + @click=" + !props.line.right.commentsDisabled && + listeners.showCommentForm(props.line.right.line_code) + " + @keydown.enter=" + !props.line.right.commentsDisabled && + listeners.showCommentForm(props.line.right.line_code) + " + @keydown.space=" + !props.line.right.commentsDisabled && + listeners.showCommentForm(props.line.right.line_code) + " + @dragstart=" + !props.line.right.commentsDisabled && + listeners.startdragging({ + event: $event, + line: { ...props.line.right, index: props.index }, + }) + " ></div> </span> </template> <a - v-if="line.right.new_line" - :data-linenumber="line.right.new_line" - :href="line.lineHrefNew" - @click="setHighlightedRow(line.lineCode)" + v-if="props.line.right.new_line" + :data-linenumber="props.line.right.new_line" + :href="props.line.lineHrefNew" + @click="listeners.setHighlightedRow(props.line.lineCode)" > </a> - <diff-gutter-avatars - v-if="line.hasDiscussionsRight" - :discussions="line.right.discussions" - :discussions-expanded="line.right.discussionsExpanded" + <component + :is="$options.DiffGutterAvatars" + v-if="props.line.hasDiscussionsRight" + :discussions="props.line.right.discussions" + :discussions-expanded="props.line.right.discussionsExpanded" data-testid="right-discussions" @toggleLineDiscussions=" - toggleLineDiscussions({ - lineCode: line.right.line_code, - fileHash, - expanded: !line.right.discussionsExpanded, + listeners.toggleLineDiscussions({ + lineCode: props.line.right.line_code, + expanded: !props.line.right.discussionsExpanded, }) " /> </div> <div - v-gl-tooltip.hover - :title="coverageStateRight.text" + :title="$options.coverageStateRight(props).text" :class="[ - line.right.type, - coverageStateRight.class, - { hll: isHighlighted, hll: isCommented }, + props.line.right.type, + $options.coverageStateRight(props).class, + { hll: props.isHighlighted, hll: props.isCommented }, ]" - class="diff-td line-coverage right-side" + class="diff-td line-coverage right-side has-tooltip" ></div> <div class="diff-td line-codequality right-side" - :class="[line.right.type, { hll: isHighlighted, hll: isCommented }]" + :class="[props.line.right.type, { hll: props.isHighlighted, hll: props.isCommented }]" > - <code-quality-gutter-icon - v-if="showCodequalityRight" - :file-path="filePath" - :codequality="line.right.codequality" + <component + :is="$options.CodeQualityGutterIcon" + v-if="$options.showCodequalityRight(props)" + :codequality="props.line.right.codequality" + :file-path="props.filePath" + data-testid="codeQualityIcon" /> </div> <div - :id="line.right.line_code" - :key="line.right.rich_text" + :key="props.line.right.rich_text" :class="[ - line.right.type, + props.line.right.type, { - hll: isHighlighted, - hll: isCommented, - parallel: !inline, + hll: props.isHighlighted, + hll: props.isCommented, + 'gl-font-weight-bold': props.line.right.type === $options.CONFLICT_MARKER_THEIR, }, ]" - class="diff-td line_content with-coverage right-side" - @mousedown="handleParallelLineMouseDown" - > - <strong v-if="line.right.type === $options.CONFLICT_MARKER_THEIR">{{ - conflictText(line.right) - }}</strong> - <span v-else v-html="line.right.rich_text"></span> - </div> + class="diff-td line_content with-coverage right-side parallel" + v-html="$options.lineContent(props.line.right)" + ></div> </template> <template v-else> - <div - data-testid="right-empty-cell" - class="diff-td diff-line-num old_line empty-cell" - :class="emptyCellRightClassMap" - ></div> - <div - v-if="inline" - class="diff-td diff-line-num old_line empty-cell" - :class="emptyCellRightClassMap" - ></div> - <div - class="diff-td line-coverage right-side empty-cell" - :class="emptyCellRightClassMap" - ></div> - <div - class="diff-td line-codequality right-side empty-cell" - :class="emptyCellRightClassMap" - ></div> - <div - class="diff-td line_content with-coverage right-side empty-cell" - :class="[emptyCellRightClassMap, { parallel: !inline }]" - ></div> + <div data-testid="right-empty-cell" class="diff-td diff-line-num old_line empty-cell"></div> + <div class="diff-td line-coverage right-side empty-cell"></div> + <div class="diff-td line-codequality right-side empty-cell"></div> + <div class="diff-td line_content with-coverage right-side empty-cell parallel"></div> </template> </div> </div> diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index cd45474afcd..99999445c43 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -6,13 +6,17 @@ import { OLD_NO_NEW_LINE_TYPE, NEW_NO_NEW_LINE_TYPE, EMPTY_CELL_TYPE, + CONFLICT_MARKER_OUR, + CONFLICT_MARKER_THEIR, + CONFLICT_THEIR, + CONFLICT_OUR, } from '../constants'; -export const isHighlighted = (state, line, isCommented) => { +export const isHighlighted = (highlightedRow, line, isCommented) => { if (isCommented) return true; const lineCode = line?.line_code; - return lineCode ? lineCode === state.diffs.highlightedRow : false; + return lineCode ? lineCode === highlightedRow : false; }; export const isContextLine = (type) => type === CONTEXT_LINE_TYPE; @@ -50,13 +54,11 @@ export const classNameMapCell = ({ line, hll, isLoggedIn, isHover }) => { ]; }; -export const addCommentTooltip = (line, dragCommentSelectionEnabled = false) => { +export const addCommentTooltip = (line) => { let tooltip; if (!line) return tooltip; - tooltip = dragCommentSelectionEnabled - ? __('Add a comment to this line or drag for multiple lines') - : __('Add a comment to this line'); + tooltip = __('Add a comment to this line or drag for multiple lines'); const brokenSymlinks = line.commentsDisabled; if (brokenSymlinks) { @@ -107,6 +109,10 @@ export const mapParallel = (content) => (line) => { hasDraft: content.hasParallelDraftLeft(content.diffFile.file_hash, line), lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'left'), hasCommentForm: left.hasForm, + isConflictMarker: + line.left.type === CONFLICT_MARKER_OUR || line.left.type === CONFLICT_MARKER_THEIR, + emptyCellClassMap: { conflict_our: line.right?.type === CONFLICT_THEIR }, + addCommentTooltip: addCommentTooltip(line.left), }; } if (right) { @@ -116,6 +122,8 @@ export const mapParallel = (content) => (line) => { hasDraft: content.hasParallelDraftRight(content.diffFile.file_hash, line), lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'right'), hasCommentForm: Boolean(right.hasForm && right.type), + emptyCellClassMap: { conflict_their: line.left?.type === CONFLICT_OUR }, + addCommentTooltip: addCommentTooltip(line.right), }; } @@ -139,24 +147,3 @@ export const mapParallel = (content) => (line) => { commentRowClasses: hasDiscussions(left) || hasDiscussions(right) ? '' : 'js-temp-notes-holder', }; }; - -// TODO: Delete this function when unifiedDiffComponents FF is removed -export const mapInline = (content) => (line) => { - // Discussions/Comments - const renderCommentRow = line.hasForm || (line.discussions?.length && line.discussionsExpanded); - - return { - ...line, - renderDiscussion: Boolean(line.discussions?.length), - isMatchLine: isMatchLine(line.type), - commentRowClasses: line.discussions?.length ? '' : 'js-temp-notes-holder', - renderCommentRow, - hasDraft: content.shouldRenderDraftRow(content.diffFile.file_hash, line), - hasCommentForm: line.hasForm, - isMetaLine: isMetaLine(line.type), - isContextLine: isContextLine(line.type), - hasDiscussions: hasDiscussions(line), - lineHref: lineHref(line), - lineCode: lineCode(line), - }; -}; diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index a2a6ebaeedf..5cf242b4ddd 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -1,12 +1,15 @@ <script> import { mapGetters, mapState, mapActions } from 'vuex'; +import { IdState } from 'vendor/vue-virtual-scroller'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; +import { hide } from '~/tooltips'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DiffCommentCell from './diff_comment_cell.vue'; import DiffExpansionCell from './diff_expansion_cell.vue'; import DiffRow from './diff_row.vue'; +import { isHighlighted } from './diff_row_utils'; export default { components: { @@ -15,7 +18,11 @@ export default { DiffCommentCell, DraftNote, }, - mixins: [draftCommentsMixin, glFeatureFlagsMixin()], + mixins: [ + draftCommentsMixin, + glFeatureFlagsMixin(), + IdState({ idProp: (vm) => vm.diffFile.file_hash }), + ], props: { diffFile: { type: Object, @@ -36,15 +43,15 @@ export default { default: false, }, }, - data() { + idState() { return { dragStart: null, updatedLineRange: null, }; }, computed: { - ...mapGetters('diffs', ['commitId']), - ...mapState('diffs', ['codequalityDiff']), + ...mapGetters('diffs', ['commitId', 'fileLineCoverage']), + ...mapState('diffs', ['codequalityDiff', 'highlightedRow']), ...mapState({ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, @@ -59,45 +66,65 @@ export default { ); }, hasCodequalityChanges() { - return ( - this.glFeatures.codequalityMrDiffAnnotations && - this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0 - ); + return this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0; }, }, methods: { ...mapActions(['setSelectedCommentPosition']), - ...mapActions('diffs', ['showCommentForm']), + ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']), showCommentLeft(line) { return line.left && !line.right; }, showCommentRight(line) { return line.right && !line.left; }, - onStartDragging(line) { - this.dragStart = line; + onStartDragging({ event = {}, line }) { + if (event.target?.parentNode) { + hide(event.target.parentNode); + } + this.idState.dragStart = line; }, onDragOver(line) { - if (line.chunk !== this.dragStart.chunk) return; + if (line.chunk !== this.idState.dragStart.chunk) return; - let start = this.dragStart; + let start = this.idState.dragStart; let end = line; - if (this.dragStart.index >= line.index) { + if (this.idState.dragStart.index >= line.index) { start = line; - end = this.dragStart; + end = this.idState.dragStart; } - this.updatedLineRange = { start, end }; + this.idState.updatedLineRange = { start, end }; - this.setSelectedCommentPosition(this.updatedLineRange); + this.setSelectedCommentPosition(this.idState.updatedLineRange); }, onStopDragging() { this.showCommentForm({ - lineCode: this.updatedLineRange?.end?.line_code, + lineCode: this.idState.updatedLineRange?.end?.line_code, fileHash: this.diffFile.file_hash, }); - this.dragStart = null; + this.idState.dragStart = null; + }, + isHighlighted(line) { + return isHighlighted( + this.highlightedRow, + line.left?.line_code ? line.left : line.right, + false, + ); + }, + handleParallelLineMouseDown(e) { + const line = e.target.closest('.diff-td'); + const table = line.closest('.diff-table'); + + table.classList.remove('left-side-selected', 'right-side-selected'); + const [lineClass] = ['left-side', 'right-side'].filter((name) => + line.classList.contains(name), + ); + + if (lineClass) { + table.classList.add(`${lineClass}-selected`); + } }, }, userColorScheme: window.gon.user_color_scheme, @@ -109,6 +136,7 @@ export default { :class="[$options.userColorScheme, { inline, 'with-codequality': hasCodequalityChanges }]" :data-commit-id="commitId" class="diff-grid diff-table code diff-wrap-lines js-syntax-highlight text-file" + @mousedown="handleParallelLineMouseDown" > <template v-for="(line, index) in diffLines"> <div @@ -136,6 +164,14 @@ export default { :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" :inline="inline" :index="index" + :is-highlighted="isHighlighted(line)" + :file-line-coverage="fileLineCoverage" + @showCommentForm="(lineCode) => showCommentForm({ lineCode, fileHash: diffFile.file_hash })" + @setHighlightedRow="setHighlightedRow" + @toggleLineDiscussions=" + ({ lineCode, expanded }) => + toggleLineDiscussions({ lineCode, fileHash: diffFile.file_hash, expanded }) + " @enterdragging="onDragOver" @startdragging="onStartDragging" @stopdragging="onStopDragging" diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue deleted file mode 100644 index f903fef72b7..00000000000 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ /dev/null @@ -1,204 +0,0 @@ -<script> -import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import { mapActions, mapGetters, mapState } from 'vuex'; -import { CONTEXT_LINE_CLASS_NAME } from '../constants'; -import { getInteropInlineAttributes } from '../utils/interoperability'; -import DiffGutterAvatars from './diff_gutter_avatars.vue'; -import { - isHighlighted, - shouldShowCommentButton, - shouldRenderCommentButton, - classNameMapCell, - addCommentTooltip, -} from './diff_row_utils'; - -export default { - components: { - DiffGutterAvatars, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - SafeHtml, - }, - props: { - fileHash: { - type: String, - required: true, - }, - filePath: { - type: String, - required: true, - }, - line: { - type: Object, - required: true, - }, - isBottom: { - type: Boolean, - required: false, - default: false, - }, - isCommented: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - isHover: false, - }; - }, - computed: { - ...mapGetters(['isLoggedIn']), - ...mapGetters('diffs', ['fileLineCoverage']), - ...mapState({ - isHighlighted(state) { - return isHighlighted(state, this.line, this.isCommented); - }, - }), - classNameMap() { - return [ - this.line.type, - { - [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLine, - }, - ]; - }, - inlineRowId() { - return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`; - }, - coverageState() { - return this.fileLineCoverage(this.filePath, this.line.new_line); - }, - classNameMapCell() { - return classNameMapCell({ - line: this.line, - hll: this.isHighlighted, - isLoggedIn: this.isLoggedIn, - isHover: this.isHover, - }); - }, - addCommentTooltip() { - return addCommentTooltip(this.line); - }, - shouldRenderCommentButton() { - return shouldRenderCommentButton(this.isLoggedIn, true); - }, - shouldShowCommentButton() { - return shouldShowCommentButton( - this.isHover, - this.line.isContextLine, - this.line.isMetaLine, - this.line.hasDiscussions, - ); - }, - shouldShowAvatarsOnGutter() { - return this.line.hasDiscussions; - }, - interopAttrs() { - return getInteropInlineAttributes(this.line); - }, - }, - mounted() { - this.scrollToLineIfNeededInline(this.line); - }, - methods: { - ...mapActions('diffs', [ - 'scrollToLineIfNeededInline', - 'showCommentForm', - 'setHighlightedRow', - 'toggleLineDiscussions', - ]), - handleMouseMove(e) { - // To show the comment icon on the gutter we need to know if we hover the line. - // Current table structure doesn't allow us to do this with CSS in both of the diff view types - this.isHover = e.type === 'mouseover'; - }, - handleCommentButton() { - this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); - }, - }, -}; -</script> - -<template> - <tr - :id="inlineRowId" - :class="classNameMap" - class="line_holder" - v-bind="interopAttrs" - @mouseover="handleMouseMove" - @mouseout="handleMouseMove" - > - <td ref="oldTd" class="diff-line-num old_line" :class="classNameMapCell"> - <span - v-if="shouldRenderCommentButton" - ref="addNoteTooltip" - v-gl-tooltip - class="add-diff-note tooltip-wrapper" - :title="addCommentTooltip" - > - <button - v-show="shouldShowCommentButton" - ref="addDiffNoteButton" - type="button" - class="add-diff-note note-button js-add-diff-note-button" - :disabled="line.commentsDisabled" - :aria-label="addCommentTooltip" - @click="handleCommentButton" - > - <gl-icon :size="12" name="comment" /> - </button> - </span> - <a - v-if="line.old_line" - ref="lineNumberRefOld" - :data-linenumber="line.old_line" - :href="line.lineHref" - @click="setHighlightedRow(line.lineCode)" - > - </a> - <diff-gutter-avatars - v-if="shouldShowAvatarsOnGutter" - :discussions="line.discussions" - :discussions-expanded="line.discussionsExpanded" - @toggleLineDiscussions=" - toggleLineDiscussions({ - lineCode: line.lineCode, - fileHash, - expanded: !line.discussionsExpanded, - }) - " - /> - </td> - <td ref="newTd" class="diff-line-num new_line" :class="classNameMapCell"> - <a - v-if="line.new_line" - ref="lineNumberRefNew" - :data-linenumber="line.new_line" - :href="line.lineHref" - @click="setHighlightedRow(line.lineCode)" - > - </a> - </td> - <td - v-gl-tooltip.hover - :title="coverageState.text" - :class="[line.type, coverageState.class, { hll: isHighlighted }]" - class="line-coverage" - ></td> - <td - :key="line.line_code" - v-safe-html="line.rich_text" - :class="[ - line.type, - { - hll: isHighlighted, - }, - ]" - class="line_content with-coverage" - ></td> - </tr> -</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue deleted file mode 100644 index e407609d9e9..00000000000 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ /dev/null @@ -1,117 +0,0 @@ -<script> -import { mapGetters, mapState } from 'vuex'; -import DraftNote from '~/batch_comments/components/draft_note.vue'; -import draftCommentsMixin from '~/diffs/mixins/draft_comments'; -import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import DiffCommentCell from './diff_comment_cell.vue'; -import DiffExpansionCell from './diff_expansion_cell.vue'; -import inlineDiffTableRow from './inline_diff_table_row.vue'; - -export default { - components: { - DiffCommentCell, - inlineDiffTableRow, - DraftNote, - DiffExpansionCell, - }, - mixins: [draftCommentsMixin, glFeatureFlagsMixin()], - props: { - diffFile: { - type: Object, - required: true, - }, - diffLines: { - type: Array, - required: true, - }, - helpPagePath: { - type: String, - required: false, - default: '', - }, - }, - computed: { - ...mapGetters('diffs', ['commitId']), - ...mapState({ - selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, - selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, - }), - diffLinesLength() { - return this.diffLines.length; - }, - commentedLines() { - return getCommentedLines( - this.selectedCommentPosition || this.selectedCommentPositionHover, - this.diffLines, - ); - }, - }, - userColorScheme: window.gon.user_color_scheme, -}; -</script> - -<template> - <table - :class="$options.userColorScheme" - :data-commit-id="commitId" - class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view" - > - <colgroup> - <col style="width: 50px" /> - <col style="width: 50px" /> - <col style="width: 8px" /> - <col /> - </colgroup> - <tbody> - <template v-for="(line, index) in diffLines"> - <tr v-if="line.isMatchLine" :key="`expand-${index}`" class="line_expansion match"> - <td colspan="4" class="text-center gl-font-regular"> - <diff-expansion-cell - :file-hash="diffFile.file_hash" - :context-lines-path="diffFile.context_lines_path" - :line="line" - :is-top="index === 0" - :is-bottom="index + 1 === diffLinesLength" - /> - </td> - </tr> - <inline-diff-table-row - v-if="!line.isMatchLine" - :key="`${line.line_code || index}`" - :file-hash="diffFile.file_hash" - :file-path="diffFile.file_path" - :line="line" - :is-bottom="index + 1 === diffLinesLength" - :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" - /> - <tr - v-if="line.renderCommentRow" - :key="`icr-${line.line_code || index}`" - :class="line.commentRowClasses" - class="notes_holder" - > - <td class="notes-content" colspan="4"> - <diff-comment-cell - :diff-file-hash="diffFile.file_hash" - :line="line" - :help-page-path="helpPagePath" - :has-draft="line.hasDraft" - /> - </td> - </tr> - <tr v-if="line.hasDraft" :key="`draft_${index}`" class="notes_holder js-temp-notes-holder"> - <td class="notes-content" colspan="4"> - <div class="content"> - <draft-note - :draft="draftForLine(diffFile.file_hash, line)" - :diff-file="diffFile" - :line="line" - /> - </div> - </td> - </tr> - </template> - </tbody> - </table> -</template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue deleted file mode 100644 index 2d33926c8aa..00000000000 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ /dev/null @@ -1,310 +0,0 @@ -<script> -import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import $ from 'jquery'; -import { mapActions, mapGetters, mapState } from 'vuex'; -import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; -import { - getInteropOldSideAttributes, - getInteropNewSideAttributes, -} from '../utils/interoperability'; -import DiffGutterAvatars from './diff_gutter_avatars.vue'; -import * as utils from './diff_row_utils'; - -export default { - components: { - GlIcon, - DiffGutterAvatars, - }, - directives: { - GlTooltip: GlTooltipDirective, - SafeHtml, - }, - props: { - fileHash: { - type: String, - required: true, - }, - filePath: { - type: String, - required: true, - }, - line: { - type: Object, - required: true, - }, - isBottom: { - type: Boolean, - required: false, - default: false, - }, - isCommented: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - isLeftHover: false, - isRightHover: false, - isCommentButtonRendered: false, - }; - }, - computed: { - ...mapGetters('diffs', ['fileLineCoverage']), - ...mapGetters(['isLoggedIn']), - ...mapState({ - isHighlighted(state) { - const line = this.line.left?.line_code ? this.line.left : this.line.right; - return utils.isHighlighted(state, line, this.isCommented); - }, - }), - classNameMap() { - return { - [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLineLeft, - [PARALLEL_DIFF_VIEW_TYPE]: true, - }; - }, - parallelViewLeftLineType() { - return utils.parallelViewLeftLineType(this.line, this.isHighlighted); - }, - coverageState() { - return this.fileLineCoverage(this.filePath, this.line.right.new_line); - }, - classNameMapCellLeft() { - return utils.classNameMapCell({ - line: this.line.left, - hll: this.isHighlighted, - isLoggedIn: this.isLoggedIn, - isHover: this.isLeftHover, - }); - }, - classNameMapCellRight() { - return utils.classNameMapCell({ - line: this.line.right, - hll: this.isHighlighted, - isLoggedIn: this.isLoggedIn, - isHover: this.isRightHover, - }); - }, - addCommentTooltipLeft() { - return utils.addCommentTooltip(this.line.left); - }, - addCommentTooltipRight() { - return utils.addCommentTooltip(this.line.right); - }, - shouldRenderCommentButton() { - return utils.shouldRenderCommentButton(this.isLoggedIn, this.isCommentButtonRendered); - }, - shouldShowCommentButtonLeft() { - return utils.shouldShowCommentButton( - this.isLeftHover, - this.line.isContextLineLeft, - this.line.isMetaLineLeft, - this.line.hasDiscussionsLeft, - ); - }, - shouldShowCommentButtonRight() { - return utils.shouldShowCommentButton( - this.isRightHover, - this.line.isContextLineRight, - this.line.isMetaLineRight, - this.line.hasDiscussionsRight, - ); - }, - interopLeftAttributes() { - return getInteropOldSideAttributes(this.line.left); - }, - interopRightAttributes() { - return getInteropNewSideAttributes(this.line.right); - }, - }, - mounted() { - this.scrollToLineIfNeededParallel(this.line); - this.unwatchShouldShowCommentButton = this.$watch( - (vm) => [vm.shouldShowCommentButtonLeft, vm.shouldShowCommentButtonRight].join(), - (newVal) => { - if (newVal) { - this.isCommentButtonRendered = true; - this.unwatchShouldShowCommentButton(); - } - }, - ); - }, - beforeDestroy() { - this.unwatchShouldShowCommentButton(); - }, - methods: { - ...mapActions('diffs', [ - 'scrollToLineIfNeededParallel', - 'showCommentForm', - 'setHighlightedRow', - 'toggleLineDiscussions', - ]), - handleMouseMove(e) { - const isHover = e.type === 'mouseover'; - const hoveringCell = e.target.closest('td'); - const allCellsInHoveringRow = Array.from(e.currentTarget.children); - const hoverIndex = allCellsInHoveringRow.indexOf(hoveringCell); - - if (hoverIndex >= 3) { - this.isRightHover = isHover; - } else { - this.isLeftHover = isHover; - } - }, - // Prevent text selecting on both sides of parallel diff view - // Backport of the same code from legacy diff notes. - handleParallelLineMouseDown(e) { - const line = $(e.currentTarget); - const table = line.closest('table'); - - table.removeClass('left-side-selected right-side-selected'); - const [lineClass] = ['left-side', 'right-side'].filter((name) => line.hasClass(name)); - - if (lineClass) { - table.addClass(`${lineClass}-selected`); - } - }, - handleCommentButton(line) { - this.showCommentForm({ lineCode: line.line_code, fileHash: this.fileHash }); - }, - }, -}; -</script> - -<template> - <tr - :class="classNameMap" - class="line_holder" - @mouseover="handleMouseMove" - @mouseout="handleMouseMove" - > - <template v-if="line.left && !line.isMatchLineLeft"> - <td ref="oldTd" :class="classNameMapCellLeft" class="diff-line-num old_line"> - <span - v-if="shouldRenderCommentButton" - ref="addNoteTooltipLeft" - v-gl-tooltip - class="add-diff-note tooltip-wrapper" - :title="addCommentTooltipLeft" - > - <button - v-show="shouldShowCommentButtonLeft" - ref="addDiffNoteButtonLeft" - type="button" - class="add-diff-note note-button js-add-diff-note-button" - :disabled="line.left.commentsDisabled" - :aria-label="addCommentTooltipLeft" - @click="handleCommentButton(line.left)" - > - <gl-icon :size="12" name="comment" /> - </button> - </span> - <a - v-if="line.left.old_line" - ref="lineNumberRefOld" - :data-linenumber="line.left.old_line" - :href="line.lineHrefOld" - @click="setHighlightedRow(line.lineCode)" - > - </a> - <diff-gutter-avatars - v-if="line.hasDiscussionsLeft" - :discussions="line.left.discussions" - :discussions-expanded="line.left.discussionsExpanded" - @toggleLineDiscussions=" - toggleLineDiscussions({ - lineCode: line.left.line_code, - fileHash, - expanded: !line.left.discussionsExpanded, - }) - " - /> - </td> - <td :class="parallelViewLeftLineType" class="line-coverage left-side"></td> - <td - :id="line.left.line_code" - :key="line.left.line_code" - v-safe-html="line.left.rich_text" - :class="parallelViewLeftLineType" - v-bind="interopLeftAttributes" - class="line_content with-coverage parallel left-side" - @mousedown="handleParallelLineMouseDown" - ></td> - </template> - <template v-else> - <td class="diff-line-num old_line empty-cell"></td> - <td class="line-coverage left-side empty-cell"></td> - <td class="line_content with-coverage parallel left-side empty-cell"></td> - </template> - <template v-if="line.right && !line.isMatchLineRight"> - <td ref="newTd" :class="classNameMapCellRight" class="diff-line-num new_line"> - <span - v-if="shouldRenderCommentButton" - ref="addNoteTooltipRight" - v-gl-tooltip - class="add-diff-note tooltip-wrapper" - :title="addCommentTooltipRight" - > - <button - v-show="shouldShowCommentButtonRight" - ref="addDiffNoteButtonRight" - type="button" - class="add-diff-note note-button js-add-diff-note-button" - :disabled="line.right.commentsDisabled" - :aria-label="addCommentTooltipRight" - @click="handleCommentButton(line.right)" - > - <gl-icon :size="12" name="comment" /> - </button> - </span> - <a - v-if="line.right.new_line" - ref="lineNumberRefNew" - :data-linenumber="line.right.new_line" - :href="line.lineHrefNew" - @click="setHighlightedRow(line.lineCode)" - > - </a> - <diff-gutter-avatars - v-if="line.hasDiscussionsRight" - :discussions="line.right.discussions" - :discussions-expanded="line.right.discussionsExpanded" - @toggleLineDiscussions=" - toggleLineDiscussions({ - lineCode: line.right.line_code, - fileHash, - expanded: !line.right.discussionsExpanded, - }) - " - /> - </td> - <td - v-gl-tooltip.hover - :title="coverageState.text" - :class="[line.right.type, coverageState.class, { hll: isHighlighted }]" - class="line-coverage right-side" - ></td> - <td - :id="line.right.line_code" - :key="line.right.rich_text" - v-safe-html="line.right.rich_text" - :class="[ - line.right.type, - { - hll: isHighlighted, - }, - ]" - v-bind="interopRightAttributes" - class="line_content with-coverage parallel right-side" - @mousedown="handleParallelLineMouseDown" - ></td> - </template> - <template v-else> - <td class="diff-line-num old_line empty-cell"></td> - <td class="line-coverage right-side empty-cell"></td> - <td class="line_content with-coverage parallel right-side empty-cell"></td> - </template> - </tr> -</template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue deleted file mode 100644 index b167081a379..00000000000 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ /dev/null @@ -1,142 +0,0 @@ -<script> -import { mapGetters, mapState } from 'vuex'; -import DraftNote from '~/batch_comments/components/draft_note.vue'; -import draftCommentsMixin from '~/diffs/mixins/draft_comments'; -import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; -import DiffCommentCell from './diff_comment_cell.vue'; -import DiffExpansionCell from './diff_expansion_cell.vue'; -import parallelDiffTableRow from './parallel_diff_table_row.vue'; - -export default { - components: { - DiffExpansionCell, - parallelDiffTableRow, - DiffCommentCell, - DraftNote, - }, - mixins: [draftCommentsMixin], - props: { - diffFile: { - type: Object, - required: true, - }, - diffLines: { - type: Array, - required: true, - }, - helpPagePath: { - type: String, - required: false, - default: '', - }, - }, - computed: { - ...mapGetters('diffs', ['commitId']), - ...mapState({ - selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, - selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, - }), - diffLinesLength() { - return this.diffLines.length; - }, - commentedLines() { - return getCommentedLines( - this.selectedCommentPosition || this.selectedCommentPositionHover, - this.diffLines, - ); - }, - }, - userColorScheme: window.gon.user_color_scheme, -}; -</script> - -<template> - <table - :class="$options.userColorScheme" - :data-commit-id="commitId" - class="code diff-wrap-lines js-syntax-highlight text-file" - > - <colgroup> - <col style="width: 50px" /> - <col style="width: 8px" /> - <col /> - <col style="width: 50px" /> - <col style="width: 8px" /> - <col /> - </colgroup> - <tbody> - <template v-for="(line, index) in diffLines"> - <tr - v-if="line.isMatchLineLeft || line.isMatchLineRight" - :key="`expand-${index}`" - class="line_expansion match" - > - <td colspan="6" class="text-center gl-font-regular"> - <diff-expansion-cell - :file-hash="diffFile.file_hash" - :context-lines-path="diffFile.context_lines_path" - :line="line.left" - :is-top="index === 0" - :is-bottom="index + 1 === diffLinesLength" - /> - </td> - </tr> - <parallel-diff-table-row - :key="line.line_code" - :file-hash="diffFile.file_hash" - :file-path="diffFile.file_path" - :line="line" - :is-bottom="index + 1 === diffLinesLength" - :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" - /> - <tr - v-if="line.renderCommentRow" - :key="`dcr-${line.line_code || index}`" - :class="line.commentRowClasses" - class="notes_holder" - > - <td class="notes-content parallel old" colspan="3"> - <diff-comment-cell - v-if="line.left" - :line="line.left" - :diff-file-hash="diffFile.file_hash" - :help-page-path="helpPagePath" - :has-draft="line.left.hasDraft" - line-position="left" - /> - </td> - <td class="notes-content parallel new" colspan="3"> - <diff-comment-cell - v-if="line.right" - :line="line.right" - :diff-file-hash="diffFile.file_hash" - :line-index="index" - :help-page-path="helpPagePath" - :has-draft="line.right.hasDraft" - line-position="right" - /> - </td> - </tr> - <tr - v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)" - :key="`drafts-${index}`" - :class="line.draftRowClasses" - class="notes_holder" - > - <td class="notes_line old"></td> - <td class="notes-content parallel old" colspan="2"> - <div v-if="line.left && line.left.lineDraft.isDraft" class="content"> - <draft-note :draft="line.left.lineDraft" :line="line.left" /> - </div> - </td> - <td class="notes_line new"></td> - <td class="notes-content parallel new" colspan="2"> - <div v-if="line.right && line.right.lineDraft.isDraft" class="content"> - <draft-note :draft="line.right.lineDraft" :line="line.right" /> - </div> - </td> - </tr> - </template> - </tbody> - </table> -</template> diff --git a/app/assets/javascripts/diffs/components/pre_renderer.vue b/app/assets/javascripts/diffs/components/pre_renderer.vue new file mode 100644 index 00000000000..c357aa2d924 --- /dev/null +++ b/app/assets/javascripts/diffs/components/pre_renderer.vue @@ -0,0 +1,84 @@ +<script> +export default { + inject: ['vscrollParent'], + props: { + maxLength: { + type: Number, + required: true, + }, + }, + data() { + return { + nextIndex: -1, + nextItem: null, + startedRender: false, + width: 0, + }; + }, + mounted() { + this.width = this.$el.parentNode.offsetWidth; + window.test = this; + + this.$_itemsWithSizeWatcher = this.$watch('vscrollParent.itemsWithSize', async () => { + await this.$nextTick(); + + const nextItem = this.findNextToRender(); + + if (nextItem) { + this.startedRender = true; + requestIdleCallback(() => { + this.nextItem = nextItem; + + if (this.nextIndex === this.maxLength - 1) { + this.$nextTick(() => { + if (this.vscrollParent.itemsWithSize[this.maxLength - 1].size !== 0) { + this.clearRendering(); + } + }); + } + }); + } else if (this.startedRender) { + this.clearRendering(); + } + }); + }, + beforeDestroy() { + this.$_itemsWithSizeWatcher(); + }, + methods: { + clearRendering() { + this.nextItem = null; + + if (this.maxLength === this.vscrollParent.itemsWithSize.length) { + this.$_itemsWithSizeWatcher(); + } + }, + findNextToRender() { + return this.vscrollParent.itemsWithSize.find(({ size }, index) => { + const isNext = size === 0; + + if (isNext) { + this.nextIndex = index; + } + + return isNext; + }); + }, + }, +}; +</script> + +<template> + <div v-if="nextItem" :style="{ width: `${width}px` }" class="gl-absolute diff-file-offscreen"> + <slot + v-bind="{ item: nextItem.item, index: nextIndex, active: true, itemWithSize: nextItem }" + ></slot> + </div> +</template> + +<style scoped> +.diff-file-offscreen { + top: -200%; + left: -200%; +} +</style> diff --git a/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js new file mode 100644 index 00000000000..984c6f8c0c9 --- /dev/null +++ b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js @@ -0,0 +1,51 @@ +import { handleLocationHash } from '~/lib/utils/common_utils'; + +export default { + inject: ['vscrollParent'], + props: { + index: { + type: Number, + required: true, + }, + }, + watch: { + index: { + handler() { + const { index } = this; + + if (index < 0) return; + + if (this.vscrollParent.itemsWithSize[index].size) { + this.scrollToIndex(index); + } else { + this.$_itemsWithSizeWatcher = this.$watch('vscrollParent.itemsWithSize', async () => { + await this.$nextTick(); + + if (this.vscrollParent.itemsWithSize[index].size) { + this.$_itemsWithSizeWatcher(); + this.scrollToIndex(index); + + await this.$nextTick(); + } + }); + } + }, + immediate: true, + }, + }, + beforeDestroy() { + if (this.$_itemsWithSizeWatcher) this.$_itemsWithSizeWatcher(); + }, + methods: { + scrollToIndex(index) { + this.vscrollParent.scrollToItem(index); + + setTimeout(() => { + handleLocationHash(); + }); + }, + }, + render(h) { + return h(null); + }, +}; diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index d1e02fbc598..f1cf556fde0 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -59,7 +59,6 @@ export const MIN_RENDERING_MS = 2; export const START_RENDERING_INDEX = 200; export const INLINE_DIFF_LINES_KEY = 'highlighted_diff_lines'; export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines'; -export const DIFFS_PER_PAGE = 20; export const DIFF_COMPARE_BASE_VERSION_INDEX = -1; export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 0ab72749760..ea83523008c 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -50,9 +50,6 @@ export default function initDiffsApp(store) { click: this.openFile, }, class: ['diff-file-finder'], - style: { - display: this.fileFinderVisible ? '' : 'none', - }, }); }, }); diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 2e94f147086..66510edf3db 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -25,7 +25,6 @@ import { MIN_RENDERING_MS, START_RENDERING_INDEX, INLINE_DIFF_LINES_KEY, - DIFFS_PER_PAGE, DIFF_FILE_MANUAL_COLLAPSE, DIFF_FILE_AUTOMATIC_COLLAPSE, EVT_PERF_MARK_FILE_TREE_START, @@ -92,22 +91,18 @@ export const setBaseConfig = ({ commit }, options) => { }; export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { - const diffsGradualLoad = window.gon?.features?.diffsGradualLoad; - let perPage = DIFFS_PER_PAGE; + let perPage = state.viewDiffsFileByFile ? 1 : 5; let increaseAmount = 1.4; - - if (diffsGradualLoad) { - perPage = state.viewDiffsFileByFile ? 1 : 5; - } - - const startPage = diffsGradualLoad ? 0 : 1; + const startPage = 0; const id = window?.location?.hash; const isNoteLink = id.indexOf('#note') === 0; const urlParams = { w: state.showWhitespace ? '0' : '1', view: 'inline', }; + const hash = window.location.hash.replace('#', '').split('diff-content-').pop(); let totalLoaded = 0; + let scrolledVirtualScroller = false; commit(types.SET_BATCH_LOADING, true); commit(types.SET_RETRIEVING_BATCHES, true); @@ -122,6 +117,18 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { commit(types.SET_DIFF_DATA_BATCH, { diff_files }); commit(types.SET_BATCH_LOADING, false); + if (window.gon?.features?.diffsVirtualScrolling && !scrolledVirtualScroller) { + const index = state.diffFiles.findIndex( + (f) => + f.file_hash === hash || f[INLINE_DIFF_LINES_KEY].find((l) => l.line_code === hash), + ); + + if (index >= 0) { + eventHub.$emit('scrollToIndex', index); + scrolledVirtualScroller = true; + } + } + if (!isNoteLink && !state.currentDiffFileId) { commit(types.VIEW_DIFF_FILE, diff_files[0].file_hash); } @@ -130,11 +137,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { dispatch('setCurrentDiffFileIdFromNote', id.split('_').pop()); } - if ( - (diffsGradualLoad && - (totalLoaded === pagination.total_pages || pagination.total_pages === null)) || - (!diffsGradualLoad && !pagination.next_page) - ) { + if (totalLoaded === pagination.total_pages || pagination.total_pages === null) { commit(types.SET_RETRIEVING_BATCHES, false); // We need to check that the currentDiffFileId points to a file that exists @@ -164,15 +167,11 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { return null; } - if (diffsGradualLoad) { - const nextPage = page + perPage; - perPage = Math.min(Math.ceil(perPage * increaseAmount), 30); - increaseAmount = Math.min(increaseAmount + 0.2, 2); - - return nextPage; - } + const nextPage = page + perPage; + perPage = Math.min(Math.ceil(perPage * increaseAmount), 30); + increaseAmount = Math.min(increaseAmount + 0.2, 2); - return pagination.next_page; + return nextPage; }) .then((nextPage) => { dispatch('startRenderDiffsQueue'); @@ -186,7 +185,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { .catch(() => commit(types.SET_RETRIEVING_BATCHES, false)); return getBatch() - .then(handleLocationHash) + .then(() => !window.gon?.features?.diffsVirtualScrolling && handleLocationHash()) .catch(() => null); }; @@ -250,6 +249,8 @@ export const setHighlightedRow = ({ commit }, lineCode) => { const fileHash = lineCode.split('_')[0]; commit(types.SET_HIGHLIGHTED_ROW, lineCode); commit(types.VIEW_DIFF_FILE, fileHash); + + handleLocationHash(); }; // This is adding line discussions to the actual lines in the diff tree @@ -523,9 +524,18 @@ export const scrollToFile = ({ state, commit }, path) => { if (!state.treeEntries[path]) return; const { fileHash } = state.treeEntries[path]; - document.location.hash = fileHash; commit(types.VIEW_DIFF_FILE, fileHash); + + if (window.gon?.features?.diffsVirtualScrolling) { + eventHub.$emit('scrollToFileHash', fileHash); + + setTimeout(() => { + window.history.replaceState(null, null, `#${fileHash}`); + }); + } else { + document.location.hash = fileHash; + } }; export const setShowTreeList = ({ commit }, { showTreeList, saving = true }) => { @@ -570,7 +580,7 @@ export const setShowWhitespace = async ( { state, commit }, { url, showWhitespace, updateDatabase = true }, ) => { - if (updateDatabase) { + if (updateDatabase && Boolean(window.gon?.current_user_id)) { await axios.put(url || state.endpointUpdateUser, { show_whitespace_in_diffs: showWhitespace }); } diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index a536db5c417..1b6a673925f 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -151,11 +151,7 @@ export const currentDiffIndex = (state) => state.diffFiles.findIndex((diff) => diff.file_hash === state.currentDiffFileId), ); -export const diffLines = (state) => (file, unifiedDiffComponents) => { - if (!unifiedDiffComponents && state.diffViewType === INLINE_DIFF_VIEW_TYPE) { - return null; - } - +export const diffLines = (state) => (file) => { return parallelizeDiffLines( file.highlighted_diff_lines || [], state.diffViewType === INLINE_DIFF_VIEW_TYPE, diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js index 673ec821b58..65ffd42fa27 100644 --- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js +++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js @@ -1,4 +1,5 @@ -import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import { DIFF_COMPARE_BASE_VERSION_INDEX, DIFF_COMPARE_HEAD_VERSION_INDEX } from '../constants'; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 75d2cf43b94..3f1af68e37a 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -381,9 +381,15 @@ function prepareDiffFileLines(file) { } function finalizeDiffFile(file, index) { + let renderIt = Boolean(window.gon?.features?.diffsVirtualScrolling); + + if (!window.gon?.features?.diffsVirtualScrolling) { + renderIt = + index < 3 ? file[INLINE_DIFF_LINES_KEY].length < LINES_TO_BE_RENDERED_DIRECTLY : false; + } + Object.assign(file, { - renderIt: - index < 3 ? file[INLINE_DIFF_LINES_KEY].length < LINES_TO_BE_RENDERED_DIRECTLY : false, + renderIt, isShowingFullFile: false, isLoadingFullFile: false, discussions: [], diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index c991316dda2..849ff91841a 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -1,14 +1,15 @@ +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __ } from '~/locale'; -export const EDITOR_LITE_INSTANCE_ERROR_NO_EL = __( +export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = __( '"el" parameter is required for createInstance()', ); export const URI_PREFIX = 'gitlab'; -export const CONTENT_UPDATE_DEBOUNCE = 250; +export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __( - 'Editor Lite instance is required to set up an extension.', + 'Source Editor instance is required to set up an extension.', ); export const EDITOR_READY_EVENT = 'editor-ready'; diff --git a/app/assets/javascripts/editor/extensions/editor_file_template_ext.js b/app/assets/javascripts/editor/extensions/editor_file_template_ext.js deleted file mode 100644 index f5474318447..00000000000 --- a/app/assets/javascripts/editor/extensions/editor_file_template_ext.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Position } from 'monaco-editor'; -import { EditorLiteExtension } from './editor_lite_extension_base'; - -export class FileTemplateExtension extends EditorLiteExtension { - navigateFileStart() { - this.setPosition(new Position(1, 1)); - } -} diff --git a/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js index c5ee61ec86e..410aaed86a7 100644 --- a/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js @@ -1,9 +1,9 @@ import Api from '~/api'; import { registerSchema } from '~/ide/utils'; import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '../constants'; -import { EditorLiteExtension } from './editor_lite_extension_base'; +import { SourceEditorExtension } from './source_editor_extension_base'; -export class CiSchemaExtension extends EditorLiteExtension { +export class CiSchemaExtension extends SourceEditorExtension { /** * Registers a syntax schema to the editor based on project * identifier and commit. diff --git a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js index 05a020bd958..5fa01f03f7e 100644 --- a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js +++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js @@ -16,15 +16,15 @@ const createAnchor = (href) => { return fragment; }; -export class EditorLiteExtension { +export class SourceEditorExtension { constructor({ instance, ...options } = {}) { if (instance) { Object.assign(instance, options); - EditorLiteExtension.highlightLines(instance); + SourceEditorExtension.highlightLines(instance); if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) { - EditorLiteExtension.setupLineLinking(instance); + SourceEditorExtension.setupLineLinking(instance); } - EditorLiteExtension.deferRerender(instance); + SourceEditorExtension.deferRerender(instance); } else if (Object.entries(options).length) { throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); } @@ -79,7 +79,7 @@ export class EditorLiteExtension { } static setupLineLinking(instance) { - instance.onMouseMove(EditorLiteExtension.onMouseMoveHandler); + instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler); instance.onMouseDown((e) => { const isCorrectAnchor = e.target.element.classList.contains('link-anchor'); if (!isCorrectAnchor) { diff --git a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js new file mode 100644 index 00000000000..397e090ed30 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js @@ -0,0 +1,8 @@ +import { Position } from 'monaco-editor'; +import { SourceEditorExtension } from './source_editor_extension_base'; + +export class FileTemplateExtension extends SourceEditorExtension { + navigateFileStart() { + this.setPosition(new Position(1, 1)); + } +} diff --git a/app/assets/javascripts/editor/extensions/editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js index 2ce003753f7..997503a12f5 100644 --- a/app/assets/javascripts/editor/extensions/editor_markdown_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js @@ -1,6 +1,6 @@ -import { EditorLiteExtension } from './editor_lite_extension_base'; +import { SourceEditorExtension } from './source_editor_extension_base'; -export class EditorMarkdownExtension extends EditorLiteExtension { +export class EditorMarkdownExtension extends SourceEditorExtension { getSelectedText(selection = this.getSelection()) { const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; const valArray = this.getValue().split('\n'); diff --git a/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js index 83b0386d470..98e05489c1c 100644 --- a/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js @@ -1,7 +1,7 @@ import { debounce } from 'lodash'; import { KeyCode, KeyMod, Range } from 'monaco-editor'; import { EDITOR_TYPE_DIFF } from '~/editor/constants'; -import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import Disposable from '~/ide/lib/common/disposable'; import { editorOptions } from '~/ide/lib/editor_options'; import keymap from '~/ide/lib/keymap.json'; @@ -12,7 +12,7 @@ const isDiffEditorType = (instance) => { export const UPDATE_DIMENSIONS_DELAY = 200; -export class EditorWebIdeExtension extends EditorLiteExtension { +export class EditorWebIdeExtension extends SourceEditorExtension { constructor({ instance, modelManager, ...options } = {}) { super({ instance, diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/source_editor.js index 249888ede9b..ee97714824e 100644 --- a/app/assets/javascripts/editor/editor_lite.js +++ b/app/assets/javascripts/editor/source_editor.js @@ -6,23 +6,23 @@ import { registerLanguages } from '~/ide/utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { uuids } from '~/lib/utils/uuids'; import { - EDITOR_LITE_INSTANCE_ERROR_NO_EL, + SOURCE_EDITOR_INSTANCE_ERROR_NO_EL, URI_PREFIX, EDITOR_READY_EVENT, EDITOR_TYPE_DIFF, } from './constants'; import { clearDomElement } from './utils'; -export default class EditorLite { +export default class SourceEditor { constructor(options = {}) { this.instances = []; this.options = { - extraEditorClassName: 'gl-editor-lite', + extraEditorClassName: 'gl-source-editor', ...defaultEditorOptions, ...options, }; - EditorLite.setupMonacoTheme(); + SourceEditor.setupMonacoTheme(); registerLanguages(...languages); } @@ -56,7 +56,7 @@ export default class EditorLite { extensionsArray.forEach((ext) => { const prefix = ext.includes('/') ? '' : 'editor/'; const trimmedExt = ext.replace(/^\//, '').trim(); - EditorLite.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`); + SourceEditor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`); }); return Promise.all(promises); @@ -77,7 +77,7 @@ export default class EditorLite { static prepareInstance(el) { if (!el) { - throw new Error(EDITOR_LITE_INSTANCE_ERROR_NO_EL); + throw new Error(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL); } clearDomElement(el); @@ -88,7 +88,7 @@ export default class EditorLite { } static manageDefaultExtensions(instance, el, extensions) { - EditorLite.loadExtensions(extensions, instance) + SourceEditor.loadExtensions(extensions, instance) .then((modules) => { if (modules) { modules.forEach((module) => { @@ -126,7 +126,7 @@ export default class EditorLite { const diffModel = { original: monacoEditor.createModel( blobOriginalContent, - EditorLite.getModelLanguage(model.uri.path), + SourceEditor.getModelLanguage(model.uri.path), ), modified: model, }; @@ -135,18 +135,18 @@ export default class EditorLite { } static convertMonacoToELInstance = (inst) => { - const editorLiteInstanceAPI = { + const sourceEditorInstanceAPI = { updateModelLanguage: (path) => { - return EditorLite.instanceUpdateLanguage(inst, path); + return SourceEditor.instanceUpdateLanguage(inst, path); }, use: (exts = []) => { - return EditorLite.instanceApplyExtension(inst, exts); + return SourceEditor.instanceApplyExtension(inst, exts); }, }; const handler = { get(target, prop, receiver) { - if (Reflect.has(editorLiteInstanceAPI, prop)) { - return editorLiteInstanceAPI[prop]; + if (Reflect.has(sourceEditorInstanceAPI, prop)) { + return sourceEditorInstanceAPI[prop]; } return Reflect.get(target, prop, receiver); }, @@ -155,7 +155,7 @@ export default class EditorLite { }; static instanceUpdateLanguage(inst, path) { - const lang = EditorLite.getModelLanguage(path); + const lang = SourceEditor.getModelLanguage(path); const model = inst.getModel(); return monacoEditor.setModelLanguage(model, lang); } @@ -163,7 +163,7 @@ export default class EditorLite { static instanceApplyExtension(inst, exts = []) { const extensions = [].concat(exts); extensions.forEach((extension) => { - EditorLite.mixIntoInstance(extension, inst); + SourceEditor.mixIntoInstance(extension, inst); }); return inst; } @@ -210,10 +210,10 @@ export default class EditorLite { isDiff = false, ...instanceOptions } = {}) { - EditorLite.prepareInstance(el); + SourceEditor.prepareInstance(el); const createEditorFn = isDiff ? 'createDiffEditor' : 'create'; - const instance = EditorLite.convertMonacoToELInstance( + const instance = SourceEditor.convertMonacoToELInstance( monacoEditor[createEditorFn].call(this, el, { ...this.options, ...instanceOptions, @@ -222,7 +222,7 @@ export default class EditorLite { let model; if (instanceOptions.model !== null) { - model = EditorLite.createEditorModel({ + model = SourceEditor.createEditorModel({ blobGlobalId, blobOriginalContent, blobPath, @@ -233,11 +233,11 @@ export default class EditorLite { } instance.onDidDispose(() => { - EditorLite.instanceRemoveFromRegistry(this, instance); - EditorLite.instanceDisposeModels(this, instance, model); + SourceEditor.instanceRemoveFromRegistry(this, instance); + SourceEditor.instanceDisposeModels(this, instance, model); }); - EditorLite.manageDefaultExtensions(instance, el, extensions); + SourceEditor.manageDefaultExtensions(instance, el, extensions); this.instances.push(instance); return instance; diff --git a/app/assets/javascripts/emoji/components/emoji_group.vue b/app/assets/javascripts/emoji/components/emoji_group.vue index 539cd6963b1..4f4c32af113 100644 --- a/app/assets/javascripts/emoji/components/emoji_group.vue +++ b/app/assets/javascripts/emoji/components/emoji_group.vue @@ -17,6 +17,7 @@ export default { }; </script> +<!-- eslint-disable-next-line vue/no-deprecated-functional-template --> <template functional> <div class="gl-display-flex gl-flex-wrap gl-mb-2"> <template v-if="props.renderGroup"> diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue index 217cea051b7..c642a07fd1e 100644 --- a/app/assets/javascripts/environments/components/deploy_board.vue +++ b/app/assets/javascripts/environments/components/deploy_board.vue @@ -111,7 +111,7 @@ export default { </script> <template> <div class="js-deploy-board deploy-board"> - <gl-loading-icon v-if="isLoading" class="loading-icon" /> + <gl-loading-icon v-if="isLoading" size="sm" class="loading-icon" /> <template v-else> <div v-if="canRenderDeployBoard" class="deploy-board-information gl-p-5"> <div class="deploy-board-information gl-w-full"> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 542b8c9219d..2d98f00433a 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -80,7 +80,7 @@ export default { <template #button-content> <gl-icon name="play" /> <gl-icon name="chevron-down" /> - <gl-loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" size="sm" /> </template> <gl-dropdown-item v-for="(action, i) in actions" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 4db0dff16aa..5ae8b000fc0 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -552,6 +552,9 @@ export default { { 'gl-display-none gl-md-display-block': !this.upcomingDeployment }, ]; }, + tableNameSpacingClass() { + return this.isFolder ? 'section-100' : this.tableData.name.spacing; + }, }, methods: { @@ -588,8 +591,9 @@ export default { > <div class="table-section section-wrap text-truncate" - :class="tableData.name.spacing" + :class="tableNameSpacingClass" role="gridcell" + data-testid="environment-name-cell" > <div v-if="!isFolder" class="table-mobile-header" role="rowheader"> {{ getMobileViewTitleForField('name') }} @@ -632,9 +636,11 @@ export default { </div> <div + v-if="!isFolder" class="table-section deployment-column d-none d-md-block" :class="tableData.deploy.spacing" role="gridcell" + data-testid="enviornment-deployment-id-cell" > <span v-if="shouldRenderDeploymentID" class="text-break-word"> {{ deploymentInternalId }} @@ -656,7 +662,13 @@ export default { </div> </div> - <div class="table-section d-none d-md-block" :class="tableData.build.spacing" role="gridcell"> + <div + v-if="!isFolder" + class="table-section d-none d-md-block" + :class="tableData.build.spacing" + role="gridcell" + data-testid="environment-build-cell" + > <a v-if="shouldRenderBuildName" :href="buildPath" class="build-link cgray"> <tooltip-on-truncate :title="buildName" diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 8bd71db957c..e4cf5760987 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -1,6 +1,6 @@ <script> import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; import environmentsMixin from '../mixins/environments_mixin'; @@ -89,7 +89,9 @@ export default { .then((response) => this.store.setfolderContent(folder, response.data.environments)) .then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false)) .catch(() => { - Flash(s__('Environments|An error occurred while fetching the environments.')); + createFlash({ + message: s__('Environments|An error occurred while fetching the environments.'), + }); this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false); }); }, @@ -133,7 +135,7 @@ export default { >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button > </div> - <gl-tabs content-class="gl-display-none"> + <gl-tabs :value="activeTab" content-class="gl-display-none"> <gl-tab v-for="(tab, idx) in tabs" :key="idx" diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index f82d3065ca5..61438872afc 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -152,8 +152,7 @@ export default { </div> </div> <template v-for="(model, i) in sortedEnvironments" :model="model"> - <div - is="environment-item" + <environment-item :key="`environment-item-${i}`" :model="model" :can-read-environment="canReadEnvironment" @@ -189,8 +188,7 @@ export default { <template v-else> <template v-for="(child, index) in model.children"> - <div - is="environment-item" + <environment-item :key="`environment-row-${i}-${index}`" :model="child" :can-read-environment="canReadEnvironment" diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index d5caff1660a..6f701f87261 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -3,9 +3,9 @@ */ import { isEqual, isFunction, omitBy } from 'lodash'; import Visibility from 'visibilityjs'; -import { deprecatedCreateFlash as Flash } from '../../flash'; -import { getParameterByName } from '../../lib/utils/common_utils'; +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'; @@ -94,7 +94,9 @@ export default { errorCallback() { this.isLoading = false; - Flash(s__('Environments|An error occurred while fetching the environments.')); + createFlash({ + message: s__('Environments|An error occurred while fetching the environments.'), + }); }, postAction({ @@ -109,7 +111,9 @@ export default { .then(() => this.fetchEnvironments()) .catch((err) => { this.isLoading = false; - Flash(isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage); + createFlash({ + message: isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage, + }); }); } }, @@ -163,7 +167,9 @@ export default { window.location.href = url.join('/'); }) .catch(() => { - Flash(errorMessage); + createFlash({ + message: errorMessage, + }); }); }, @@ -202,6 +208,9 @@ export default { }, ]; }, + activeTab() { + return this.tabs.findIndex(({ isActive }) => isActive) ?? 0; + }, }, /** diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index dd320676e98..68b4438831e 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -82,7 +82,7 @@ export default { <div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()"> <gl-icon :name="collapseIcon" :size="16" class="gl-mr-2" /> </div> - <file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" /> + <file-icon :file-name="filePath" :size="16" aria-hidden="true" css-classes="gl-mr-2" /> <strong v-gl-tooltip :title="filePath" diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js index c945a9e2316..d402d0336d9 100644 --- a/app/assets/javascripts/error_tracking_settings/store/actions.js +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -48,7 +48,6 @@ export const receiveSettingsError = ({ commit }, { response = {} }) => { createFlash({ message: `${__('There was an error saving your changes.')} ${message}`, - type: 'alert', }); commit(types.UPDATE_SETTINGS_LOADING, false); }; diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue index 77e40039b43..d86e13ce722 100644 --- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue +++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue @@ -196,6 +196,7 @@ export default { /> <gl-loading-icon v-if="isRotating" + size="sm" class="gl-absolute gl-align-self-center gl-right-5 gl-mr-7" /> diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue index e7f4b51c964..dde021b67be 100644 --- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue @@ -1,10 +1,8 @@ <script> import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import axios from '~/lib/utils/axios_utils'; import { sprintf, s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { LEGACY_FLAG } from '../constants'; import FeatureFlagForm from './form.vue'; export default { @@ -15,59 +13,29 @@ export default { FeatureFlagForm, }, mixins: [glFeatureFlagMixin()], - inject: { - showUserCallout: {}, - userCalloutId: { - default: '', - }, - userCalloutsPath: { - default: '', - }, - }, - data() { - return { - userShouldSeeNewFlagAlert: this.showUserCallout, - }; - }, - translations: { - legacyReadOnlyFlagAlert: s__( - 'FeatureFlags|GitLab is moving to a new way of managing feature flags. This feature flag is read-only, and it will be removed in 14.0. Please create a new feature flag.', - ), - }, computed: { ...mapState([ 'path', 'error', 'name', 'description', - 'scopes', 'strategies', 'isLoading', 'hasError', 'iid', 'active', - 'version', ]), title() { return this.iid ? `^${this.iid} ${this.name}` : sprintf(s__('Edit %{name}'), { name: this.name }); }, - deprecated() { - return this.version === LEGACY_FLAG; - }, }, created() { return this.fetchFeatureFlag(); }, methods: { ...mapActions(['updateFeatureFlag', 'fetchFeatureFlag', 'toggleActive']), - dismissNewVersionFlagAlert() { - this.userShouldSeeNewFlagAlert = false; - axios.post(this.userCalloutsPath, { - feature_name: this.userCalloutId, - }); - }, }, }; </script> @@ -76,9 +44,6 @@ export default { <gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-7" /> <template v-else-if="!isLoading && !hasError"> - <gl-alert v-if="deprecated" variant="warning" :dismissible="false" class="gl-my-5">{{ - $options.translations.legacyReadOnlyFlagAlert - }}</gl-alert> <div class="gl-display-flex gl-align-items-center gl-mb-4 gl-mt-4"> <gl-toggle :value="active" @@ -100,12 +65,10 @@ export default { <feature-flag-form :name="name" :description="description" - :scopes="scopes" :strategies="strategies" :cancel-path="path" :submit-text="__('Save changes')" :active="active" - :version="version" @handleSubmit="(data) => updateFeatureFlag(data)" /> </template> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index d08e8d2b3a1..53909dcf42e 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -3,11 +3,8 @@ import { GlAlert, GlBadge, GlButton, GlModalDirective, GlSprintf } from '@gitlab import { isEmpty } from 'lodash'; import { mapState, mapActions } from 'vuex'; -import { - buildUrlWithCurrentLocation, - getParameterByName, - historyPushState, -} from '~/lib/utils/common_utils'; +import { buildUrlWithCurrentLocation, historyPushState } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue'; import EmptyState from './empty_state.vue'; diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue index 9220077af71..cfd838bf5a1 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -1,8 +1,7 @@ <script> -import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle, GlIcon } from '@gitlab/ui'; +import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, NEW_VERSION_FLAG, LEGACY_FLAG } from '../constants'; import { labelForStrategy } from '../utils'; export default { @@ -14,7 +13,6 @@ export default { components: { GlBadge, GlButton, - GlIcon, GlModal, GlToggle, }, @@ -35,13 +33,7 @@ export default { deleteFeatureFlagName: null, }; }, - translations: { - legacyFlagReadOnlyAlert: s__('FeatureFlags|Flag is read-only'), - }, computed: { - permissions() { - return this.glFeatures.featureFlagPermissions; - }, modalTitle() { return sprintf(s__('FeatureFlags|Delete %{name}?'), { name: this.deleteFeatureFlagName, @@ -57,12 +49,6 @@ export default { }, }, methods: { - isLegacyFlag(flag) { - return flag.version !== NEW_VERSION_FLAG; - }, - statusToggleDisabled(flag) { - return flag.version === LEGACY_FLAG; - }, scopeTooltipText(scope) { return !scope.active ? sprintf(s__('FeatureFlags|Inactive flag for %{scope}'), { @@ -70,22 +56,6 @@ export default { }) : ''; }, - badgeText(scope) { - const displayName = - scope.environmentScope === '*' - ? s__('FeatureFlags|* (All environments)') - : scope.environmentScope; - - const displayPercentage = - scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT - ? `: ${scope.rolloutPercentage}%` - : ''; - - return `${displayName}${displayPercentage}`; - }, - badgeVariant(scope) { - return scope.active ? 'info' : 'muted'; - }, strategyBadgeText(strategy) { return labelForStrategy(strategy); }, @@ -142,7 +112,6 @@ export default { <gl-toggle v-if="featureFlag.update_path" :value="featureFlag.active" - :disabled="statusToggleDisabled(featureFlag)" :label="$options.i18n.toggleLabel" label-position="hidden" data-testid="feature-flag-status-toggle" @@ -169,12 +138,6 @@ export default { <div class="feature-flag-name text-monospace text-truncate"> {{ featureFlag.name }} </div> - <gl-icon - v-if="isLegacyFlag(featureFlag)" - v-gl-tooltip.hover="$options.translations.legacyFlagReadOnlyAlert" - class="gl-ml-3" - name="information-o" - /> </div> <div class="feature-flag-description text-secondary text-truncate"> {{ featureFlag.description }} @@ -189,27 +152,14 @@ export default { <div class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments" > - <template v-if="isLegacyFlag(featureFlag)"> - <gl-badge - v-for="scope in featureFlag.scopes" - :key="scope.id" - v-gl-tooltip.hover="scopeTooltipText(scope)" - :variant="badgeVariant(scope)" - :data-qa-selector="`feature-flag-scope-${badgeVariant(scope)}-badge`" - class="gl-mr-3 gl-mt-2" - >{{ badgeText(scope) }}</gl-badge - > - </template> - <template v-else> - <gl-badge - v-for="strategy in featureFlag.strategies" - :key="strategy.id" - data-testid="strategy-badge" - variant="info" - class="gl-mr-3 gl-mt-2 gl-white-space-normal gl-text-left gl-px-5" - >{{ strategyBadgeText(strategy) }}</gl-badge - > - </template> + <gl-badge + v-for="strategy in featureFlag.strategies" + :key="strategy.id" + data-testid="strategy-badge" + variant="info" + class="gl-mr-3 gl-mt-2 gl-white-space-normal gl-text-left gl-px-5" + >{{ strategyBadgeText(strategy) }}</gl-badge + > </div> </div> diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue index 67ddceaf080..f7ad2c1f106 100644 --- a/app/assets/javascripts/feature_flags/components/form.vue +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -1,16 +1,6 @@ <script> -import { - GlButton, - GlBadge, - GlTooltip, - GlTooltipDirective, - GlFormTextarea, - GlFormCheckbox, - GlSprintf, - GlIcon, - GlToggle, -} from '@gitlab/ui'; -import { memoize, isString, cloneDeep, isNumber, uniqueId } from 'lodash'; +import { GlButton } from '@gitlab/ui'; +import { memoize, cloneDeep, isNumber, uniqueId } from 'lodash'; import Vue from 'vue'; import { s__ } from '~/locale'; import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; @@ -20,12 +10,8 @@ import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, ROLLOUT_STRATEGY_USER_ID, ALL_ENVIRONMENTS_NAME, - INTERNAL_ID_PREFIX, NEW_VERSION_FLAG, - LEGACY_FLAG, } from '../constants'; -import { createNewEnvironmentScope } from '../store/helpers'; -import EnvironmentsDropdown from './environments_dropdown.vue'; import Strategy from './strategy.vue'; export default { @@ -35,20 +21,9 @@ export default { }, components: { GlButton, - GlBadge, - GlFormTextarea, - GlFormCheckbox, - GlTooltip, - GlSprintf, - GlIcon, - GlToggle, - EnvironmentsDropdown, Strategy, RelatedIssuesRoot, }, - directives: { - GlTooltip: GlTooltipDirective, - }, mixins: [featureFlagsMixin()], inject: { featureFlagIssuesEndpoint: { @@ -71,11 +46,6 @@ export default { required: false, default: '', }, - scopes: { - type: Array, - required: false, - default: () => [], - }, cancelPath: { type: String, required: true, @@ -89,11 +59,6 @@ export default { required: false, default: () => [], }, - version: { - type: String, - required: false, - default: LEGACY_FLAG, - }, }, translations: { allEnvironmentsText: s__('FeatureFlags|* (All Environments)'), @@ -120,35 +85,18 @@ export default { formName: this.name, formDescription: this.description, - // operate on a clone to avoid mutating props - formScopes: this.scopes.map((s) => ({ ...s })), formStrategies: cloneDeep(this.strategies), newScope: '', }; }, computed: { - filteredScopes() { - return this.formScopes.filter((scope) => !scope.shouldBeDestroyed); - }, filteredStrategies() { return this.formStrategies.filter((s) => !s.shouldBeDestroyed); }, - canUpdateFlag() { - return !this.permissionsFlag || (this.formScopes || []).every((scope) => scope.canUpdate); - }, - permissionsFlag() { - return this.glFeatures.featureFlagPermissions; - }, - supportsStrategies() { - return this.version === NEW_VERSION_FLAG; - }, showRelatedIssues() { return this.featureFlagIssuesEndpoint.length > 0; }, - readOnly() { - return this.version === LEGACY_FLAG; - }, }, methods: { keyFor(strategy) { @@ -174,37 +122,6 @@ export default { isAllEnvironment(name) { return name === ALL_ENVIRONMENTS_NAME; }, - - /** - * When the user clicks the remove button we delete the scope - * - * If the scope has an ID, we need to add the `shouldBeDestroyed` flag. - * If the scope does *not* have an ID, we can just remove it. - * - * This flag will be used when submitting the data to the backend - * to determine which records to delete (via a "_destroy" property). - * - * @param {Object} scope - */ - removeScope(scope) { - if (isString(scope.id) && scope.id.startsWith(INTERNAL_ID_PREFIX)) { - this.formScopes = this.formScopes.filter((s) => s !== scope); - } else { - Vue.set(scope, 'shouldBeDestroyed', true); - } - }, - - /** - * Creates a new scope and adds it to the list of scopes - * - * @param overrides An object whose properties will - * be used override the default scope options - */ - createNewScope(overrides) { - this.formScopes.push(createNewEnvironmentScope(overrides, this.permissionsFlag)); - this.newScope = ''; - }, - /** * When the user clicks the submit button * it triggers an event with the form data @@ -214,61 +131,16 @@ export default { name: this.formName, description: this.formDescription, active: this.active, - version: this.version, + version: NEW_VERSION_FLAG, + strategies: this.formStrategies, }; - if (this.version === LEGACY_FLAG) { - flag.scopes = this.formScopes; - } else { - flag.strategies = this.formStrategies; - } - this.$emit('handleSubmit', flag); }, - canUpdateScope(scope) { - return !this.permissionsFlag || scope.canUpdate; - }, - isRolloutPercentageInvalid: memoize(function isRolloutPercentageInvalid(percentage) { return !this.$options.rolloutPercentageRegex.test(percentage); }), - - /** - * Generates a unique ID for the strategy based on the v-for index - * - * @param index The index of the strategy - */ - rolloutStrategyId(index) { - return `rollout-strategy-${index}`; - }, - - /** - * Generates a unique ID for the percentage based on the v-for index - * - * @param index The index of the percentage - */ - rolloutPercentageId(index) { - return `rollout-percentage-${index}`; - }, - rolloutUserId(index) { - return `rollout-user-id-${index}`; - }, - - shouldDisplayIncludeUserIds(scope) { - return ![ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_USER_ID].includes( - scope.rolloutStrategy, - ); - }, - shouldDisplayUserIds(scope) { - return scope.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID || scope.shouldIncludeUserIds; - }, - onStrategyChange(index) { - const scope = this.filteredScopes[index]; - scope.shouldIncludeUserIds = - scope.rolloutUserIds.length > 0 && - scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT; - }, onFormStrategyChange(strategy, index) { Object.assign(this.filteredStrategies[index], strategy); }, @@ -281,12 +153,7 @@ export default { <div class="row"> <div class="form-group col-md-4"> <label for="feature-flag-name" class="label-bold">{{ s__('FeatureFlags|Name') }} *</label> - <input - id="feature-flag-name" - v-model="formName" - :disabled="!canUpdateFlag" - class="form-control" - /> + <input id="feature-flag-name" v-model="formName" class="form-control" /> </div> </div> @@ -298,7 +165,6 @@ export default { <textarea id="feature-flag-description" v-model="formDescription" - :disabled="!canUpdateFlag" class="form-control" rows="4" ></textarea> @@ -312,277 +178,35 @@ export default { :show-categorized-issues="false" /> - <template v-if="supportsStrategies"> - <div class="row"> - <div class="col-md-12"> - <h4>{{ s__('FeatureFlags|Strategies') }}</h4> - <div class="flex align-items-baseline justify-content-between"> - <p class="mr-3">{{ $options.translations.newHelpText }}</p> - <gl-button variant="confirm" category="secondary" @click="addStrategy"> - {{ s__('FeatureFlags|Add strategy') }} - </gl-button> - </div> - </div> - </div> - <div v-if="filteredStrategies.length > 0" data-testid="feature-flag-strategies"> - <strategy - v-for="(strategy, index) in filteredStrategies" - :key="keyFor(strategy)" - :strategy="strategy" - :index="index" - @change="onFormStrategyChange($event, index)" - @delete="deleteStrategy(strategy)" - /> - </div> - <div v-else class="flex justify-content-center border-top py-4 w-100"> - <span>{{ $options.translations.noStrategiesText }}</span> - </div> - </template> - - <div v-else class="row"> - <div class="form-group col-md-12"> - <h4>{{ s__('FeatureFlags|Target environments') }}</h4> - <gl-sprintf :message="$options.translations.helpText"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - <template #bold="{ content }"> - <b>{{ content }}</b> - </template> - </gl-sprintf> - - <div class="js-scopes-table gl-mt-3"> - <div class="gl-responsive-table-row table-row-header" role="row"> - <div class="table-section section-30" role="columnheader"> - {{ s__('FeatureFlags|Environment Spec') }} - </div> - <div class="table-section section-20 text-center" role="columnheader"> - {{ s__('FeatureFlags|Status') }} - </div> - <div class="table-section section-40" role="columnheader"> - {{ s__('FeatureFlags|Rollout Strategy') }} - </div> - </div> - - <div - v-for="(scope, index) in filteredScopes" - :key="scope.id" - ref="scopeRow" - class="gl-responsive-table-row" - role="row" - > - <div class="table-section section-30" role="gridcell"> - <div class="table-mobile-header" role="rowheader"> - {{ s__('FeatureFlags|Environment Spec') }} - </div> - <div - class="table-mobile-content gl-display-flex gl-align-items-center gl-justify-content-start" - > - <p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3"> - {{ $options.translations.allEnvironmentsText }} - </p> - - <environments-dropdown - v-else - class="col-12" - :value="scope.environmentScope" - :disabled="!canUpdateScope(scope) || scope.environmentScope !== ''" - @selectEnvironment="(env) => (scope.environmentScope = env)" - @createClicked="(env) => (scope.environmentScope = env)" - @clearInput="(env) => (scope.environmentScope = '')" - /> - - <gl-badge v-if="permissionsFlag && scope.protected" variant="success"> - {{ s__('FeatureFlags|Protected') }} - </gl-badge> - </div> - </div> - - <div class="table-section section-20 text-center" role="gridcell"> - <div class="table-mobile-header" role="rowheader"> - {{ $options.i18n.statusLabel }} - </div> - <div class="table-mobile-content gl-display-flex gl-justify-content-center"> - <gl-toggle - :value="scope.active" - :disabled="!active || !canUpdateScope(scope)" - :label="$options.i18n.statusLabel" - label-position="hidden" - @change="(status) => (scope.active = status)" - /> - </div> - </div> - - <div class="table-section section-40" role="gridcell"> - <div class="table-mobile-header" role="rowheader"> - {{ s__('FeatureFlags|Rollout Strategy') }} - </div> - <div class="table-mobile-content js-rollout-strategy form-inline"> - <label class="sr-only" :for="rolloutStrategyId(index)"> - {{ s__('FeatureFlags|Rollout Strategy') }} - </label> - <div class="select-wrapper col-12 col-md-8 p-0"> - <select - :id="rolloutStrategyId(index)" - v-model="scope.rolloutStrategy" - :disabled="!scope.active" - class="form-control select-control w-100 js-rollout-strategy" - @change="onStrategyChange(index)" - > - <option :value="$options.ROLLOUT_STRATEGY_ALL_USERS"> - {{ s__('FeatureFlags|All users') }} - </option> - <option :value="$options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT"> - {{ s__('FeatureFlags|Percent rollout (logged in users)') }} - </option> - <option :value="$options.ROLLOUT_STRATEGY_USER_ID"> - {{ s__('FeatureFlags|User IDs') }} - </option> - </select> - <gl-icon - name="chevron-down" - class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" - :size="16" - /> - </div> - - <div - v-if="scope.rolloutStrategy === $options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT" - class="d-flex-center mt-2 mt-md-0 ml-md-2" - > - <label class="sr-only" :for="rolloutPercentageId(index)"> - {{ s__('FeatureFlags|Rollout Percentage') }} - </label> - <div class="gl-w-9"> - <input - :id="rolloutPercentageId(index)" - v-model="scope.rolloutPercentage" - :disabled="!scope.active" - :class="{ - 'is-invalid': isRolloutPercentageInvalid(scope.rolloutPercentage), - }" - type="number" - min="0" - max="100" - :pattern="$options.rolloutPercentageRegex.source" - class="rollout-percentage js-rollout-percentage form-control text-right w-100" - /> - </div> - <gl-tooltip - v-if="isRolloutPercentageInvalid(scope.rolloutPercentage)" - :target="rolloutPercentageId(index)" - > - {{ - s__( - 'FeatureFlags|Percent rollout must be an integer number between 0 and 100', - ) - }} - </gl-tooltip> - <span class="ml-1">%</span> - </div> - <div class="d-flex flex-column align-items-start mt-2 w-100"> - <gl-form-checkbox - v-if="shouldDisplayIncludeUserIds(scope)" - v-model="scope.shouldIncludeUserIds" - >{{ s__('FeatureFlags|Include additional user IDs') }}</gl-form-checkbox - > - <template v-if="shouldDisplayUserIds(scope)"> - <label :for="rolloutUserId(index)" class="mb-2"> - {{ s__('FeatureFlags|User IDs') }} - </label> - <gl-form-textarea - :id="rolloutUserId(index)" - v-model="scope.rolloutUserIds" - class="w-100" - /> - </template> - </div> - </div> - </div> - - <div class="table-section section-10 text-right" role="gridcell"> - <div class="table-mobile-header" role="rowheader"> - {{ s__('FeatureFlags|Remove') }} - </div> - <div class="table-mobile-content"> - <gl-button - v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)" - v-gl-tooltip - :title="$options.i18n.removeLabel" - :aria-label="$options.i18n.removeLabel" - class="js-delete-scope btn-transparent pr-3 pl-3" - icon="clear" - data-testid="feature-flag-delete" - @click="removeScope(scope)" - /> - </div> - </div> - </div> - - <div class="gl-responsive-table-row" role="row" data-testid="add-new-scope"> - <div class="table-section section-30" role="gridcell"> - <div class="table-mobile-header" role="rowheader"> - {{ s__('FeatureFlags|Environment Spec') }} - </div> - <div class="table-mobile-content"> - <environments-dropdown - class="js-new-scope-name col-12" - :value="newScope" - @selectEnvironment="(env) => createNewScope({ environmentScope: env })" - @createClicked="(env) => createNewScope({ environmentScope: env })" - /> - </div> - </div> - - <div class="table-section section-20 text-center" role="gridcell"> - <div class="table-mobile-header" role="rowheader"> - {{ $options.i18n.statusLabel }} - </div> - <div class="table-mobile-content gl-display-flex gl-justify-content-center"> - <gl-toggle - :disabled="!active" - :label="$options.i18n.statusLabel" - label-position="hidden" - :value="false" - @change="createNewScope({ active: true })" - /> - </div> - </div> - - <div class="table-section section-40" role="gridcell"> - <div class="table-mobile-header" role="rowheader"> - {{ s__('FeatureFlags|Rollout Strategy') }} - </div> - <div class="table-mobile-content js-rollout-strategy form-inline"> - <label class="sr-only" for="new-rollout-strategy-placeholder">{{ - s__('FeatureFlags|Rollout Strategy') - }}</label> - <div class="select-wrapper col-12 col-md-8 p-0"> - <select - id="new-rollout-strategy-placeholder" - disabled - class="form-control select-control w-100" - > - <option>{{ s__('FeatureFlags|All users') }}</option> - </select> - <gl-icon - name="chevron-down" - class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" - :size="16" - /> - </div> - </div> - </div> - </div> + <div class="row"> + <div class="col-md-12"> + <h4>{{ s__('FeatureFlags|Strategies') }}</h4> + <div class="flex align-items-baseline justify-content-between"> + <p class="mr-3">{{ $options.translations.newHelpText }}</p> + <gl-button variant="confirm" category="secondary" @click="addStrategy"> + {{ s__('FeatureFlags|Add strategy') }} + </gl-button> </div> </div> </div> + <div v-if="filteredStrategies.length > 0" data-testid="feature-flag-strategies"> + <strategy + v-for="(strategy, index) in filteredStrategies" + :key="keyFor(strategy)" + :strategy="strategy" + :index="index" + @change="onFormStrategyChange($event, index)" + @delete="deleteStrategy(strategy)" + /> + </div> + <div v-else class="flex justify-content-center border-top py-4 w-100"> + <span>{{ $options.translations.noStrategiesText }}</span> + </div> </fieldset> <div class="form-actions"> <gl-button ref="submitButton" - :disabled="readOnly" type="button" variant="confirm" class="js-ff-submit col-xs-12" diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue index c59e3178b09..5575c6567b5 100644 --- a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue +++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue @@ -80,7 +80,7 @@ export default { @focus="fetchEnvironments" @keyup="fetchEnvironments" /> - <gl-loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" size="sm" /> <gl-dropdown-item v-for="environment in results" v-else-if="results.length" diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue index 19be57f9d27..865c1e677cd 100644 --- a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue @@ -1,10 +1,8 @@ <script> import { GlAlert } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import axios from '~/lib/utils/axios_utils'; import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { NEW_VERSION_FLAG, ROLLOUT_STRATEGY_ALL_USERS } from '../constants'; -import { createNewEnvironmentScope } from '../store/helpers'; +import { ROLLOUT_STRATEGY_ALL_USERS } from '../constants'; import FeatureFlagForm from './form.vue'; export default { @@ -13,48 +11,14 @@ export default { GlAlert, }, mixins: [featureFlagsMixin()], - inject: { - showUserCallout: {}, - userCalloutId: { - default: '', - }, - userCalloutsPath: { - default: '', - }, - }, - data() { - return { - userShouldSeeNewFlagAlert: this.showUserCallout, - }; - }, computed: { ...mapState(['error', 'path']), - scopes() { - return [ - createNewEnvironmentScope( - { - environmentScope: '*', - active: true, - }, - this.glFeatures.featureFlagsPermissions, - ), - ]; - }, - version() { - return NEW_VERSION_FLAG; - }, strategies() { return [{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }]; }, }, methods: { ...mapActions(['createFeatureFlag']), - dismissNewVersionFlagAlert() { - this.userShouldSeeNewFlagAlert = false; - axios.post(this.userCalloutsPath, { - feature_name: this.userCalloutId, - }); - }, }, }; </script> @@ -69,9 +33,7 @@ export default { <feature-flag-form :cancel-path="path" :submit-text="s__('FeatureFlags|Create feature flag')" - :scopes="scopes" :strategies="strategies" - :version="version" @handleSubmit="(data) => createFeatureFlag(data)" /> </div> diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue index 45fc37da747..9dbffe75f6b 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue @@ -76,7 +76,7 @@ export default { @focus="fetchUserLists" @keyup="fetchUserLists" /> - <gl-loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" size="sm" /> <gl-dropdown-item v-for="list in userLists" :key="list.id" diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js index 010674592f8..98dee7c7e97 100644 --- a/app/assets/javascripts/feature_flags/edit.js +++ b/app/assets/javascripts/feature_flags/edit.js @@ -1,6 +1,5 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import { parseBoolean } from '~/lib/utils/common_utils'; import EditFeatureFlag from './components/edit_feature_flag.vue'; import createStore from './store/edit'; @@ -16,9 +15,6 @@ export default () => { environmentsEndpoint, projectId, featureFlagIssuesEndpoint, - userCalloutsPath, - userCalloutId, - showUserCallout, } = el.dataset; return new Vue({ @@ -30,9 +26,6 @@ export default () => { environmentsEndpoint, projectId, featureFlagIssuesEndpoint, - userCalloutsPath, - userCalloutId, - showUserCallout: parseBoolean(showUserCallout), }, render(createElement) { return createElement(EditFeatureFlag); diff --git a/app/assets/javascripts/feature_flags/store/edit/actions.js b/app/assets/javascripts/feature_flags/store/edit/actions.js index 54c7e8c4453..8656479190a 100644 --- a/app/assets/javascripts/feature_flags/store/edit/actions.js +++ b/app/assets/javascripts/feature_flags/store/edit/actions.js @@ -2,8 +2,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import { NEW_VERSION_FLAG } from '../../constants'; -import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers'; +import { mapStrategiesToRails } from '../helpers'; import * as types from './mutation_types'; /** @@ -19,12 +18,7 @@ export const updateFeatureFlag = ({ state, dispatch }, params) => { dispatch('requestUpdateFeatureFlag'); axios - .put( - state.endpoint, - params.version === NEW_VERSION_FLAG - ? mapStrategiesToRails(params) - : mapFromScopesViewModel(params), - ) + .put(state.endpoint, mapStrategiesToRails(params)) .then(() => { dispatch('receiveUpdateFeatureFlagSuccess'); visitUrl(state.path); diff --git a/app/assets/javascripts/feature_flags/store/edit/mutations.js b/app/assets/javascripts/feature_flags/store/edit/mutations.js index 0a610f4b395..3882cb2dfff 100644 --- a/app/assets/javascripts/feature_flags/store/edit/mutations.js +++ b/app/assets/javascripts/feature_flags/store/edit/mutations.js @@ -1,5 +1,5 @@ import { LEGACY_FLAG } from '../../constants'; -import { mapToScopesViewModel, mapStrategiesToViewModel } from '../helpers'; +import { mapStrategiesToViewModel } from '../helpers'; import * as types from './mutation_types'; export default { @@ -14,7 +14,6 @@ export default { state.description = response.description; state.iid = response.iid; state.active = response.active; - state.scopes = mapToScopesViewModel(response.scopes); state.strategies = mapStrategiesToViewModel(response.strategies); state.version = response.version || LEGACY_FLAG; }, diff --git a/app/assets/javascripts/feature_flags/store/helpers.js b/app/assets/javascripts/feature_flags/store/helpers.js index 2fa20e25f4e..300709f2771 100644 --- a/app/assets/javascripts/feature_flags/store/helpers.js +++ b/app/assets/javascripts/feature_flags/store/helpers.js @@ -1,149 +1,4 @@ -import { isEmpty, uniqueId, isString } from 'lodash'; -import { - ROLLOUT_STRATEGY_ALL_USERS, - ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - ROLLOUT_STRATEGY_USER_ID, - ROLLOUT_STRATEGY_GITLAB_USER_LIST, - INTERNAL_ID_PREFIX, - DEFAULT_PERCENT_ROLLOUT, - PERCENT_ROLLOUT_GROUP_ID, - fetchPercentageParams, - fetchUserIdParams, - LEGACY_FLAG, -} from '../constants'; - -/** - * Converts raw scope objects fetched from the API into an array of scope - * objects that is easier/nicer to bind to in Vue. - * @param {Array} scopesFromRails An array of scope objects fetched from the API - */ -export const mapToScopesViewModel = (scopesFromRails) => - (scopesFromRails || []).map((s) => { - const percentStrategy = (s.strategies || []).find( - (strat) => strat.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - ); - - const rolloutPercentage = fetchPercentageParams(percentStrategy) || DEFAULT_PERCENT_ROLLOUT; - - const userStrategy = (s.strategies || []).find( - (strat) => strat.name === ROLLOUT_STRATEGY_USER_ID, - ); - - const rolloutStrategy = - (percentStrategy && percentStrategy.name) || - (userStrategy && userStrategy.name) || - ROLLOUT_STRATEGY_ALL_USERS; - - const rolloutUserIds = (fetchUserIdParams(userStrategy) || '') - .split(',') - .filter((id) => id) - .join(', '); - - return { - id: s.id, - environmentScope: s.environment_scope, - active: Boolean(s.active), - canUpdate: Boolean(s.can_update), - protected: Boolean(s.protected), - rolloutStrategy, - rolloutPercentage, - rolloutUserIds, - - // eslint-disable-next-line no-underscore-dangle - shouldBeDestroyed: Boolean(s._destroy), - shouldIncludeUserIds: rolloutUserIds.length > 0 && percentStrategy !== null, - }; - }); -/** - * Converts the parameters emitted by the Vue component into - * the shape that the Rails API expects. - * @param {Array} scopesFromVue An array of scope objects from the Vue component - */ -export const mapFromScopesViewModel = (params) => { - const scopes = (params.scopes || []).map((s) => { - const parameters = {}; - if (s.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT) { - parameters.groupId = PERCENT_ROLLOUT_GROUP_ID; - parameters.percentage = s.rolloutPercentage; - } else if (s.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID) { - parameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ','); - } - - const userIdParameters = {}; - - if (s.shouldIncludeUserIds && s.rolloutStrategy !== ROLLOUT_STRATEGY_USER_ID) { - userIdParameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ','); - } - - // Strip out any internal IDs - const id = isString(s.id) && s.id.startsWith(INTERNAL_ID_PREFIX) ? undefined : s.id; - - const strategies = [ - { - name: s.rolloutStrategy, - parameters, - }, - ]; - - if (!isEmpty(userIdParameters)) { - strategies.push({ name: ROLLOUT_STRATEGY_USER_ID, parameters: userIdParameters }); - } - - return { - id, - environment_scope: s.environmentScope, - active: s.active, - can_update: s.canUpdate, - protected: s.protected, - _destroy: s.shouldBeDestroyed, - strategies, - }; - }); - - const model = { - operations_feature_flag: { - name: params.name, - description: params.description, - active: params.active, - scopes_attributes: scopes, - version: LEGACY_FLAG, - }, - }; - - return model; -}; - -/** - * Creates a new feature flag environment scope object for use - * in a Vue component. An optional parameter can be passed to - * override the property values that are created by default. - * - * @param {Object} overrides An optional object whose - * property values will be used to override the default values. - * - */ -export const createNewEnvironmentScope = (overrides = {}, featureFlagPermissions = false) => { - const defaultScope = { - environmentScope: '', - active: false, - id: uniqueId(INTERNAL_ID_PREFIX), - rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, - rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, - rolloutUserIds: '', - }; - - const newScope = { - ...defaultScope, - ...overrides, - }; - - if (featureFlagPermissions) { - newScope.canUpdate = true; - newScope.protected = false; - } - - return newScope; -}; +import { ROLLOUT_STRATEGY_GITLAB_USER_LIST, NEW_VERSION_FLAG } from '../constants'; const mapStrategyScopesToRails = (scopes) => scopes.length === 0 @@ -206,8 +61,8 @@ export const mapStrategiesToRails = (params) => ({ operations_feature_flag: { name: params.name, description: params.description, - version: params.version, active: params.active, strategies_attributes: (params.strategies || []).map(mapStrategyToRails), + version: NEW_VERSION_FLAG, }, }); diff --git a/app/assets/javascripts/feature_flags/store/index/mutations.js b/app/assets/javascripts/feature_flags/store/index/mutations.js index 54e48a4b80c..7e08440c299 100644 --- a/app/assets/javascripts/feature_flags/store/index/mutations.js +++ b/app/assets/javascripts/feature_flags/store/index/mutations.js @@ -1,10 +1,7 @@ import Vue from 'vue'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; -import { mapToScopesViewModel } from '../helpers'; import * as types from './mutation_types'; -const mapFlag = (flag) => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) }); - const updateFlag = (state, flag) => { const index = state.featureFlags.findIndex(({ id }) => id === flag.id); Vue.set(state.featureFlags, index, flag); @@ -31,7 +28,7 @@ export default { [types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) { state.isLoading = false; state.hasError = false; - state.featureFlags = (response.data.feature_flags || []).map(mapFlag); + state.featureFlags = response.data.feature_flags || []; const paginationInfo = createPaginationInfo(response.headers); state.count = paginationInfo?.total ?? state.featureFlags.length; @@ -58,7 +55,7 @@ export default { updateFlag(state, flag); }, [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state, data) { - updateFlag(state, mapFlag(data)); + updateFlag(state, data); }, [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) { const flag = state.featureFlags.find(({ id }) => i === id); diff --git a/app/assets/javascripts/feature_flags/store/new/actions.js b/app/assets/javascripts/feature_flags/store/new/actions.js index d0a1c77a69e..dc3f7a21cdb 100644 --- a/app/assets/javascripts/feature_flags/store/new/actions.js +++ b/app/assets/javascripts/feature_flags/store/new/actions.js @@ -1,7 +1,6 @@ import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; -import { NEW_VERSION_FLAG } from '../../constants'; -import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers'; +import { mapStrategiesToRails } from '../helpers'; import * as types from './mutation_types'; /** @@ -17,12 +16,7 @@ export const createFeatureFlag = ({ state, dispatch }, params) => { dispatch('requestCreateFeatureFlag'); return axios - .post( - state.endpoint, - params.version === NEW_VERSION_FLAG - ? mapStrategiesToRails(params) - : mapFromScopesViewModel(params), - ) + .post(state.endpoint, mapStrategiesToRails(params)) .then(() => { dispatch('receiveCreateFeatureFlagSuccess'); visitUrl(state.path); diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js index 7b4bed69fb8..747f368b671 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '../lib/utils/axios_utils'; import { __ } from '../locale'; @@ -10,10 +10,10 @@ export function dismiss(endpoint, highlightId) { feature_name: highlightId, }) .catch(() => - Flash( - __( + createFlash({ + message: __( 'An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.', ), - ), + }), ); } diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index 38a5bdd4a71..d00e6e59cf5 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -75,6 +75,13 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { icon: 'approval', tag: '@approved-by', }, + tokenAlternative: { + formattedKey: __('Approved-By'), + key: 'approved-by', + type: 'string', + param: 'usernames', + symbol: '@', + }, condition: [ { url: 'approved_by_usernames[]=None', @@ -105,7 +112,11 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { const tokenPosition = 3; IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]); - IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]); + IssuableTokenKeys.tokenKeysWithAlternative.splice( + tokenPosition, + 0, + ...[approvedBy.token, approvedBy.tokenAlternative], + ); IssuableTokenKeys.conditions.push(...approvedBy.condition); const environmentToken = { diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 626a5669067..e0281b8f443 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -1,3 +1,4 @@ +import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; import { mergeUrlParams } from '../lib/utils/url_utility'; import DropdownAjaxFilter from './dropdown_ajax_filter'; import DropdownEmoji from './dropdown_emoji'; @@ -87,6 +88,7 @@ export default class AvailableDropdownMappings { extraArguments: { endpoint: this.getMilestoneEndpoint(), symbol: '%', + preprocessing: (milestones) => milestones.sort(sortMilestonesByDueDate), }, element: this.container.querySelector('#js-dropdown-milestone'), }, diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js index 35c79891458..545719ee681 100644 --- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -1,6 +1,6 @@ +import createFlash from '~/flash'; import { __ } from '~/locale'; import AjaxFilter from '../droplab/plugins/ajax_filter'; -import createFlash from '../flash'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; import FilteredSearchTokenizer from './filtered_search_tokenizer'; diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index 91af3a6b812..a7648a3c463 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -1,7 +1,7 @@ +import createFlash from '~/flash'; import { __ } from '~/locale'; import Ajax from '../droplab/plugins/ajax'; import Filter from '../droplab/plugins/filter'; -import createFlash from '../flash'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index 93051b00756..f78644a3893 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -1,7 +1,7 @@ +import createFlash from '~/flash'; import { __ } from '~/locale'; import Ajax from '../droplab/plugins/ajax'; import Filter from '../droplab/plugins/filter'; -import createFlash from '../flash'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 707205a6502..5ba69f052c9 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,7 +1,7 @@ import { last } from 'lodash'; import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; import { ENTER_KEY_CODE, BACKSPACE_KEY_CODE, @@ -10,9 +10,8 @@ import { DOWN_KEY_CODE, } from '~/lib/utils/keycodes'; import { __ } from '~/locale'; -import createFlash from '../flash'; import { addClassIfElementExists } from '../lib/utils/dom_utils'; -import { visitUrl } from '../lib/utils/url_utility'; +import { visitUrl, getUrlParamsArray, getParameterByName } from '../lib/utils/url_utility'; import FilteredSearchContainer from './container'; import DropdownUtils from './dropdown_utils'; import eventHub from './event_hub'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index eec4db41b0a..7143cb50ea6 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,4 +1,5 @@ -import { objectToQueryString, spriteIcon } from '~/lib/utils/common_utils'; +import { spriteIcon } from '~/lib/utils/common_utils'; +import { objectToQuery } from '~/lib/utils/url_utility'; import FilteredSearchContainer from './container'; import VisualTokenValue from './visual_token_value'; @@ -327,7 +328,7 @@ export default class FilteredSearchVisualTokens { return endpoint; } - const queryString = objectToQueryString(JSON.parse(endpointQueryParams)); + const queryString = objectToQuery(JSON.parse(endpointQueryParams)); return `${endpoint}?${queryString}`; } diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index 7f4445ad4c7..707add10009 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -4,7 +4,7 @@ import * as Emoji from '~/emoji'; import FilteredSearchContainer from '~/filtered_search/container'; import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import AjaxCache from '~/lib/utils/ajax_cache'; import UsersCache from '~/lib/utils/users_cache'; import { __ } from '~/locale'; @@ -83,7 +83,11 @@ export default class VisualTokenValue { matchingLabel.text_color, ); }) - .catch(() => new Flash(__('An error occurred while fetching label colors.'))); + .catch(() => + createFlash({ + message: __('An error occurred while fetching label colors.'), + }), + ); } updateEpicLabel(tokenValueContainer) { @@ -105,7 +109,11 @@ export default class VisualTokenValue { VisualTokenValue.replaceEpicTitle(tokenValueContainer, matchingEpic.title, matchingEpic.id); }) - .catch(() => new Flash(__('An error occurred while adding formatted title for epic'))); + .catch(() => + createFlash({ + message: __('An error occurred while adding formatted title for epic'), + }), + ); } static replaceEpicTitle(tokenValueContainer, epicTitle, epicId) { diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 2edb6e79d3b..741171b185a 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -125,38 +125,11 @@ const createFlash = function createFlash({ return flashContainer; }; -/* - * Flash banner supports different types of Flash configurations - * along with ability to provide actionConfig which can be used to show - * additional action or link on banner next to message - * - * @param {String} message Flash message text - * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default) - * @param {Object} parent Reference to parent element under which Flash needs to appear - * @param {Object} actionConfig Map of config to show action on banner - * @param {String} href URL to which action config should point to (default: '#') - * @param {String} title Title of action - * @param {Function} clickHandler Method to call when action is clicked on - * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out - */ -const deprecatedCreateFlash = function deprecatedCreateFlash( - message, - type, - parent, - actionConfig, - fadeTransition, - addBodyClass, -) { - return createFlash({ message, type, parent, actionConfig, fadeTransition, addBodyClass }); -}; - export { createFlash as default, - deprecatedCreateFlash, createFlashEl, createAction, hideFlash, removeFlashClickListener, FLASH_TYPES, }; -window.Flash = createFlash; diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 893b74a9895..0fb70fb831e 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -1,9 +1,8 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar'; -const isRefactoring = document.body.classList.contains('sidebar-refactoring'); const HIDE_INTERVAL_TIMEOUT = 300; -const COLLAPSED_PANEL_WIDTH = isRefactoring ? 48 : 50; +const COLLAPSED_PANEL_WIDTH = 48; const IS_OVER_CLASS = 'is-over'; const IS_ABOVE_CLASS = 'is-above'; const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out'; @@ -89,12 +88,12 @@ export const moveSubItemsToPosition = (el, subItems) => { const boundingRect = el.getBoundingClientRect(); const left = sidebar ? sidebar.offsetWidth : COLLAPSED_PANEL_WIDTH; let top = calculateTop(boundingRect, subItems.offsetHeight); - if (isRefactoring && hasSubItems) { - top -= header.offsetHeight; - } else if (isRefactoring) { + const isAbove = top < boundingRect.top; + if (hasSubItems) { + top = isAbove ? top : top - header.offsetHeight; + } else { top = boundingRect.top; } - const isAbove = top < boundingRect.top; subItems.classList.add('fly-out-list'); subItems.style.transform = `translate3d(${left}px, ${Math.floor(top) - getHeaderHeight()}px, 0)`; // eslint-disable-line no-param-reassign diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index d6fcdeb9e13..1137951ccfc 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -1,16 +1,18 @@ <script> /* eslint-disable vue/require-default-prop, vue/no-v-html */ +import { GlButton } from '@gitlab/ui'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers'; import Tracking from '~/tracking'; -import Identicon from '~/vue_shared/components/identicon.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; const trackingMixin = Tracking.mixin(); export default { components: { - Identicon, + GlButton, + ProjectAvatar, }, mixins: [trackingMixin], inject: ['vuexModule'], @@ -56,24 +58,18 @@ export default { <template> <li class="frequent-items-list-item-container"> - <a + <gl-button + category="tertiary" :href="webUrl" - class="clearfix dropdown-item" + class="gl-text-left gl-justify-content-start!" @click="track('click_link', { label: `${dropdownType}_dropdown_frequent_items_list_item` })" > - <div - ref="frequentItemsItemAvatarContainer" - class="frequent-items-item-avatar-container avatar-container rect-avatar s32" - > - <img v-if="avatarUrl" ref="frequentItemsItemAvatar" :src="avatarUrl" class="avatar s32" /> - <identicon - v-else - :entity-id="itemId" - :entity-name="itemName" - size-class="s32" - class="rect-avatar" - /> - </div> + <project-avatar + class="gl-float-left gl-mr-3" + :project-avatar-url="avatarUrl" + :project-name="itemName" + aria-hidden="true" + /> <div ref="frequentItemsItemMetadataContainer" class="frequent-items-item-metadata-container"> <div ref="frequentItemsItemTitle" @@ -90,6 +86,6 @@ export default { {{ truncatedNamespace }} </div> </div> - </a> + </gl-button> </li> </template> diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js index 90b454d1b42..65a762f54ad 100644 --- a/app/assets/javascripts/frequent_items/store/actions.js +++ b/app/assets/javascripts/frequent_items/store/actions.js @@ -1,4 +1,5 @@ import AccessorUtilities from '~/lib/utils/accessor'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import { getGroups, getProjects } from '~/rest_api'; import { getTopFrequentItems } from '../utils'; import * as types from './mutation_types'; @@ -51,7 +52,7 @@ export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => { const params = { simple: true, per_page: 20, - membership: Boolean(gon.current_user_id), + membership: isLoggedIn(), }; let searchFunction; diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js index fa6f07edfcf..7964e762dac 100644 --- a/app/assets/javascripts/gpg_badges.js +++ b/app/assets/javascripts/gpg_badges.js @@ -1,7 +1,8 @@ import $ from 'jquery'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { parseQueryStringIntoObject } from '~/lib/utils/common_utils'; +import { queryToObject } from '~/lib/utils/url_utility'; + import { __ } from '~/locale'; export default class GpgBadges { @@ -27,7 +28,7 @@ export default class GpgBadges { return Promise.reject(new Error(__('Missing commit signatures endpoint!'))); } - const params = parseQueryStringIntoObject(tag.serialize()); + const params = queryToObject(tag.serialize()); return axios .get(endpoint, { params }) .then(({ data }) => { diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js index 77d2acd3393..25347ad6433 100644 --- a/app/assets/javascripts/grafana_integration/store/actions.js +++ b/app/assets/javascripts/grafana_integration/store/actions.js @@ -40,6 +40,5 @@ export const receiveGrafanaIntegrationUpdateError = (_, error) => { createFlash({ message: `${__('There was an error saving your changes.')} ${message}`, - type: 'alert', }); }; diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 7e897be9e9a..aad7712a9f0 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -1,2 +1,11 @@ -/* eslint-disable @gitlab/require-i18n-strings */ -export const IssueType = 'Issue'; +export const TYPE_CI_RUNNER = 'Ci::Runner'; +export const TYPE_GROUP = 'Group'; +export const TYPE_ISSUE = 'Issue'; +export const TYPE_ITERATION = 'Iteration'; +export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence'; +export const TYPE_MERGE_REQUEST = 'MergeRequest'; +export const TYPE_MILESTONE = 'Milestone'; +export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; +export const TYPE_SITE_PROFILE = 'DastSiteProfile'; +export const TYPE_USER = 'User'; +export const TYPE_VULNERABILITY = 'Vulnerability'; diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index e64e8009a5f..18f9a50bbce 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -18,11 +18,6 @@ export const MutationOperationMode = { }; /** - * Possible GraphQL entity types. - */ -export const TYPE_GROUP = 'Group'; - -/** * Ids generated by GraphQL endpoints are usually in the format * gid://gitlab/Groups/123. This method takes a type and an id * and interpolates the 2 values into the expected GraphQL ID format. diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index c1fc75fbea6..b6a1f41afb5 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability'; import { slugify } from './lib/utils/text_utility'; @@ -16,7 +16,7 @@ export default class Group { if (groupName.value === '') { groupName.addEventListener('keyup', this.updateHandler); - groupName.addEventListener('blur', this.updateGroupPathSlugHandler); + groupName.addEventListener('keyup', this.updateGroupPathSlugHandler); } }); @@ -61,11 +61,15 @@ export default class Group { element.value = suggestedSlug; }); } else if (exists && !suggests.length) { - flash(__('Unable to suggest a path. Please refresh and try again.')); + createFlash({ + message: __('Unable to suggest a path. Please refresh and try again.'), + }); } }) .catch(() => - flash(__('An error occurred while checking group path. Please refresh and try again.')), + createFlash({ + message: __('An error occurred while checking group path. Please refresh and try again.'), + }), ); } } diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js index 257f5ac9658..378259eb9c8 100644 --- a/app/assets/javascripts/group_label_subscription.js +++ b/app/assets/javascripts/group_label_subscription.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { __ } from '~/locale'; import { fixTitle, hide } from '~/tooltips'; -import { deprecatedCreateFlash as flash } from './flash'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; const tooltipTitles = { @@ -30,7 +30,11 @@ export default class GroupLabelSubscription { this.toggleSubscriptionButtons(); this.$unsubscribeButtons.removeAttr('data-url'); }) - .catch(() => flash(__('There was an error when unsubscribing from this label.'))); + .catch(() => + createFlash({ + message: __('There was an error when unsubscribing from this label.'), + }), + ); } subscribe(event) { @@ -45,7 +49,11 @@ export default class GroupLabelSubscription { .post(url) .then(() => GroupLabelSubscription.setNewTooltip($btn)) .then(() => this.toggleSubscriptionButtons()) - .catch(() => flash(__('There was an error when subscribing to this label.'))); + .catch(() => + createFlash({ + message: __('There was an error when subscribing to this label.'), + }), + ); } toggleSubscriptionButtons() { diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue index a1d706f0f66..f61d96b3dfd 100644 --- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue +++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue @@ -101,7 +101,7 @@ export default { <h4 class="gl-display-flex gl-align-items-center"> {{ __('Set up shared runner availability') }} - <gl-loading-icon v-if="isLoading" class="gl-ml-3" inline /> + <gl-loading-icon v-if="isLoading" class="gl-ml-3" size="sm" inline /> </h4> <section class="gl-mt-5"> diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 9d2c7cfe581..2a95b242510 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -1,10 +1,8 @@ <script> -/* global Flash */ - import { GlLoadingIcon, GlModal } from '@gitlab/ui'; -import { getParameterByName } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; import { HIDDEN_CLASS } from '~/lib/utils/constants'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants'; @@ -116,7 +114,7 @@ export default { this.isLoading = false; window.scrollTo({ top: 0, behavior: 'smooth' }); - Flash(COMMON_STR.FAILURE); + createFlash({ message: COMMON_STR.FAILURE }); }); }, fetchAllGroups() { @@ -202,7 +200,7 @@ export default { if (err.status === 403) { message = COMMON_STR.LEAVE_FORBIDDEN; } - Flash(message); + createFlash({ message }); this.targetGroup.isBeingRemoved = false; }); }, diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index dbad2688451..ad0b27c9693 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -1,5 +1,6 @@ <script> import { + GlAvatar, GlLoadingIcon, GlBadge, GlIcon, @@ -7,7 +8,6 @@ import { GlSafeHtmlDirective, } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; -import identicon from '~/vue_shared/components/identicon.vue'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants'; import eventHub from '../event_hub'; @@ -23,11 +23,11 @@ export default { SafeHtml: GlSafeHtmlDirective, }, components: { + GlAvatar, GlBadge, GlLoadingIcon, GlIcon, UserAccessRoleBadge, - identicon, itemCaret, itemTypeIcon, itemStats, @@ -125,21 +125,21 @@ export default { size="lg" class="d-none d-sm-inline-flex flex-shrink-0 gl-mr-3" /> - <div - :class="{ 'd-sm-flex': !group.isChildrenLoading }" - class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0" + <a + :class="{ 'gl-sm-display-flex': !group.isChildrenLoading }" + class="gl-display-none gl-text-decoration-none! gl-mr-3" + :href="group.relativePath" + :aria-label="group.name" > - <a :href="group.relativePath" class="no-expand"> - <img - v-if="hasAvatar" - :src="group.avatarUrl" - data-testid="group-avatar" - class="avatar s40" - :itemprop="microdata.imageItemprop" - /> - <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" /> - </a> - </div> + <gl-avatar + shape="rect" + :entity-name="group.name" + :src="group.avatarUrl" + :alt="group.name" + :size="32" + :itemprop="microdata.imageItemprop" + /> + </a> <div class="group-text-container d-flex flex-fill align-items-center"> <div class="group-text flex-grow-1 flex-shrink-1"> <div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3"> @@ -178,7 +178,7 @@ export default { </div> </div> <div v-if="isGroupPendingRemoval"> - <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge> + <gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge> </div> <div class="metadata d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between"> <item-actions diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index d407fdd2b90..59a37b2a1d5 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/common_utils'; +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 e09df8a5d26..7a37d1eb93d 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -73,7 +73,7 @@ export default { icon-name="star" /> <div v-if="isProjectPendingRemoval"> - <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge> + <gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge> </div> <div v-if="isProject" class="last-updated"> <time-ago-tooltip :time="item.updatedAt" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js index cedf16cd7f1..a7d44322eb1 100644 --- a/app/assets/javascripts/groups/groups_filterable_list.js +++ b/app/assets/javascripts/groups/groups_filterable_list.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import FilterableList from '~/filterable_list'; -import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils'; +import { normalizeHeaders } from '../lib/utils/common_utils'; +import { getParameterByName } from '../lib/utils/url_utility'; import eventHub from './event_hub'; export default class GroupFilterableList extends FilterableList { @@ -45,7 +46,7 @@ export default class GroupFilterableList extends FilterableList { onFilterInput() { const queryData = {}; const $form = $(this.form); - const archivedParam = getParameterByName('archived', window.location.href); + const archivedParam = getParameterByName('archived'); const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val(); if (filterGroupsParam) { @@ -85,11 +86,11 @@ export default class GroupFilterableList extends FilterableList { // Get option query param, also preserve currently applied query param const sortParam = getParameterByName( 'sort', - isOptionFilterBySort ? e.currentTarget.href : window.location.href, + isOptionFilterBySort ? e.currentTarget.search : window.location.search, ); const archivedParam = getParameterByName( 'archived', - isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href, + isOptionFilterByArchivedProjects ? e.currentTarget.search : window.location.search, ); if (sortParam) { diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue index d3a52f9f0cf..2b75d10f659 100644 --- a/app/assets/javascripts/ide/components/error_message.vue +++ b/app/assets/javascripts/ide/components/error_message.vue @@ -57,6 +57,6 @@ export default { @primaryAction="doAction" > <span v-html="message.text"></span> - <gl-loading-icon v-show="isLoading" inline class="vertical-align-middle ml-1" /> + <gl-loading-icon v-show="isLoading" size="sm" inline class="vertical-align-middle ml-1" /> </gl-alert> </template> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 0c9fd324f8c..e345e5dc099 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -130,7 +130,6 @@ export default { <div class="ide-view flex-grow d-flex"> <template v-if="loadDeferred"> <find-file - v-show="fileFindVisible" :files="allBlobs" :visible="fileFindVisible" :loading="loading" diff --git a/app/assets/javascripts/ide/components/ide_project_header.vue b/app/assets/javascripts/ide/components/ide_project_header.vue index 36891505230..1c25a8e634d 100644 --- a/app/assets/javascripts/ide/components/ide_project_header.vue +++ b/app/assets/javascripts/ide/components/ide_project_header.vue @@ -1,9 +1,9 @@ <script> -import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; export default { components: { - ProjectAvatarDefault, + ProjectAvatar, }, props: { project: { @@ -16,8 +16,12 @@ export default { <template> <div class="context-header ide-context-header"> - <a :href="project.web_url" :title="s__('IDE|Go to project')"> - <project-avatar-default :project="project" :size="48" /> + <a :href="project.web_url" :title="s__('IDE|Go to project')" data-testid="go-to-project-link"> + <project-avatar + :project-name="project.name" + :project-avatar-url="project.avatar_url" + :size="48" + /> <span class="ide-sidebar-project-title"> <span class="sidebar-context-title"> {{ project.name }} </span> <span diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 6c7f084c164..938385f0b81 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -79,7 +79,7 @@ export default { <gl-icon :name="collapseIcon" class="ide-stage-collapse-icon" /> </div> <div v-show="!stage.isCollapsed" ref="jobList" class="card-body p-0"> - <gl-loading-icon v-if="showLoadingIcon" /> + <gl-loading-icon v-if="showLoadingIcon" size="sm" /> <template v-else> <item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" /> </template> diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue index 639937481f3..2d9f74a06ee 100644 --- a/app/assets/javascripts/ide/components/merge_requests/item.vue +++ b/app/assets/javascripts/ide/components/merge_requests/item.vue @@ -41,7 +41,7 @@ export default { <template> <a :href="mergeRequestHref" class="btn-link d-flex align-items-center"> <span class="d-flex gl-mr-3 ide-search-list-current-icon"> - <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" use-deprecated-sizes /> + <gl-icon v-if="isActive" :size="16" name="mobile-issue-close" /> </span> <span> <strong> {{ item.title }} </strong> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index f8dc10420d0..e8541d3a4c3 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -61,9 +61,6 @@ export default { message: sprintf(s__('The name "%{name}" is already taken in this directory.'), { name: this.entryName, }), - type: 'alert', - parent: document, - actionConfig: null, fadeTransition: false, addBodyClass: true, }); diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue index 4d35e946d89..838c363a6a3 100644 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -126,7 +126,11 @@ export default { class="ide-navigator-location form-control bg-white" readonly /> - <gl-loading-icon v-if="loading" class="position-absolute ide-preview-loading-icon" /> + <gl-loading-icon + v-if="loading" + size="sm" + class="position-absolute ide-preview-loading-icon" + /> </div> </header> </template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index bf2af9ffd49..5c711313ff6 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -6,9 +6,9 @@ import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN, } from '~/editor/constants'; -import EditorLite from '~/editor/editor_lite'; -import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; +import SourceEditor from '~/editor/source_editor'; +import createFlash from '~/flash'; import ModelManager from '~/ide/lib/common/model_manager'; import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options'; import { __ } from '~/locale'; @@ -216,7 +216,7 @@ export default { }, mounted() { if (!this.globalEditor) { - this.globalEditor = new EditorLite(); + this.globalEditor = new SourceEditor(); } this.initEditor(); @@ -250,14 +250,11 @@ export default { this.createEditorInstance(); }) .catch((err) => { - flash( - __('Error setting up editor. Please try again.'), - 'alert', - document, - null, - false, - true, - ); + createFlash({ + message: __('Error setting up editor. Please try again.'), + fadeTransition: false, + addBodyClass: true, + }); throw err; }); }, @@ -418,7 +415,11 @@ export default { const parentPath = getPathParent(this.file.path); const path = `${parentPath ? `${parentPath}/` : ''}${file.name}`; - return this.addTempImage({ name: path, rawPath: content }).then(({ name: fileName }) => { + return this.addTempImage({ + name: path, + rawPath: URL.createObjectURL(file), + content: atob(content.split('base64,')[1]), + }).then(({ name: fileName }) => { this.editor.replaceSelectedText(`![${fileName}](./${fileName})`); }); }); diff --git a/app/assets/javascripts/ide/components/shared/tokened_input.vue b/app/assets/javascripts/ide/components/shared/tokened_input.vue index ed0dab47947..14052c23a0c 100644 --- a/app/assets/javascripts/ide/components/shared/tokened_input.vue +++ b/app/assets/javascripts/ide/components/shared/tokened_input.vue @@ -82,7 +82,7 @@ export default { <div class="value-container rounded"> <div class="value">{{ token.label }}</div> <div class="remove-token inverted"> - <gl-icon :size="10" name="close" use-deprecated-sizes /> + <gl-icon :size="16" name="close" /> </div> </div> </button> diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue index 08fb2f5e5a0..c91a98c9527 100644 --- a/app/assets/javascripts/ide/components/terminal/terminal.vue +++ b/app/assets/javascripts/ide/components/terminal/terminal.vue @@ -93,7 +93,7 @@ export default { <div class="d-flex flex-column flex-fill min-height-0 pr-3"> <div class="top-bar d-flex border-left-0 align-items-center"> <div v-if="loadingText" data-qa-selector="loading_container"> - <gl-loading-icon :inline="true" /> + <gl-loading-icon size="sm" :inline="true" /> <span>{{ loadingText }}</span> </div> <terminal-controls diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 5f60bf0269d..27cedd80347 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import IdeRouter from '~/ide/ide_router_extension'; import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -111,14 +111,11 @@ export const createRouter = (store, defaultBranch) => { } }) .catch((e) => { - flash( - __('Error while loading the project data. Please try again.'), - 'alert', - document, - null, - false, - true, - ); + createFlash({ + message: __('Error while loading the project data. Please try again.'), + fadeTransition: false, + addBodyClass: true, + }); throw e; }); } diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index 1d051062637..682914df9ec 100644 --- a/app/assets/javascripts/ide/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -1,5 +1,6 @@ import { throttle } from 'lodash'; import { Range } from 'monaco-editor'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import Disposable from '../common/disposable'; import DirtyDiffWorker from './diff_worker'; @@ -31,7 +32,7 @@ export default class DirtyDiffController { this.modelManager = modelManager; this.decorationsController = decorationsController; this.dirtyDiffWorker = new DirtyDiffWorker(); - this.throttledComputeDiff = throttle(this.computeDiff, 250); + this.throttledComputeDiff = throttle(this.computeDiff, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); this.decorate = this.decorate.bind(this); this.dirtyDiffWorker.addEventListener('message', this.decorate); diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 6bd28cd4fb6..ef4f47f226a 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -100,7 +100,7 @@ export default { return Api.commitPipelines(getters.currentProject.path_with_namespace, commitSha); }, pingUsage(projectPath) { - const url = `${gon.relative_url_root}/${projectPath}/usage_ping/web_ide_pipelines_count`; + const url = `${gon.relative_url_root}/${projectPath}/service_ping/web_ide_pipelines_count`; return axios.post(url); }, getCiConfig(projectPath, content) { diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 062dc150805..b22e58a376d 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,6 +1,6 @@ import { escape } from 'lodash'; import Vue from 'vue'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { @@ -36,16 +36,13 @@ export const createTempEntry = ( const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; if (getters.entryExists(name)) { - flash( - sprintf(__('The name "%{name}" is already taken in this directory.'), { + createFlash({ + message: sprintf(__('The name "%{name}" is already taken in this directory.'), { name: name.split('/').pop(), }), - 'alert', - document, - null, - false, - true, - ); + fadeTransition: false, + addBodyClass: true, + }); return undefined; } @@ -79,11 +76,11 @@ export const createTempEntry = ( return file; }; -export const addTempImage = ({ dispatch, getters }, { name, rawPath = '' }) => +export const addTempImage = ({ dispatch, getters }, { name, rawPath = '', content = '' }) => dispatch('createTempEntry', { name: getters.getAvailableFileName(name), type: 'blob', - content: rawPath.split('base64,')[1], + content, rawPath, openFile: false, makeFileActive: false, @@ -284,14 +281,11 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force = if (e.response.status === 404) { reject(e); } else { - flash( - __('Error loading branch data. Please try again.'), - 'alert', - document, - null, - false, - true, - ); + createFlash({ + message: __('Error loading branch data. Please try again.'), + fadeTransition: false, + addBodyClass: true, + }); reject( new Error( diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 5e020f16104..f3f603d4ae9 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -36,9 +36,6 @@ export const getMergeRequestsForBranch = ( .catch((e) => { createFlash({ message: __(`Error fetching merge requests for ${branchId}`), - type: 'alert', - parent: document, - actionConfig: null, fadeTransition: false, addBodyClass: true, }); diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 120a577d44a..93ad19ba81e 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -1,5 +1,5 @@ import { escape } from 'lodash'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; import api from '../../../api'; import service from '../../services'; @@ -19,14 +19,11 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force resolve(data); }) .catch(() => { - flash( - __('Error loading project data. Please try again.'), - 'alert', - document, - null, - false, - true, - ); + createFlash({ + message: __('Error loading project data. Please try again.'), + fadeTransition: false, + addBodyClass: true, + }); reject(new Error(`Project not loaded ${namespace}/${projectId}`)); }); } else { @@ -45,7 +42,11 @@ export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) }); }) .catch((e) => { - flash(__('Error loading last commit.'), 'alert', document, null, false, true); + createFlash({ + message: __('Error loading last commit.'), + fadeTransition: false, + addBodyClass: true, + }); throw e; }); diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js index 2bebf8b90ce..e36419cd7eb 100644 --- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js +++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js @@ -3,7 +3,7 @@ import axios from '~/lib/utils/axios_utils'; export const pingUsage = ({ rootGetters }) => { const { web_url: projectUrl } = rootGetters.currentProject; - const url = `${projectUrl}/usage_ping/web_ide_clientside_preview`; + const url = `${projectUrl}/service_ping/web_ide_clientside_preview`; return axios.post(url); }; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 29555799074..2ff71523b1b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { addNumericSuffix } from '~/ide/utils'; import { sprintf, __ } from '~/locale'; import { leftSidebarViews } from '../../../constants'; @@ -143,7 +143,11 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo commit(types.UPDATE_LOADING, false); if (!data.short_id) { - flash(data.message, 'alert', document, null, false, true); + createFlash({ + message: data.message, + fadeTransition: false, + addBodyClass: true, + }); return null; } diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 4019703b296..0cef3b98e61 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -252,10 +252,10 @@ export function extractMarkdownImagesFromEntries(mdFile, entries) { .trim(); const imageContent = entries[imagePath]?.content || entries[imagePath]?.raw; + const imageRawPath = entries[imagePath]?.rawPath; if (!isAbsolute(path) && imageContent) { - const ext = path.includes('.') ? path.split('.').pop().trim() : 'jpeg'; - const src = `data:image/${ext};base64,${imageContent}`; + const src = imageRawPath; i += 1; const key = `{{${prefix}${i}}}`; images[key] = { alt, src, title }; diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue new file mode 100644 index 00000000000..44d6d17232f --- /dev/null +++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue @@ -0,0 +1,40 @@ +<script> +import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; + +export default { + components: { + GlDropdown, + GlSearchBoxByType, + }, + inheritAttrs: false, + props: { + namespaces: { + type: Array, + required: true, + }, + }, + data() { + return { searchTerm: '' }; + }, + computed: { + filteredNamespaces() { + return this.namespaces.filter((ns) => + ns.toLowerCase().includes(this.searchTerm.toLowerCase()), + ); + }, + }, +}; +</script> +<template> + <gl-dropdown + toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1" + data-qa-selector="target_namespace_selector_dropdown" + v-bind="$attrs" + > + <template #header> + <gl-search-box-by-type v-model.trim="searchTerm" /> + </template> + <slot :namespaces="filteredNamespaces"></slot> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index 3daa5eebcb6..cb7e3ef9632 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -227,7 +227,12 @@ export default { </template> </gl-sprintf> </span> - <gl-search-box-by-click class="gl-ml-auto" @submit="filter = $event" @clear="filter = ''" /> + <gl-search-box-by-click + class="gl-ml-auto" + :placeholder="s__('BulkImport|Filter by source group')" + @submit="filter = $event" + @clear="filter = ''" + /> </div> <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> <template v-else> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue index 63c18f4d78e..1c3ede769e0 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue @@ -1,7 +1,6 @@ <script> import { GlButton, - GlDropdown, GlDropdownDivider, GlDropdownItem, GlDropdownSectionHeader, @@ -11,6 +10,7 @@ import { } from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; +import ImportGroupDropdown from '../../components/group_dropdown.vue'; import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; import addValidationErrorMutation from '../graphql/mutations/add_validation_error.mutation.graphql'; @@ -22,8 +22,8 @@ const DEBOUNCE_INTERVAL = 300; export default { components: { ImportStatus, + ImportGroupDropdown, GlButton, - GlDropdown, GlDropdownDivider, GlDropdownItem, GlDropdownSectionHeader, @@ -83,6 +83,10 @@ export default { }, computed: { + availableNamespaceNames() { + return this.availableNamespaces.map((ns) => ns.full_path); + }, + importTarget() { return this.group.import_target; }, @@ -153,9 +157,11 @@ export default { disabled: isAlreadyImported, }" > - <gl-dropdown + <import-group-dropdown + #default="{ namespaces }" :text="importTarget.target_namespace" :disabled="isAlreadyImported" + :namespaces="availableNamespaceNames" toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" class="import-entities-namespace-dropdown gl-h-7 gl-flex-grow-1" data-qa-selector="target_namespace_selector_dropdown" @@ -163,22 +169,22 @@ export default { <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{ s__('BulkImport|No parent') }}</gl-dropdown-item> - <template v-if="availableNamespaces.length"> + <template v-if="namespaces.length"> <gl-dropdown-divider /> <gl-dropdown-section-header> {{ s__('BulkImport|Existing groups') }} </gl-dropdown-section-header> <gl-dropdown-item - v-for="ns in availableNamespaces" - :key="ns.full_path" + v-for="ns in namespaces" + :key="ns" data-qa-selector="target_group_dropdown_item" - :data-qa-group-name="ns.full_path" - @click="$emit('update-target-namespace', ns.full_path)" + :data-qa-group-name="ns" + @click="$emit('update-target-namespace', ns)" > - {{ ns.full_path }} + {{ ns }} </gl-dropdown-item> </template> - </gl-dropdown> + </import-group-dropdown> <div class="import-entities-target-select-separator gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1" > 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 be09052fb7e..14d08caef34 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 @@ -47,18 +47,7 @@ export default { }, availableNamespaces() { - const serializedNamespaces = this.namespaces.map(({ fullPath }) => ({ - id: fullPath, - text: fullPath, - })); - - return [ - { text: __('Groups'), children: serializedNamespaces }, - { - text: __('Users'), - children: [{ id: this.defaultTargetNamespace, text: this.defaultTargetNamespace }], - }, - ]; + return this.namespaces.map(({ fullPath }) => fullPath); }, importAllButtonText() { @@ -179,6 +168,7 @@ export default { :key="repo.importSource.providerLink" :repo="repo" :available-namespaces="availableNamespaces" + :user-namespace="defaultTargetNamespace" /> </template> </tbody> 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 a803afeb901..e2fd608d9db 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 @@ -1,8 +1,17 @@ <script> -import { GlIcon, GlBadge, GlFormInput, GlButton, GlLink } from '@gitlab/ui'; +import { + GlIcon, + GlBadge, + GlFormInput, + GlButton, + GlLink, + GlDropdownItem, + GlDropdownDivider, + GlDropdownSectionHeader, +} from '@gitlab/ui'; import { mapState, mapGetters, mapActions } from 'vuex'; import { __ } from '~/locale'; -import Select2Select from '~/vue_shared/components/select2_select.vue'; +import ImportGroupDropdown from '../../components/group_dropdown.vue'; import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; import { isProjectImportable, isIncompatible, getImportStatus } from '../utils'; @@ -10,10 +19,13 @@ import { isProjectImportable, isIncompatible, getImportStatus } from '../utils'; export default { name: 'ProviderRepoTableRow', components: { - Select2Select, + ImportGroupDropdown, ImportStatus, GlFormInput, GlButton, + GlDropdownItem, + GlDropdownDivider, + GlDropdownSectionHeader, GlIcon, GlBadge, GlLink, @@ -23,6 +35,10 @@ export default { type: Object, required: true, }, + userNamespace: { + type: String, + required: true, + }, availableNamespaces: { type: Array, required: true, @@ -61,22 +77,6 @@ export default { return this.ciCdOnly ? __('Connect') : __('Import'); }, - select2Options() { - return { - data: this.availableNamespaces, - containerCssClass: 'import-namespace-select qa-project-namespace-select gl-w-auto', - }; - }, - - targetNamespaceSelect: { - get() { - return this.importTarget.targetNamespace; - }, - set(value) { - this.updateImportTarget({ targetNamespace: value }); - }, - }, - newNameInput: { get() { return this.importTarget.newName; @@ -118,7 +118,29 @@ export default { <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> <template v-else-if="isImportNotStarted"> <div class="import-entities-target-select gl-display-flex gl-align-items-stretch gl-w-full"> - <select2-select v-model="targetNamespaceSelect" :options="select2Options" /> + <import-group-dropdown + #default="{ namespaces }" + :text="importTarget.targetNamespace" + :namespaces="availableNamespaces" + > + <template v-if="namespaces.length"> + <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="ns in namespaces" + :key="ns" + data-qa-selector="target_group_dropdown_item" + :data-qa-group-name="ns" + @click="updateImportTarget({ targetNamespace: ns })" + > + {{ ns }} + </gl-dropdown-item> + <gl-dropdown-divider /> + </template> + <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> + <gl-dropdown-item @click="updateImportTarget({ targetNamespace: ns })">{{ + userNamespace + }}</gl-dropdown-item> + </import-group-dropdown> <div class="import-entities-target-select-separator gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1" > diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js index 6b7fe23ed60..110cc77b20d 100644 --- a/app/assets/javascripts/import_entities/import_projects/index.js +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -38,7 +38,7 @@ export function initStoreFromElement(element) { export function initPropsFromElement(element) { return { - providerTitle: element.dataset.providerTitle, + providerTitle: element.dataset.provider, filterable: parseBoolean(element.dataset.filterable), paginatable: parseBoolean(element.dataset.paginatable), }; diff --git a/app/assets/javascripts/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js index 83fd29a058e..93baa54956a 100644 --- a/app/assets/javascripts/incidents_settings/incidents_settings_service.js +++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js @@ -24,7 +24,6 @@ export default class IncidentsSettingsService { createFlash({ message: `${ERROR_MSG} ${message}`, - type: 'alert', }); }); } 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 ec93980251b..1242493fb57 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -75,15 +75,6 @@ export default { validProjectKey() { return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated; }, - showJiraVulnerabilitiesOptions() { - return this.showJiraVulnerabilitiesIntegration; - }, - showUltimateUpgrade() { - return this.showJiraIssuesIntegration && !this.showJiraVulnerabilitiesIntegration; - }, - showPremiumUpgrade() { - return !this.showJiraIssuesIntegration; - }, }, created() { eventHub.$on('validateForm', this.validateForm); @@ -128,23 +119,30 @@ export default { }} </template> </gl-form-checkbox> - <jira-issue-creation-vulnerabilities - v-if="enableJiraIssues" - :project-key="projectKey" - :initial-is-enabled="initialEnableJiraVulnerabilities" - :initial-issue-type-id="initialVulnerabilitiesIssuetype" - :show-full-feature="showJiraVulnerabilitiesOptions" - data-testid="jira-for-vulnerabilities" - @request-get-issue-types="getJiraIssueTypes" - /> + <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-get-issue-types="getJiraIssueTypes" + /> + <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> <jira-upgrade-cta - v-if="showUltimateUpgrade || showPremiumUpgrade" + v-else class="gl-mt-2" - :class="{ 'gl-ml-6': showUltimateUpgrade }" + data-testid="premium-upgrade-cta" + show-premium-message :upgrade-plan-path="upgradePlanPath" - :show-ultimate-message="showUltimateUpgrade" - :show-premium-message="showPremiumUpgrade" /> </div> </gl-form-group> 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 84c8594c6b6..4aab1123af9 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -1,5 +1,6 @@ <script> import { + GlFormGroup, GlModal, GlDropdown, GlDropdownItem, @@ -12,16 +13,21 @@ import { import { partition, isString } from 'lodash'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; -import GroupSelect from '~/invite_members/components/group_select.vue'; -import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { s__, sprintf } from '~/locale'; import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS } from '../constants'; import eventHub from '../event_hub'; +import { + responseMessageFromError, + responseMessageFromSuccess, +} from '../utils/response_message_parser'; +import GroupSelect from './group_select.vue'; +import MembersTokenSelect from './members_token_select.vue'; export default { name: 'InviteMembersModal', components: { + GlFormGroup, GlDatepicker, GlLink, GlModal, @@ -79,9 +85,13 @@ export default { selectedDate: undefined, groupToBeSharedWith: {}, source: 'unknown', + invalidFeedbackMessage: '', }; }, computed: { + validationState() { + return this.invalidFeedbackMessage === '' ? null : false; + }, isInviteGroup() { return this.inviteeType === 'group'; }, @@ -142,6 +152,7 @@ export default { this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, closeModal() { + this.resetFields(); this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, sendInvite() { @@ -150,7 +161,6 @@ export default { } else { this.submitInviteMembers(); } - this.closeModal(); }, trackInvite() { if (this.source === INVITE_MEMBERS_IN_COMMENT) { @@ -158,12 +168,12 @@ export default { tracking.event('comment_invite_success'); } }, - cancelInvite() { + resetFields() { this.selectedAccessLevel = this.defaultAccessLevel; this.selectedDate = undefined; this.newUsersToInvite = []; this.groupToBeSharedWith = {}; - this.closeModal(); + this.invalidFeedbackMessage = ''; }, changeSelectedItem(item) { this.selectedAccessLevel = item; @@ -175,9 +185,11 @@ export default { apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id)) .then(this.showToastMessageSuccess) - .catch(this.showToastMessageError); + .catch(this.showInvalidFeedbackMessage); }, submitInviteMembers() { + this.invalidFeedbackMessage = ''; + const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const promises = []; @@ -196,10 +208,11 @@ export default { promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); } - this.trackInvite(); - Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError); + Promise.all(promises) + .then(this.conditionallyShowToastSuccess) + .catch(this.showInvalidFeedbackMessage); }, inviteByEmailPostData(usersToInviteByEmail) { return { @@ -224,13 +237,27 @@ export default { group_access: this.selectedAccessLevel, }; }, + conditionallyShowToastSuccess(response) { + const message = responseMessageFromSuccess(response); + + if (message === '') { + this.showToastMessageSuccess(); + + return; + } + + this.invalidFeedbackMessage = message; + }, showToastMessageSuccess() { this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + this.closeModal(); }, - showToastMessageError(error) { - const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful; - - this.$toast.show(message, this.toastOptions); + showInvalidFeedbackMessage(response) { + this.invalidFeedbackMessage = + responseMessageFromError(response) || this.$options.labels.invalidFeedbackMessageDefault; + }, + handleMembersTokenSelectClear() { + this.invalidFeedbackMessage = ''; }, }, labels: { @@ -267,8 +294,8 @@ export default { accessLevel: s__('InviteMembersModal|Select a role'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'), - toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'), - readMoreText: s__(`InviteMembersModal|%{linkStart}Learn more%{linkEnd} about roles.`), + invalidFeedbackMessageDefault: s__('InviteMembersModal|Something went wrong'), + readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), inviteButtonText: s__('InviteMembersModal|Invite'), cancelButtonText: s__('InviteMembersModal|Cancel'), headerCloseLabel: s__('InviteMembersModal|Close invite team members'), @@ -283,6 +310,7 @@ export default { data-qa-selector="invite_members_modal_content" :title="$options.labels[inviteeType].modalTitle" :header-close-label="$options.labels.headerCloseLabel" + @close="resetFields" > <div> <p ref="introText"> @@ -293,15 +321,22 @@ export default { </gl-sprintf> </p> - <label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{ - $options.labels[inviteeType].searchField - }}</label> - <div class="gl-mt-2"> + <gl-form-group + class="gl-mt-2" + :invalid-feedback="invalidFeedbackMessage" + :state="validationState" + :description="$options.labels[inviteeType].placeHolder" + data-testid="members-form-group" + > + <label :id="$options.membersTokenSelectLabelId" class="col-form-label">{{ + $options.labels[inviteeType].searchField + }}</label> <members-token-select v-if="!isInviteGroup" v-model="newUsersToInvite" + :validation-state="validationState" :aria-labelledby="$options.membersTokenSelectLabelId" - :placeholder="$options.labels[inviteeType].placeHolder" + @clear="handleMembersTokenSelectClear" /> <group-select v-if="isInviteGroup" @@ -309,7 +344,7 @@ export default { :groups-filter="groupSelectFilter" :parent-group-id="groupSelectParentId" /> - </div> + </gl-form-group> <label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label> <div class="gl-mt-2 gl-w-half gl-xs-w-full"> @@ -364,15 +399,15 @@ export default { <template #modal-footer> <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"> - <gl-button ref="cancelButton" @click="cancelInvite"> + <gl-button data-testid="cancel-button" @click="closeModal"> {{ $options.labels.cancelButtonText }} </gl-button> <div class="gl-mr-3"></div> <gl-button - ref="inviteButton" :disabled="inviteDisabled" variant="success" data-qa-selector="invite_button" + data-testid="invite-button" @click="sendInvite" >{{ $options.labels.inviteButtonText }}</gl-button > 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 db6a7888786..7aece3b7bb4 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -1,5 +1,5 @@ <script> -import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/ui'; +import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@gitlab/ui'; import { debounce } from 'lodash'; import { __ } from '~/locale'; import { getUsers } from '~/rest_api'; @@ -10,6 +10,7 @@ export default { GlTokenSelector, GlAvatar, GlAvatarLabeled, + GlIcon, GlSprintf, }, props: { @@ -22,6 +23,11 @@ export default { type: String, required: true, }, + validationState: { + type: Boolean, + required: false, + default: null, + }, }, data() { return { @@ -84,6 +90,13 @@ export default { this.hasBeenFocused = true; }, + handleTokenRemove() { + if (this.selectedTokens.length) { + return; + } + + this.$emit('clear'); + }, }, queryOptions: { exclude_internal: true, active: true }, i18n: { @@ -95,19 +108,26 @@ export default { <template> <gl-token-selector v-model="selectedTokens" + :state="validationState" :dropdown-items="users" :loading="loading" :allow-user-defined-tokens="emailIsValid" :hide-dropdown-with-no-items="hideDropdownWithNoItems" :placeholder="placeholderText" :aria-labelledby="ariaLabelledby" + :text-input-attrs="{ + 'data-testid': 'members-token-select-input', + 'data-qa-selector': 'members_token_select_input', + }" @blur="handleBlur" @text-input="handleTextInput" @input="handleInput" @focus="handleFocus" + @token-remove="handleTokenRemove" > <template #token-content="{ token }"> - <gl-avatar v-if="token.avatar_url" :src="token.avatar_url" :size="16" /> + <gl-icon v-if="validationState === false" name="error" :size="16" class="gl-mr-2" /> + <gl-avatar v-else-if="token.avatar_url" :src="token.avatar_url" :size="16" /> {{ token.name }} </template> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 0c5538d5b86..83e6cac0ac0 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const SEARCH_DELAY = 200; export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment'; @@ -6,3 +8,7 @@ export const GROUP_FILTERS = { ALL: 'all', DESCENDANT_GROUPS: 'descendant_groups', }; + +export const API_MESSAGES = { + EMAIL_ALREADY_INVITED: __('Invite email has already been taken'), +}; diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js new file mode 100644 index 00000000000..b7bc9ea5652 --- /dev/null +++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js @@ -0,0 +1,65 @@ +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) { + return message.split(' and ')[0]; +} + +export function responseMessageFromError(response) { + if (!response?.response?.data) { + return ''; + } + + const { + response: { data }, + } = response; + + return ( + data.error || + data.message?.user?.[0] || + data.message?.access_level?.[0] || + data.message?.error || + data.message || + '' + ); +} + +export function responseMessageFromSuccess(response) { + if (!response?.[0]?.data) { + return ''; + } + + const { data } = response[0]; + + if (data.message && !data.message.user) { + 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 || ''; +} diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue index c659dfef495..6e300831e00 100644 --- a/app/assets/javascripts/issuable/components/issuable_by_email.vue +++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue @@ -36,7 +36,7 @@ export default { default: null, }, issuableType: { - default: '', + default: 'issue', }, emailsHelpPagePath: { default: '', @@ -78,7 +78,7 @@ export default { } = await axios.put(this.resetPath); this.email = newAddress; } catch { - this.$toast.show(__('There was an error when reseting email token.'), { type: 'error' }); + this.$toast.show(__('There was an error when reseting email token.')); } }, cancelHandler() { diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue b/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue new file mode 100644 index 00000000000..9509399e91d --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue @@ -0,0 +1,58 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { ISSUE_STATUS_SELECT_OPTIONS } from '../constants'; + +export default { + name: 'StatusSelect', + components: { + GlDropdown, + GlDropdownItem, + }, + data() { + return { + status: null, + }; + }, + computed: { + dropdownText() { + return this.status?.text ?? this.$options.i18n.defaultDropdownText; + }, + selectedValue() { + return this.status?.value; + }, + }, + methods: { + onDropdownItemClick(statusOption) { + // clear status if the currently checked status is clicked again + if (this.status?.value === statusOption.value) { + this.status = null; + } else { + this.status = statusOption; + } + }, + }, + i18n: { + dropdownTitle: __('Change status'), + defaultDropdownText: __('Select status'), + }, + ISSUE_STATUS_SELECT_OPTIONS, +}; +</script> +<template> + <div> + <input type="hidden" name="update[state_event]" :value="selectedValue" /> + <gl-dropdown :text="dropdownText" :title="$options.i18n.dropdownTitle" class="gl-w-full"> + <gl-dropdown-item + v-for="statusOption in $options.ISSUE_STATUS_SELECT_OPTIONS" + :key="statusOption.value" + :is-checked="selectedValue === statusOption.value" + is-check-item + :title="statusOption.text" + @click="onDropdownItemClick(statusOption)" + > + {{ statusOption.text }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js b/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js new file mode 100644 index 00000000000..ad15b25f9cf --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js @@ -0,0 +1,17 @@ +import { __ } from '~/locale'; + +export const ISSUE_STATUS_MODIFIERS = { + REOPEN: 'reopen', + CLOSE: 'close', +}; + +export const ISSUE_STATUS_SELECT_OPTIONS = [ + { + value: ISSUE_STATUS_MODIFIERS.REOPEN, + text: __('Open'), + }, + { + value: ISSUE_STATUS_MODIFIERS.CLOSE, + text: __('Closed'), + }, +]; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js b/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js new file mode 100644 index 00000000000..43179a86d70 --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import StatusSelect from './components/status_select.vue'; + +export default function initIssueStatusSelect() { + const el = document.querySelector('.js-issue-status'); + + if (!el) { + return null; + } + + return new Vue({ + el, + render(h) { + return h(StatusSelect); + }, + }); +} diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js index 366a9a8a883..463e0e5837e 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import { difference, intersection, union } from 'lodash'; -import { deprecatedCreateFlash as Flash } from './flash'; -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; export default { init({ form, issues, prefixId } = {}) { @@ -32,7 +32,9 @@ export default { onFormSubmitFailure() { this.form.find('[type="submit"]').enable(); - return new Flash(__('Issue update failed')); + return createFlash({ + message: __('Issue update failed'), + }); }, /** diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js index 97d50dde9f7..a9d4548f8cf 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js @@ -2,11 +2,12 @@ import $ from 'jquery'; import { property } from 'lodash'; + +import issueableEventHub from '~/issues_list/eventhub'; +import LabelsSelect from '~/labels_select'; +import MilestoneSelect from '~/milestone_select'; +import initIssueStatusSelect from './init_issue_status_select'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; -import issueStatusSelect from './issue_status_select'; -import issueableEventHub from './issues_list/eventhub'; -import LabelsSelect from './labels_select'; -import MilestoneSelect from './milestone_select'; import subscriptionSelect from './subscription_select'; const HIDDEN_CLASS = 'hidden'; @@ -29,7 +30,7 @@ export default class IssuableBulkUpdateSidebar { this.$sidebar = $('.right-sidebar'); this.$sidebarInnerContainer = this.$sidebar.find('.issuable-sidebar'); this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide'); - this.$bulkEditSubmitBtn = $('.update-selected-issues'); + this.$bulkEditSubmitBtn = $('.js-update-selected-issues'); this.$bulkUpdateEnableBtn = $('.js-bulk-update-toggle'); this.$otherFilters = $('.issues-other-filters'); this.$checkAllContainer = $('.check-all-holder'); @@ -56,7 +57,7 @@ export default class IssuableBulkUpdateSidebar { initDropdowns() { new LabelsSelect(); new MilestoneSelect(); - issueStatusSelect(); + initIssueStatusSelect(); subscriptionSelect(); if (IS_EE) { diff --git a/app/assets/javascripts/issuable_init_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js index 179c2b83c6c..179c2b83c6c 100644 --- a/app/assets/javascripts/issuable_init_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js index 4a688d819b0..b12ac776b4f 100644 --- a/app/assets/javascripts/subscription_select.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { __ } from './locale'; +import { __ } from '~/locale'; export default function subscriptionSelect() { $('.js-subscription-event').each((i, element) => { diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index a87d4f077cc..51b5237a339 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -9,19 +9,23 @@ export default class IssuableContext { this.userSelect = new UsersSelect(currentUser); this.reviewersSelect = new UsersSelect(currentUser, '.js-reviewer-search'); - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - // eslint-disable-next-line promise/no-nesting - loadCSSFile(gon.select2_css_path) - .then(() => { - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); - }) - .catch(() => {}); - }) - .catch(() => {}); + const $select2 = $('select.select2'); + + if ($select2.length) { + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + $select2.select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + }) + .catch(() => {}); + }) + .catch(() => {}); + } $('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() { return $(this).submit(); diff --git a/app/assets/javascripts/issuable_create/components/issuable_form.vue b/app/assets/javascripts/issuable_create/components/issuable_form.vue index 3cbd5620063..c216a05bdb0 100644 --- a/app/assets/javascripts/issuable_create/components/issuable_form.vue +++ b/app/assets/javascripts/issuable_create/components/issuable_form.vue @@ -72,16 +72,17 @@ export default { :show-suggest-popover="true" :textarea-value="issuableDescription" > - <textarea - id="issuable-description" - ref="textarea" - slot="textarea" - v-model="issuableDescription" - dir="auto" - class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" - ></textarea> + <template #textarea> + <textarea + id="issuable-description" + ref="textarea" + v-model="issuableDescription" + dir="auto" + class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files here…')" + ></textarea> + </template> </markdown-field> </div> </div> diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js index cdeee68b762..5a57da292a0 100644 --- a/app/assets/javascripts/issuable_index.js +++ b/app/assets/javascripts/issuable_index.js @@ -1,4 +1,4 @@ -import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar'; +import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; export default class IssuableIndex { constructor(pagePrefix = 'issuable_') { diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue index a19c76cfe3f..87066a0a0b6 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -134,7 +134,7 @@ export default { labelFilterParam: { type: String, required: false, - default: null, + default: undefined, }, isManualOrdering: { type: Boolean, diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue index ca057094868..011db52cbe3 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue @@ -153,9 +153,9 @@ export default { </template> </issuable-discussion> - <issuable-sidebar @sidebar-toggle="$emit('sidebar-toggle', $event)"> - <template #right-sidebar-items="sidebarProps"> - <slot name="right-sidebar-items" v-bind="sidebarProps"></slot> + <issuable-sidebar> + <template #right-sidebar-items="{ sidebarExpanded, toggleSidebar }"> + <slot name="right-sidebar-items" v-bind="{ sidebarExpanded, toggleSidebar }"></slot> </template> </issuable-sidebar> </div> diff --git a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue index 8a159139af0..99dcccd12ed 100644 --- a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue +++ b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue @@ -2,15 +2,15 @@ import { GlIcon } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; - import { parseBoolean } from '~/lib/utils/common_utils'; +import { USER_COLLAPSED_GUTTER_COOKIE } from '../constants'; export default { components: { GlIcon, }, data() { - const userExpanded = !parseBoolean(Cookies.get('collapsed_gutter')); + const userExpanded = !parseBoolean(Cookies.get(USER_COLLAPSED_GUTTER_COOKIE)); // We're deliberately keeping two different props for sidebar status; // 1. userExpanded reflects value based on cookie `collapsed_gutter`. @@ -20,13 +20,6 @@ export default { isExpanded: userExpanded ? bp.isDesktop() : userExpanded, }; }, - watch: { - isExpanded(expanded) { - this.$emit('sidebar-toggle', { - expanded, - }); - }, - }, mounted() { window.addEventListener('resize', this.handleWindowResize); this.updatePageContainerClass(); @@ -49,11 +42,11 @@ export default { this.updatePageContainerClass(); } }, - handleToggleSidebarClick() { + toggleSidebar() { this.isExpanded = !this.isExpanded; this.userExpanded = this.isExpanded; - Cookies.set('collapsed_gutter', !this.userExpanded); + Cookies.set(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded); this.updatePageContainerClass(); }, }, @@ -68,8 +61,9 @@ export default { > <button class="toggle-right-sidebar-button js-toggle-right-sidebar-button w-100 gl-text-decoration-none! gl-display-flex gl-outline-0!" + data-testid="toggle-right-sidebar-button" :title="__('Toggle sidebar')" - @click="handleToggleSidebarClick" + @click="toggleSidebar" > <span v-if="isExpanded" class="collapse-text gl-flex-grow-1 gl-text-left">{{ __('Collapse sidebar') @@ -83,7 +77,10 @@ export default { /> </button> <div data-testid="sidebar-items" class="issuable-sidebar"> - <slot name="right-sidebar-items" v-bind="{ sidebarExpanded: isExpanded }"></slot> + <slot + name="right-sidebar-items" + v-bind="{ sidebarExpanded: isExpanded, toggleSidebar }" + ></slot> </div> </aside> </template> diff --git a/app/assets/javascripts/issuable_sidebar/constants.js b/app/assets/javascripts/issuable_sidebar/constants.js new file mode 100644 index 00000000000..4f4b6341a1c --- /dev/null +++ b/app/assets/javascripts/issuable_sidebar/constants.js @@ -0,0 +1 @@ +export const USER_COLLAPSED_GUTTER_COOKIE = 'collapsed_gutter'; diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index f6eff8133a7..1e053d7daaa 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { joinPaths } from '~/lib/utils/url_utility'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; -import { deprecatedCreateFlash as flash } from './flash'; +import createFlash from './flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from './issuable/constants'; import axios from './lib/utils/axios_utils'; import { addDelimiter } from './lib/utils/text_utility'; @@ -68,7 +68,9 @@ export default class Issue { this.createMergeRequestDropdown.checkAbilityToCreateBranch(); } } else { - flash(issueFailMessage); + createFlash({ + message: issueFailMessage, + }); } } @@ -102,6 +104,10 @@ export default class Issue { $container.html(data.html); } }) - .catch(() => flash(__('Failed to load related branches'))); + .catch(() => + createFlash({ + message: __('Failed to load related branches'), + }), + ); } } diff --git a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql index bb637dea033..938b90b3f7c 100644 --- a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql +++ b/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql @@ -1,6 +1,7 @@ query getAlert($iid: String!, $fullPath: ID!) { project(fullPath: $fullPath) { issue(iid: $iid) { + id alertManagementAlert { iid title diff --git a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql b/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql index 9c28fdded21..ec8d8f32d8b 100644 --- a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql +++ b/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql @@ -1,5 +1,9 @@ mutation updateIssue($input: UpdateIssueInput!) { updateIssue(input: $input) { + issuable: issue { + id + state + } errors } } diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js index 08b04ebfdaf..b1deeaae0fc 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issue_show/services/index.js @@ -1,11 +1,9 @@ -import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor'; import axios from '../../lib/utils/axios_utils'; export default class Service { constructor(endpoint) { this.endpoint = `${endpoint}.json`; this.realtimeEndpoint = `${endpoint}/realtime_changes`; - registerCaptchaModalInterceptor(axios); } getData() { diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js deleted file mode 100644 index 2ede0837930..00000000000 --- a/app/assets/javascripts/issue_status_select.js +++ /dev/null @@ -1,27 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { __ } from './locale'; - -export default function issueStatusSelect() { - $('.js-issue-status').each((i, el) => { - const fieldName = $(el).data('fieldName'); - initDeprecatedJQueryDropdown($(el), { - selectable: true, - fieldName, - toggleLabel(selected, element, instance) { - let label = __('Author'); - const $item = instance.dropdown.find('.is-active'); - if ($item.length) { - label = $item.text(); - } - return label; - }, - clicked(options) { - return options.e.preventDefault(); - }, - id(obj, element) { - return $(element).data('id'); - }, - }); - }); -} diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue index 51cad662ebf..b13a389b963 100644 --- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue @@ -6,15 +6,11 @@ import { GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; import { toNumber, omit } from 'lodash'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { - scrollToElement, - urlParamsToObject, - historyPushState, - getParameterByName, -} from '~/lib/utils/common_utils'; -import { setUrlParams } from '~/lib/utils/url_utility'; +import { scrollToElement, historyPushState } from '~/lib/utils/common_utils'; +// eslint-disable-next-line import/no-deprecated +import { setUrlParams, urlParamsToObject, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import initManualOrdering from '~/manual_ordering'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; @@ -82,10 +78,7 @@ export default { isBulkEditing: false, issuables: [], loading: false, - page: - getParameterByName('page', window.location.href) !== null - ? toNumber(getParameterByName('page')) - : 1, + page: getParameterByName('page') !== null ? toNumber(getParameterByName('page')) : 1, selection: {}, totalItems: 0, }; @@ -265,10 +258,13 @@ export default { }) .catch(() => { this.loading = false; - return flash(__('An error occurred while loading issues')); + return createFlash({ + message: __('An error occurred while loading issues'), + }); }); }, getQueryObject() { + // eslint-disable-next-line import/no-deprecated return urlParamsToObject(window.location.search); }, onPaginate(newPage) { 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 70d73aca925..07492b0046c 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 @@ -115,7 +115,7 @@ export default { {{ timeEstimate }} </span> <weight-count - class="gl-display-none gl-sm-display-inline-block gl-mr-3" + class="issuable-weight gl-display-none gl-sm-display-inline-block gl-mr-3" :weight="issue.weight" /> <issue-health-status 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 dbf7717b248..6563094ef72 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -11,45 +11,47 @@ import { import fuzzaldrinPlus from 'fuzzaldrin-plus'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; import createFlash from '~/flash'; +import { TYPE_USER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; import { - API_PARAM, CREATED_DESC, i18n, initialPageParams, + issuesCountSmartQueryBase, MAX_LIST_SIZE, PAGE_SIZE, PARAM_DUE_DATE, PARAM_SORT, PARAM_STATE, - RELATIVE_POSITION_DESC, + RELATIVE_POSITION_ASC, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, - TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_EPIC, TOKEN_TYPE_ITERATION, TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_WEIGHT, UPDATED_DESC, - URL_PARAM, urlSortParams, } from '~/issues_list/constants'; import { - convertToParams, + convertToApiParams, convertToSearchQuery, + convertToUrlParams, getDueDateValue, getFilterTokens, getSortKey, getSortOptions, } from '~/issues_list/utils'; import axios from '~/lib/utils/axios_utils'; -import { getParameterByName } from '~/lib/utils/common_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { DEFAULT_NONE_ANY, OPERATOR_IS_ONLY, @@ -71,6 +73,10 @@ import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; import eventHub from '../eventhub'; +import searchIterationsQuery from '../queries/search_iterations.query.graphql'; +import searchLabelsQuery from '../queries/search_labels.query.graphql'; +import searchMilestonesQuery from '../queries/search_milestones.query.graphql'; +import searchUsersQuery from '../queries/search_users.query.graphql'; import IssueCardTimeInfo from './issue_card_time_info.vue'; export default { @@ -95,9 +101,6 @@ export default { autocompleteAwardEmojisPath: { default: '', }, - autocompleteUsersPath: { - default: '', - }, calendarPath: { default: '', }, @@ -119,6 +122,9 @@ export default { hasIssueWeightsFeature: { default: false, }, + hasIterationsFeature: { + default: false, + }, hasMultipleIssueAssigneesFeature: { default: false, }, @@ -140,15 +146,6 @@ export default { newIssuePath: { default: '', }, - projectIterationsPath: { - default: '', - }, - projectLabelsPath: { - default: '', - }, - projectMilestonesPath: { - default: '', - }, projectPath: { default: '', }, @@ -176,26 +173,17 @@ export default { showBulkEditSidebar: false, sortKey: getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey, state: state || IssuableStates.Opened, - totalIssues: 0, }; }, apollo: { issues: { query: getIssuesQuery, variables() { - return { - projectPath: this.projectPath, - search: this.searchQuery, - sort: this.sortKey, - state: this.state, - ...this.pageParams, - ...this.apiFilterParams, - }; + return this.queryVariables; }, - update: ({ project }) => project.issues.nodes, + update: ({ project }) => project?.issues.nodes ?? [], result({ data }) { - this.pageInfo = data.project.issues.pageInfo; - this.totalIssues = data.project.issues.count; + this.pageInfo = data.project?.issues.pageInfo ?? {}; this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }, error(error) { @@ -206,8 +194,55 @@ export default { }, debounce: 200, }, + countOpened: { + ...issuesCountSmartQueryBase, + variables() { + return { + ...this.queryVariables, + state: IssuableStates.Opened, + }; + }, + skip() { + return !this.hasProjectIssues; + }, + }, + countClosed: { + ...issuesCountSmartQueryBase, + variables() { + return { + ...this.queryVariables, + state: IssuableStates.Closed, + }; + }, + skip() { + return !this.hasProjectIssues; + }, + }, + countAll: { + ...issuesCountSmartQueryBase, + variables() { + return { + ...this.queryVariables, + state: IssuableStates.All, + }; + }, + skip() { + return !this.hasProjectIssues; + }, + }, }, computed: { + queryVariables() { + return { + isSignedIn: this.isSignedIn, + projectPath: this.projectPath, + search: this.searchQuery, + sort: this.sortKey, + state: this.state, + ...this.pageParams, + ...this.apiFilterParams, + }; + }, hasSearch() { return this.searchQuery || Object.keys(this.urlFilterParams).length; }, @@ -215,32 +250,30 @@ export default { return this.showBulkEditSidebar || !this.issues.length; }, isManualOrdering() { - return this.sortKey === RELATIVE_POSITION_DESC; + return this.sortKey === RELATIVE_POSITION_ASC; }, isOpenTab() { return this.state === IssuableStates.Opened; }, apiFilterParams() { - return convertToParams(this.filterTokens, API_PARAM); + return convertToApiParams(this.filterTokens); }, urlFilterParams() { - return convertToParams(this.filterTokens, URL_PARAM); + return convertToUrlParams(this.filterTokens); }, searchQuery() { return convertToSearchQuery(this.filterTokens) || undefined; }, searchTokens() { - let preloadedAuthors = []; + const preloadedAuthors = []; if (gon.current_user_id) { - preloadedAuthors = [ - { - id: gon.current_user_id, - name: gon.current_user_fullname, - username: gon.current_username, - avatar_url: gon.current_user_avatar_url, - }, - ]; + preloadedAuthors.push({ + id: convertToGraphQLId(TYPE_USER, gon.current_user_id), + name: gon.current_user_fullname, + username: gon.current_username, + avatar_url: gon.current_user_avatar_url, + }); } const tokens = [ @@ -252,6 +285,7 @@ export default { dataType: 'user', unique: true, defaultAuthors: [], + operators: OPERATOR_IS_ONLY, fetchAuthors: this.fetchUsers, preloadedAuthors, }, @@ -280,7 +314,7 @@ export default { title: TOKEN_TITLE_LABEL, icon: 'labels', token: LabelToken, - defaultLabels: [], + defaultLabels: DEFAULT_NONE_ANY, fetchLabels: this.fetchLabels, }, ]; @@ -310,7 +344,7 @@ export default { }); } - if (this.projectIterationsPath) { + if (this.hasIterationsFeature) { tokens.push({ type: TOKEN_TYPE_ITERATION, title: TOKEN_TITLE_ITERATION, @@ -329,6 +363,7 @@ export default { token: EpicToken, unique: true, idProperty: 'id', + useIdValue: true, fetchEpics: this.fetchEpics, }); } @@ -346,37 +381,28 @@ export default { return tokens; }, showPaginationControls() { - return this.issues.length > 0; + return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); }, sortOptions() { return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature); }, tabCounts() { - return Object.values(IssuableStates).reduce( - (acc, state) => ({ - ...acc, - [state]: this.state === state ? this.totalIssues : undefined, - }), - {}, - ); + return { + [IssuableStates.Opened]: this.countOpened, + [IssuableStates.Closed]: this.countClosed, + [IssuableStates.All]: this.countAll, + }; + }, + currentTabCount() { + return this.tabCounts[this.state] ?? 0; }, urlParams() { - const filterParams = { - ...this.urlFilterParams, - }; - - if (filterParams.epic_id) { - filterParams.epic_id = encodeURIComponent(filterParams.epic_id); - } else if (filterParams['not[epic_id]']) { - filterParams['not[epic_id]'] = encodeURIComponent(filterParams['not[epic_id]']); - } - return { due_date: this.dueDateFilter, search: this.searchQuery, + sort: urlSortParams[this.sortKey], state: this.state, - ...urlSortParams[this.sortKey], - ...filterParams, + ...this.urlFilterParams, }; }, }, @@ -418,16 +444,42 @@ export default { : epics.filter((epic) => epic.id === number); }, fetchLabels(search) { - return this.fetchWithCache(this.projectLabelsPath, 'labels', 'title', search); + return this.$apollo + .query({ + query: searchLabelsQuery, + variables: { projectPath: this.projectPath, search }, + }) + .then(({ data }) => data.project.labels.nodes); }, fetchMilestones(search) { - return this.fetchWithCache(this.projectMilestonesPath, 'milestones', 'title', search, true); + return this.$apollo + .query({ + query: searchMilestonesQuery, + variables: { projectPath: this.projectPath, search }, + }) + .then(({ data }) => data.project.milestones.nodes); }, fetchIterations(search) { - return axios.get(this.projectIterationsPath, { params: { search } }); + const id = Number(search); + const variables = + !search || Number.isNaN(id) + ? { projectPath: this.projectPath, search } + : { projectPath: this.projectPath, id }; + + return this.$apollo + .query({ + query: searchIterationsQuery, + variables, + }) + .then(({ data }) => data.project.iterations.nodes); }, fetchUsers(search) { - return axios.get(this.autocompleteUsersPath, { params: { search } }); + return this.$apollo + .query({ + query: searchUsersQuery, + variables: { projectPath: this.projectPath, search }, + }) + .then(({ data }) => data.project.projectMembers.nodes.map((member) => member.user)); }, getExportCsvPathWithQuery() { return `${this.exportCsvPath}${window.location.search}`; @@ -450,7 +502,9 @@ export default { }, async handleBulkUpdateClick() { if (!this.hasInitBulkEdit) { - const initBulkUpdateSidebar = await import('~/issuable_init_bulk_update_sidebar'); + const initBulkUpdateSidebar = await import( + '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar' + ); initBulkUpdateSidebar.default.init('issuable_'); const usersSelect = await import('~/users_select'); @@ -469,6 +523,7 @@ export default { this.state = state; }, handleFilter(filter) { + this.pageParams = initialPageParams; this.filterTokens = filter; }, handleNextPage() { @@ -581,7 +636,7 @@ export default { v-if="isSignedIn" class="gl-md-mr-3" :export-csv-path="exportCsvPathWithQuery" - :issuable-count="totalIssues" + :issuable-count="currentTabCount" /> <gl-button v-if="canBulkUpdate" @@ -609,7 +664,7 @@ export default { v-gl-tooltip class="gl-display-none gl-sm-display-block" :title="$options.i18n.relatedMergeRequests" - data-testid="issuable-mr" + data-testid="merge-requests" > <gl-icon name="merge-request" /> {{ issuable.mergeRequestsCount }} @@ -617,7 +672,7 @@ export default { <li v-if="issuable.upvotes" v-gl-tooltip - class="gl-display-none gl-sm-display-block" + class="issuable-upvotes gl-display-none gl-sm-display-block" :title="$options.i18n.upvotes" data-testid="issuable-upvotes" > @@ -627,7 +682,7 @@ export default { <li v-if="issuable.downvotes" v-gl-tooltip - class="gl-display-none gl-sm-display-block" + class="issuable-downvotes gl-display-none gl-sm-display-block" :title="$options.i18n.downvotes" data-testid="issuable-downvotes" > @@ -635,9 +690,10 @@ export default { {{ issuable.downvotes }} </li> <blocking-issues-count - class="gl-display-none gl-sm-display-block" - :blocking-issues-count="issuable.blockedByCount" + class="blocking-issues gl-display-none gl-sm-display-block" + :blocking-issues-count="issuable.blockingCount" :is-list-item="true" + data-testid="blocking-issues" /> </template> @@ -692,7 +748,7 @@ export default { <csv-import-export-buttons class="gl-mr-3" :export-csv-path="exportCsvPathWithQuery" - :issuable-count="totalIssues" + :issuable-count="currentTabCount" /> </template> </gl-empty-state> diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index 76006f9081d..d94d4b9a19a 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -1,3 +1,5 @@ +import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql'; +import createFlash from '~/flash'; import { __, s__ } from '~/locale'; import { FILTER_ANY, @@ -68,6 +70,7 @@ export const i18n = { confidentialYes: __('Yes'), downvotes: __('Downvotes'), editIssues: __('Edit issues'), + errorFetchingCounts: __('An error occurred while getting issue counts'), errorFetchingIssues: __('An error occurred while loading issues'), jiraIntegrationMessage: s__( 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.', @@ -94,7 +97,7 @@ export const i18n = { relatedMergeRequests: __('Related merge requests'), reorderError: __('An error occurred while reordering issues.'), rssLabel: __('Subscribe to RSS feed'), - searchPlaceholder: __('Search or filter results…'), + searchPlaceholder: __('Search or filter results...'), upvotes: __('Upvotes'), }; @@ -128,21 +131,21 @@ export const CREATED_ASC = 'CREATED_ASC'; export const CREATED_DESC = 'CREATED_DESC'; export const DUE_DATE_ASC = 'DUE_DATE_ASC'; export const DUE_DATE_DESC = 'DUE_DATE_DESC'; +export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC'; export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC'; export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC'; export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC'; export const POPULARITY_ASC = 'POPULARITY_ASC'; export const POPULARITY_DESC = 'POPULARITY_DESC'; +export const PRIORITY_ASC = 'PRIORITY_ASC'; export const PRIORITY_DESC = 'PRIORITY_DESC'; -export const RELATIVE_POSITION_DESC = 'RELATIVE_POSITION_DESC'; +export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC'; export const UPDATED_ASC = 'UPDATED_ASC'; export const UPDATED_DESC = 'UPDATED_DESC'; export const WEIGHT_ASC = 'WEIGHT_ASC'; export const WEIGHT_DESC = 'WEIGHT_DESC'; -const SORT_ASC = 'asc'; -const SORT_DESC = 'desc'; - +const PRIORITY_ASC_SORT = 'priority_asc'; const CREATED_DATE_SORT = 'created_date'; const CREATED_ASC_SORT = 'created_asc'; const UPDATED_DESC_SORT = 'updated_desc'; @@ -150,129 +153,30 @@ const UPDATED_ASC_SORT = 'updated_asc'; const MILESTONE_SORT = 'milestone'; const MILESTONE_DUE_DESC_SORT = 'milestone_due_desc'; const DUE_DATE_DESC_SORT = 'due_date_desc'; +const LABEL_PRIORITY_ASC_SORT = 'label_priority_asc'; const POPULARITY_ASC_SORT = 'popularity_asc'; const WEIGHT_DESC_SORT = 'weight_desc'; const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc'; -const BLOCKING_ISSUES = 'blocking_issues'; - -export const apiSortParams = { - [PRIORITY_DESC]: { - order_by: PRIORITY, - sort: SORT_DESC, - }, - [CREATED_ASC]: { - order_by: CREATED_AT, - sort: SORT_ASC, - }, - [CREATED_DESC]: { - order_by: CREATED_AT, - sort: SORT_DESC, - }, - [UPDATED_ASC]: { - order_by: UPDATED_AT, - sort: SORT_ASC, - }, - [UPDATED_DESC]: { - order_by: UPDATED_AT, - sort: SORT_DESC, - }, - [MILESTONE_DUE_ASC]: { - order_by: MILESTONE_DUE, - sort: SORT_ASC, - }, - [MILESTONE_DUE_DESC]: { - order_by: MILESTONE_DUE, - sort: SORT_DESC, - }, - [DUE_DATE_ASC]: { - order_by: DUE_DATE, - sort: SORT_ASC, - }, - [DUE_DATE_DESC]: { - order_by: DUE_DATE, - sort: SORT_DESC, - }, - [POPULARITY_ASC]: { - order_by: POPULARITY, - sort: SORT_ASC, - }, - [POPULARITY_DESC]: { - order_by: POPULARITY, - sort: SORT_DESC, - }, - [LABEL_PRIORITY_DESC]: { - order_by: LABEL_PRIORITY, - sort: SORT_DESC, - }, - [RELATIVE_POSITION_DESC]: { - order_by: RELATIVE_POSITION, - per_page: 100, - sort: SORT_ASC, - }, - [WEIGHT_ASC]: { - order_by: WEIGHT, - sort: SORT_ASC, - }, - [WEIGHT_DESC]: { - order_by: WEIGHT, - sort: SORT_DESC, - }, - [BLOCKING_ISSUES_DESC]: { - order_by: BLOCKING_ISSUES, - sort: SORT_DESC, - }, -}; export const urlSortParams = { - [PRIORITY_DESC]: { - sort: PRIORITY, - }, - [CREATED_ASC]: { - sort: CREATED_ASC_SORT, - }, - [CREATED_DESC]: { - sort: CREATED_DATE_SORT, - }, - [UPDATED_ASC]: { - sort: UPDATED_ASC_SORT, - }, - [UPDATED_DESC]: { - sort: UPDATED_DESC_SORT, - }, - [MILESTONE_DUE_ASC]: { - sort: MILESTONE_SORT, - }, - [MILESTONE_DUE_DESC]: { - sort: MILESTONE_DUE_DESC_SORT, - }, - [DUE_DATE_ASC]: { - sort: DUE_DATE, - }, - [DUE_DATE_DESC]: { - sort: DUE_DATE_DESC_SORT, - }, - [POPULARITY_ASC]: { - sort: POPULARITY_ASC_SORT, - }, - [POPULARITY_DESC]: { - sort: POPULARITY, - }, - [LABEL_PRIORITY_DESC]: { - sort: LABEL_PRIORITY, - }, - [RELATIVE_POSITION_DESC]: { - sort: RELATIVE_POSITION, - per_page: 100, - }, - [WEIGHT_ASC]: { - sort: WEIGHT, - }, - [WEIGHT_DESC]: { - sort: WEIGHT_DESC_SORT, - }, - [BLOCKING_ISSUES_DESC]: { - sort: BLOCKING_ISSUES_DESC_SORT, - }, + [PRIORITY_ASC]: PRIORITY_ASC_SORT, + [PRIORITY_DESC]: PRIORITY, + [CREATED_ASC]: CREATED_ASC_SORT, + [CREATED_DESC]: CREATED_DATE_SORT, + [UPDATED_ASC]: UPDATED_ASC_SORT, + [UPDATED_DESC]: UPDATED_DESC_SORT, + [MILESTONE_DUE_ASC]: MILESTONE_SORT, + [MILESTONE_DUE_DESC]: MILESTONE_DUE_DESC_SORT, + [DUE_DATE_ASC]: DUE_DATE, + [DUE_DATE_DESC]: DUE_DATE_DESC_SORT, + [POPULARITY_ASC]: POPULARITY_ASC_SORT, + [POPULARITY_DESC]: POPULARITY, + [LABEL_PRIORITY_ASC]: LABEL_PRIORITY_ASC_SORT, + [LABEL_PRIORITY_DESC]: LABEL_PRIORITY, + [RELATIVE_POSITION_ASC]: RELATIVE_POSITION, + [WEIGHT_ASC]: WEIGHT, + [WEIGHT_DESC]: WEIGHT_DESC_SORT, + [BLOCKING_ISSUES_DESC]: BLOCKING_ISSUES_DESC_SORT, }; export const MAX_LIST_SIZE = 10; @@ -297,12 +201,7 @@ export const TOKEN_TYPE_WEIGHT = 'weight'; export const filters = { [TOKEN_TYPE_AUTHOR]: { [API_PARAM]: { - [OPERATOR_IS]: { - [NORMAL_FILTER]: 'author_username', - }, - [OPERATOR_IS_NOT]: { - [NORMAL_FILTER]: 'not[author_username]', - }, + [NORMAL_FILTER]: 'authorUsername', }, [URL_PARAM]: { [OPERATOR_IS]: { @@ -315,13 +214,8 @@ export const filters = { }, [TOKEN_TYPE_ASSIGNEE]: { [API_PARAM]: { - [OPERATOR_IS]: { - [NORMAL_FILTER]: 'assignee_username', - [SPECIAL_FILTER]: 'assignee_id', - }, - [OPERATOR_IS_NOT]: { - [NORMAL_FILTER]: 'not[assignee_username]', - }, + [NORMAL_FILTER]: 'assigneeUsernames', + [SPECIAL_FILTER]: 'assigneeId', }, [URL_PARAM]: { [OPERATOR_IS]: { @@ -336,12 +230,7 @@ export const filters = { }, [TOKEN_TYPE_MILESTONE]: { [API_PARAM]: { - [OPERATOR_IS]: { - [NORMAL_FILTER]: 'milestone', - }, - [OPERATOR_IS_NOT]: { - [NORMAL_FILTER]: 'not[milestone]', - }, + [NORMAL_FILTER]: 'milestoneTitle', }, [URL_PARAM]: { [OPERATOR_IS]: { @@ -354,16 +243,13 @@ export const filters = { }, [TOKEN_TYPE_LABEL]: { [API_PARAM]: { - [OPERATOR_IS]: { - [NORMAL_FILTER]: 'labels', - }, - [OPERATOR_IS_NOT]: { - [NORMAL_FILTER]: 'not[labels]', - }, + [NORMAL_FILTER]: 'labelName', + [SPECIAL_FILTER]: 'labelName', }, [URL_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'label_name[]', + [SPECIAL_FILTER]: 'label_name[]', }, [OPERATOR_IS_NOT]: { [NORMAL_FILTER]: 'not[label_name][]', @@ -372,10 +258,8 @@ export const filters = { }, [TOKEN_TYPE_MY_REACTION]: { [API_PARAM]: { - [OPERATOR_IS]: { - [NORMAL_FILTER]: 'my_reaction_emoji', - [SPECIAL_FILTER]: 'my_reaction_emoji', - }, + [NORMAL_FILTER]: 'myReactionEmoji', + [SPECIAL_FILTER]: 'myReactionEmoji', }, [URL_PARAM]: { [OPERATOR_IS]: { @@ -386,9 +270,7 @@ export const filters = { }, [TOKEN_TYPE_CONFIDENTIAL]: { [API_PARAM]: { - [OPERATOR_IS]: { - [NORMAL_FILTER]: 'confidential', - }, + [NORMAL_FILTER]: 'confidential', }, [URL_PARAM]: { [OPERATOR_IS]: { @@ -398,33 +280,23 @@ export const filters = { }, [TOKEN_TYPE_ITERATION]: { [API_PARAM]: { - [OPERATOR_IS]: { - [NORMAL_FILTER]: 'iteration_title', - [SPECIAL_FILTER]: 'iteration_id', - }, - [OPERATOR_IS_NOT]: { - [NORMAL_FILTER]: 'not[iteration_title]', - }, + [NORMAL_FILTER]: 'iterationId', + [SPECIAL_FILTER]: 'iterationWildcardId', }, [URL_PARAM]: { [OPERATOR_IS]: { - [NORMAL_FILTER]: 'iteration_title', + [NORMAL_FILTER]: 'iteration_id', [SPECIAL_FILTER]: 'iteration_id', }, [OPERATOR_IS_NOT]: { - [NORMAL_FILTER]: 'not[iteration_title]', + [NORMAL_FILTER]: 'not[iteration_id]', }, }, }, [TOKEN_TYPE_EPIC]: { [API_PARAM]: { - [OPERATOR_IS]: { - [NORMAL_FILTER]: 'epic_id', - [SPECIAL_FILTER]: 'epic_id', - }, - [OPERATOR_IS_NOT]: { - [NORMAL_FILTER]: 'not[epic_id]', - }, + [NORMAL_FILTER]: 'epicId', + [SPECIAL_FILTER]: 'epicId', }, [URL_PARAM]: { [OPERATOR_IS]: { @@ -438,13 +310,8 @@ export const filters = { }, [TOKEN_TYPE_WEIGHT]: { [API_PARAM]: { - [OPERATOR_IS]: { - [NORMAL_FILTER]: 'weight', - [SPECIAL_FILTER]: 'weight', - }, - [OPERATOR_IS_NOT]: { - [NORMAL_FILTER]: 'not[weight]', - }, + [NORMAL_FILTER]: 'weight', + [SPECIAL_FILTER]: 'weight', }, [URL_PARAM]: { [OPERATOR_IS]: { @@ -457,3 +324,15 @@ export const filters = { }, }, }; + +export const issuesCountSmartQueryBase = { + query: getIssuesCountQuery, + context: { + isSingleRequest: true, + }, + update: ({ project }) => project?.issues.count, + error(error) { + createFlash({ message: i18n.errorFetchingCounts, captureError: true, error }); + }, + debounce: 200, +}; diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 97b9a9a115d..71ceb9bef55 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -1,6 +1,5 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { IssuableType } from '~/issue_show/constants'; import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; @@ -82,7 +81,6 @@ export function mountIssuesListApp() { const { autocompleteAwardEmojisPath, - autocompleteUsersPath, calendarPath, canBulkUpdate, canEdit, @@ -95,6 +93,7 @@ export function mountIssuesListApp() { hasBlockedIssuesFeature, hasIssuableHealthStatusFeature, hasIssueWeightsFeature, + hasIterationsFeature, hasMultipleIssueAssigneesFeature, hasProjectIssues, importCsvIssuesPath, @@ -106,9 +105,6 @@ export function mountIssuesListApp() { maxAttachmentSize, newIssuePath, projectImportJiraPath, - projectIterationsPath, - projectLabelsPath, - projectMilestonesPath, projectPath, quickActionsHelpPath, resetPath, @@ -122,7 +118,6 @@ export function mountIssuesListApp() { apolloProvider, provide: { autocompleteAwardEmojisPath, - autocompleteUsersPath, calendarPath, canBulkUpdate: parseBoolean(canBulkUpdate), emptyStateSvgPath, @@ -130,15 +125,13 @@ export function mountIssuesListApp() { hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + hasIterationsFeature: parseBoolean(hasIterationsFeature), hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature), hasProjectIssues: parseBoolean(hasProjectIssues), isSignedIn: parseBoolean(isSignedIn), issuesPath, jiraIntegrationPath, newIssuePath, - projectIterationsPath, - projectLabelsPath, - projectMilestonesPath, projectPath, rssPath, showNewIssueLink: parseBoolean(showNewIssueLink), @@ -156,7 +149,6 @@ export function mountIssuesListApp() { // For IssuableByEmail component emailsHelpPagePath, initialEmail, - issuableType: IssuableType.Issue, markdownHelpPath, quickActionsHelpPath, resetPath, 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 afd53084ca0..124190915c0 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql @@ -2,6 +2,7 @@ #import "./issue.fragment.graphql" query getProjectIssues( + $isSignedIn: Boolean = false $projectPath: ID! $search: String $sort: IssueSort @@ -33,7 +34,6 @@ query getProjectIssues( first: $firstPageSize last: $lastPageSize ) { - count pageInfo { ...PageInfo } diff --git a/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql new file mode 100644 index 00000000000..a1742859640 --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql @@ -0,0 +1,26 @@ +query getProjectIssuesCount( + $projectPath: ID! + $search: String + $state: IssuableState + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $labelName: [String] + $milestoneTitle: [String] + $not: NegatedIssueFilterInput +) { + project(fullPath: $projectPath) { + issues( + search: $search + state: $state + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + labelName: $labelName + milestoneTitle: $milestoneTitle + not: $not + ) { + count + } + } +} diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql index de30d8b4bf6..f7ebf64ffb8 100644 --- a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql @@ -11,7 +11,7 @@ fragment IssueFragment on Issue { title updatedAt upvotes - userDiscussionsCount + userDiscussionsCount @include(if: $isSignedIn) webUrl assignees { nodes { diff --git a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql new file mode 100644 index 00000000000..11d9dcea573 --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql @@ -0,0 +1,10 @@ +query searchIterations($projectPath: ID!, $search: String, $id: ID) { + project(fullPath: $projectPath) { + iterations(title: $search, id: $id) { + nodes { + id + title + } + } + } +} diff --git a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql new file mode 100644 index 00000000000..de884e1221c --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql @@ -0,0 +1,12 @@ +query searchLabels($projectPath: ID!, $search: String) { + project(fullPath: $projectPath) { + labels(searchTerm: $search, includeAncestorGroups: true) { + nodes { + id + color + textColor + title + } + } + } +} diff --git a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql new file mode 100644 index 00000000000..91f74fd220b --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql @@ -0,0 +1,10 @@ +query searchMilestones($projectPath: ID!, $search: String) { + project(fullPath: $projectPath) { + milestones(searchTitle: $search, includeAncestors: true) { + nodes { + id + title + } + } + } +} diff --git a/app/assets/javascripts/issues_list/queries/search_users.query.graphql b/app/assets/javascripts/issues_list/queries/search_users.query.graphql new file mode 100644 index 00000000000..953157cfe3a --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/search_users.query.graphql @@ -0,0 +1,14 @@ +query searchUsers($projectPath: ID!, $search: String) { + project(fullPath: $projectPath) { + projectMembers(search: $search) { + nodes { + user { + id + avatarUrl + name + username + } + } + } + } +} diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js index b5ec44198da..49f937cc453 100644 --- a/app/assets/javascripts/issues_list/utils.js +++ b/app/assets/javascripts/issues_list/utils.js @@ -1,4 +1,5 @@ import { + API_PARAM, BLOCKING_ISSUES_DESC, CREATED_ASC, CREATED_DESC, @@ -6,29 +7,36 @@ import { DUE_DATE_DESC, DUE_DATE_VALUES, filters, + LABEL_PRIORITY_ASC, LABEL_PRIORITY_DESC, MILESTONE_DUE_ASC, MILESTONE_DUE_DESC, NORMAL_FILTER, POPULARITY_ASC, POPULARITY_DESC, + PRIORITY_ASC, PRIORITY_DESC, - RELATIVE_POSITION_DESC, + RELATIVE_POSITION_ASC, SPECIAL_FILTER, SPECIAL_FILTER_VALUES, TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_ITERATION, UPDATED_ASC, UPDATED_DESC, + URL_PARAM, urlSortParams, WEIGHT_ASC, WEIGHT_DESC, } from '~/issues_list/constants'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; -import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + FILTERED_SEARCH_TERM, + OPERATOR_IS_NOT, +} from '~/vue_shared/components/filtered_search_bar/constants'; export const getSortKey = (sort) => - Object.keys(urlSortParams).find((key) => urlSortParams[key].sort === sort); + Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort); export const getDueDateValue = (value) => (DUE_DATE_VALUES.includes(value) ? value : undefined); @@ -38,7 +46,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) id: 1, title: __('Priority'), sortDirection: { - ascending: PRIORITY_DESC, + ascending: PRIORITY_ASC, descending: PRIORITY_DESC, }, }, @@ -86,7 +94,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) id: 7, title: __('Label priority'), sortDirection: { - ascending: LABEL_PRIORITY_DESC, + ascending: LABEL_PRIORITY_ASC, descending: LABEL_PRIORITY_DESC, }, }, @@ -94,8 +102,8 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) id: 8, title: __('Manual'), sortDirection: { - ascending: RELATIVE_POSITION_DESC, - descending: RELATIVE_POSITION_DESC, + ascending: RELATIVE_POSITION_ASC, + descending: RELATIVE_POSITION_ASC, }, }, ]; @@ -128,7 +136,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) const tokenTypes = Object.keys(filters); const getUrlParams = (tokenType) => - Object.values(filters[tokenType].urlParam).flatMap((filterObj) => Object.values(filterObj)); + Object.values(filters[tokenType][URL_PARAM]).flatMap((filterObj) => Object.values(filterObj)); const urlParamKeys = tokenTypes.flatMap(getUrlParams); @@ -136,7 +144,7 @@ const getTokenTypeFromUrlParamKey = (urlParamKey) => tokenTypes.find((tokenType) => getUrlParams(tokenType).includes(urlParamKey)); const getOperatorFromUrlParamKey = (tokenType, urlParamKey) => - Object.entries(filters[tokenType].urlParam).find(([, filterObj]) => + Object.entries(filters[tokenType][URL_PARAM]).find(([, filterObj]) => Object.values(filterObj).includes(urlParamKey), )[0]; @@ -178,12 +186,36 @@ const getFilterType = (data, tokenType = '') => ? SPECIAL_FILTER : NORMAL_FILTER; -export const convertToParams = (filterTokens, paramType) => +const isIterationSpecialValue = (tokenType, value) => + tokenType === TOKEN_TYPE_ITERATION && SPECIAL_FILTER_VALUES.includes(value); + +export const convertToApiParams = (filterTokens) => { + const params = {}; + const not = {}; + + filterTokens + .filter((token) => token.type !== FILTERED_SEARCH_TERM) + .forEach((token) => { + const filterType = getFilterType(token.value.data, token.type); + const field = filters[token.type][API_PARAM][filterType]; + const obj = token.value.operator === OPERATOR_IS_NOT ? not : params; + const data = isIterationSpecialValue(token.type, token.value.data) + ? token.value.data.toUpperCase() + : token.value.data; + Object.assign(obj, { + [field]: obj[field] ? [obj[field], data].flat() : data, + }); + }); + + return Object.keys(not).length ? Object.assign(params, { not }) : params; +}; + +export const convertToUrlParams = (filterTokens) => filterTokens .filter((token) => token.type !== FILTERED_SEARCH_TERM) .reduce((acc, token) => { const filterType = getFilterType(token.value.data, token.type); - const param = filters[token.type][paramType][token.value.operator]?.[filterType]; + const param = filters[token.type][URL_PARAM][token.value.operator]?.[filterType]; return Object.assign(acc, { [param]: acc[param] ? [acc[param], token.value.data].flat() : token.value.data, }); diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue new file mode 100644 index 00000000000..c1f57be7f97 --- /dev/null +++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue @@ -0,0 +1,95 @@ +<script> +import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { PROJECTS_PER_PAGE } from '../constants'; +import getProjectsQuery from '../graphql/queries/get_projects.query.graphql'; + +export default { + PROJECTS_PER_PAGE, + projectQueryPageInfo: { + endCursor: '', + }, + components: { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, + }, + props: { + selectedProject: { + type: Object, + required: false, + default: null, + }, + }, + data() { + return { + initialProjectsLoading: true, + projectSearchQuery: '', + }; + }, + apollo: { + projects: { + query: getProjectsQuery, + variables() { + return { + search: this.projectSearchQuery, + first: this.$options.PROJECTS_PER_PAGE, + after: this.$options.projectQueryPageInfo.endCursor, + searchNamespaces: true, + sort: 'similarity', + }; + }, + update(data) { + return data?.projects?.nodes.filter((project) => !project.repository.empty) ?? []; + }, + result() { + this.initialProjectsLoading = false; + }, + error() { + this.onError({ message: __('Failed to load projects') }); + }, + }, + }, + computed: { + projectsLoading() { + return Boolean(this.$apollo.queries.projects.loading); + }, + projectDropdownText() { + return this.selectedProject?.nameWithNamespace || __('Select a project'); + }, + }, + methods: { + async onProjectSelect(project) { + this.$emit('change', project); + }, + onError({ message } = {}) { + this.$emit('error', { message }); + }, + isProjectSelected(project) { + return project.id === this.selectedProject?.id; + }, + }, +}; +</script> + +<template> + <gl-dropdown :text="projectDropdownText" :loading="initialProjectsLoading"> + <template #header> + <gl-search-box-by-type v-model.trim="projectSearchQuery" :debounce="250" /> + </template> + + <gl-loading-icon v-show="projectsLoading" /> + <template v-if="!projectsLoading"> + <gl-dropdown-item + v-for="project in projects" + :key="project.id" + is-check-item + :is-checked="isProjectSelected(project)" + @click="onProjectSelect(project)" + > + {{ project.nameWithNamespace }} + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue new file mode 100644 index 00000000000..0e2d8821f36 --- /dev/null +++ b/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue @@ -0,0 +1,134 @@ +<script> +import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { BRANCHES_PER_PAGE } from '../constants'; +import getProjectQuery from '../graphql/queries/get_project.query.graphql'; + +export default { + BRANCHES_PER_PAGE, + components: { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, + }, + props: { + selectedProject: { + type: Object, + required: false, + default: null, + }, + selectedBranchName: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + sourceBranchSearchQuery: '', + initialSourceBranchNamesLoading: false, + sourceBranchNamesLoading: false, + sourceBranchNames: [], + }; + }, + computed: { + hasSelectedProject() { + return Boolean(this.selectedProject); + }, + hasSelectedSourceBranch() { + return Boolean(this.selectedBranchName); + }, + branchDropdownText() { + return this.selectedBranchName || __('Select a branch'); + }, + }, + watch: { + selectedProject: { + immediate: true, + async handler(selectedProject) { + if (!selectedProject) return; + + this.initialSourceBranchNamesLoading = true; + await this.fetchSourceBranchNames({ projectPath: selectedProject.fullPath }); + this.initialSourceBranchNamesLoading = false; + }, + }, + }, + methods: { + onSourceBranchSelect(branchName) { + this.$emit('change', branchName); + }, + onSourceBranchSearchQuery(branchSearchQuery) { + this.branchSearchQuery = branchSearchQuery; + this.fetchSourceBranchNames({ + projectPath: this.selectedProject.fullPath, + searchPattern: this.branchSearchQuery, + }); + }, + onError({ message } = {}) { + this.$emit('error', { message }); + }, + async fetchSourceBranchNames({ projectPath, searchPattern } = {}) { + this.sourceBranchNamesLoading = true; + try { + const { data } = await this.$apollo.query({ + query: getProjectQuery, + variables: { + projectPath, + branchNamesLimit: this.$options.BRANCHES_PER_PAGE, + branchNamesOffset: 0, + branchNamesSearchPattern: searchPattern ? `*${searchPattern}*` : '*', + }, + }); + + const { branchNames, rootRef } = data?.project.repository || {}; + this.sourceBranchNames = branchNames || []; + + // Use root ref as the default selection + if (rootRef && !this.hasSelectedSourceBranch) { + this.onSourceBranchSelect(rootRef); + } + } catch (err) { + this.onError({ + message: __('Something went wrong while fetching source branches.'), + }); + } finally { + this.sourceBranchNamesLoading = false; + } + }, + }, +}; +</script> + +<template> + <gl-dropdown + :text="branchDropdownText" + :loading="initialSourceBranchNamesLoading" + :disabled="!hasSelectedProject" + :class="{ 'gl-font-monospace': hasSelectedSourceBranch }" + > + <template #header> + <gl-search-box-by-type + :debounce="250" + :value="sourceBranchSearchQuery" + @input="onSourceBranchSearchQuery" + /> + </template> + + <gl-loading-icon v-show="sourceBranchNamesLoading" /> + <template v-if="!sourceBranchNamesLoading"> + <gl-dropdown-item + v-for="branchName in sourceBranchNames" + v-show="!sourceBranchNamesLoading" + :key="branchName" + :is-checked="branchName === selectedBranchName" + is-check-item + class="gl-font-monospace" + @click="onSourceBranchSelect(branchName)" + > + {{ branchName }} + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/jira_connect/branches/constants.js b/app/assets/javascripts/jira_connect/branches/constants.js new file mode 100644 index 00000000000..987c8d356b4 --- /dev/null +++ b/app/assets/javascripts/jira_connect/branches/constants.js @@ -0,0 +1,2 @@ +export const BRANCHES_PER_PAGE = 20; +export const PROJECTS_PER_PAGE = 20; diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql new file mode 100644 index 00000000000..f3428e816d7 --- /dev/null +++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql @@ -0,0 +1,17 @@ +query getProject( + $projectPath: ID! + $branchNamesLimit: Int! + $branchNamesOffset: Int! + $branchNamesSearchPattern: String! +) { + project(fullPath: $projectPath) { + repository { + branchNames( + limit: $branchNamesLimit + offset: $branchNamesOffset + searchPattern: $branchNamesSearchPattern + ) + rootRef + } + } +} 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 new file mode 100644 index 00000000000..e768154e210 --- /dev/null +++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql @@ -0,0 +1,34 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getProjects( + $search: String! + $after: String = "" + $first: Int! + $searchNamespaces: Boolean = false + $sort: String + $membership: Boolean = true +) { + projects( + search: $search + after: $after + first: $first + membership: $membership + searchNamespaces: $searchNamespaces + sort: $sort + ) { + nodes { + id + name + nameWithNamespace + fullPath + avatarUrl + path + repository { + empty + } + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/components/groups_list.vue index d764f778a9d..55233bb6326 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list.vue +++ b/app/assets/javascripts/jira_connect/components/groups_list.vue @@ -89,6 +89,7 @@ export default { debounce="500" :placeholder="__('Search by name')" :is-loading="isLoadingMore" + :value="searchTerm" @input="onGroupSearch" /> 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 e7816f6d187..1b6e365fdb2 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -310,7 +310,7 @@ export default { > <gl-search-box-by-type v-model.trim="searchTerm" /> - <gl-loading-icon v-if="isFetching" /> + <gl-loading-icon v-if="isFetching" size="sm" /> <gl-dropdown-item v-for="user in users" @@ -328,7 +328,7 @@ export default { </template> </gl-table> - <gl-loading-icon v-if="isInitialLoadingState" /> + <gl-loading-icon v-if="isInitialLoadingState" size="sm" /> <gl-button v-if="hasMoreUsers" diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue index 35b16d73cc7..e31c13f40b0 100644 --- a/app/assets/javascripts/jobs/components/empty_state.vue +++ b/app/assets/javascripts/jobs/components/empty_state.vue @@ -35,11 +35,6 @@ export default { required: false, default: false, }, - variablesSettingsUrl: { - type: String, - required: false, - default: null, - }, action: { type: Object, required: false, @@ -75,11 +70,7 @@ export default { <p v-if="content" data-testid="job-empty-state-content">{{ content }}</p> </div> - <manual-variables-form - v-if="shouldRenderManualVariables" - :action="action" - :variables-settings-url="variablesSettingsUrl" - /> + <manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> <div class="text-content"> <div v-if="action && !shouldRenderManualVariables" class="text-center"> <gl-link diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index be95001a396..fa9ee56c049 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -50,11 +50,6 @@ export default { required: false, default: null, }, - variablesSettingsUrl: { - type: String, - required: false, - default: null, - }, deploymentHelpUrl: { type: String, required: false, @@ -315,7 +310,6 @@ export default { :action="emptyStateAction" :playable="job.playable" :scheduled="job.scheduled" - :variables-settings-url="variablesSettingsUrl" /> <!-- EO empty state --> diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue index 55cdfb691f4..c0d5fac0e8d 100644 --- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue +++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue @@ -1,4 +1,6 @@ <script> +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants'; import LogLine from './line.vue'; import LogLineHeader from './line_header.vue'; @@ -7,7 +9,9 @@ export default { components: { LogLine, LogLineHeader, + CollapsibleLogSection: () => import('./collapsible_section.vue'), }, + mixins: [glFeatureFlagsMixin()], props: { section: { type: Object, @@ -22,6 +26,9 @@ export default { badgeDuration() { return this.section.line && this.section.line.section_duration; }, + infinitelyCollapsibleSectionsFlag() { + return this.glFeatures?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF]; + }, }, methods: { handleOnClickCollapsibleLine(section) { @@ -40,12 +47,26 @@ export default { @toggleLine="handleOnClickCollapsibleLine(section)" /> <template v-if="!section.isClosed"> - <log-line - v-for="line in section.lines" - :key="line.offset" - :line="line" - :path="traceEndpoint" - /> + <template v-if="infinitelyCollapsibleSectionsFlag"> + <template v-for="line in section.lines"> + <collapsible-log-section + v-if="line.isHeader" + :key="line.line.offset" + :section="line" + :trace-endpoint="traceEndpoint" + @onClickCollapsibleLine="handleOnClickCollapsibleLine" + /> + <log-line v-else :key="line.offset" :line="line" :path="traceEndpoint" /> + </template> + </template> + <template v-else> + <log-line + v-for="line in section.lines" + :key="line.offset" + :line="line" + :path="traceEndpoint" + /> + </template> </template> </div> </template> diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue index 7ca9154d2fe..c8ceac2c7ff 100644 --- a/app/assets/javascripts/jobs/components/log/line_number.vue +++ b/app/assets/javascripts/jobs/components/log/line_number.vue @@ -1,4 +1,6 @@ <script> +import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants'; + export default { functional: true, props: { @@ -14,7 +16,9 @@ export default { render(h, { props }) { const { lineNumber, path } = props; - const parsedLineNumber = lineNumber + 1; + const parsedLineNumber = gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF] + ? lineNumber + : lineNumber + 1; const lineId = `L${parsedLineNumber}`; const lineHref = `${path}#${lineId}`; diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue index d45012d2023..269551ff9aa 100644 --- a/app/assets/javascripts/jobs/components/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue @@ -1,14 +1,16 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlLink, GlSprintf } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { mapActions } from 'vuex'; -import { s__, sprintf } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; export default { name: 'ManualVariablesForm', components: { GlButton, + GlLink, + GlSprintf, }, props: { action: { @@ -24,11 +26,6 @@ export default { ); }, }, - variablesSettingsUrl: { - type: String, - required: true, - default: '', - }, }, inputTypes: { key: 'key', @@ -37,6 +34,9 @@ export default { i18n: { keyPlaceholder: s__('CiVariables|Input variable key'), valuePlaceholder: s__('CiVariables|Input variable value'), + formHelpText: s__( + 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', + ), }, data() { return { @@ -47,17 +47,8 @@ export default { }; }, computed: { - helpText() { - return sprintf( - s__( - 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', - ), - { - linkStart: `<a href="${this.variablesSettingsUrl}">`, - linkEnd: '</a>', - }, - false, - ); + variableSettings() { + return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); }, }, watch: { @@ -188,8 +179,14 @@ export default { </div> </div> </div> - <div class="d-flex gl-mt-3 justify-content-center"> - <p class="text-muted" data-testid="form-help-text" v-html="helpText"></p> + <div class="gl-text-center gl-mt-3"> + <gl-sprintf :message="$options.i18n.formHelpText"> + <template #link="{ content }"> + <gl-link :href="variableSettings" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> </div> <div class="d-flex justify-content-center"> <gl-button diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index 98badb96ed7..a6eff743ce9 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -46,7 +46,7 @@ export default { return timeIntervalInWords(this.job.queued); }, runnerHelpUrl() { - return helpPagePath('ci/runners/README.html', { + return helpPagePath('ci/runners/index.html', { anchor: 'set-maximum-job-timeout-for-a-runner', }); }, diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js index 3040d4e2379..97f31eee57c 100644 --- a/app/assets/javascripts/jobs/constants.js +++ b/app/assets/javascripts/jobs/constants.js @@ -24,3 +24,5 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { }; export const SUCCESS_STATUS = 'SUCCESS'; + +export const INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF = 'infinitelyCollapsibleSections'; diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 260190f5043..1fb6a6f9850 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -15,7 +15,6 @@ export default () => { deploymentHelpUrl, codeQualityHelpUrl, runnerSettingsUrl, - variablesSettingsUrl, subscriptionsMoreMinutesUrl, endpoint, pagePath, @@ -41,7 +40,6 @@ export default () => { deploymentHelpUrl, codeQualityHelpUrl, runnerSettingsUrl, - variablesSettingsUrl, subscriptionsMoreMinutesUrl, endpoint, pagePath, diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index c89aeada69d..a8be5d8d039 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -1,5 +1,5 @@ import Visibility from 'visibilityjs'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { setFaviconOverlay, resetFavicon } from '~/lib/utils/favicon'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -99,7 +99,9 @@ export const receiveJobSuccess = ({ commit }, data = {}) => { }; export const receiveJobError = ({ commit }) => { commit(types.RECEIVE_JOB_ERROR); - flash(__('An error occurred while fetching the job.')); + createFlash({ + message: __('An error occurred while fetching the job.'), + }); resetFavicon(); }; @@ -197,11 +199,15 @@ export const stopPollingTrace = ({ state, commit }) => { export const receiveTraceSuccess = ({ commit }, log) => commit(types.RECEIVE_TRACE_SUCCESS, log); export const receiveTraceError = ({ dispatch }) => { dispatch('stopPollingTrace'); - flash(__('An error occurred while fetching the job log.')); + createFlash({ + message: __('An error occurred while fetching the job log.'), + }); }; export const receiveTraceUnauthorizedError = ({ dispatch }) => { dispatch('stopPollingTrace'); - flash(__('The current user is not authorized to access the job log.')); + createFlash({ + message: __('The current user is not authorized to access the job log.'), + }); }; /** * When the user clicks a collapsible line in the job @@ -240,7 +246,9 @@ export const receiveJobsForStageSuccess = ({ commit }, data) => commit(types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, data); export const receiveJobsForStageError = ({ commit }) => { commit(types.RECEIVE_JOBS_FOR_STAGE_ERROR); - flash(__('An error occurred while fetching the jobs.')); + createFlash({ + message: __('An error occurred while fetching the jobs.'), + }); }; export const triggerManualJob = ({ state }, variables) => { @@ -254,5 +262,9 @@ export const triggerManualJob = ({ state }, variables) => { .post(state.job.status.action.path, { job_variables_attributes: parsedVariables, }) - .catch(() => flash(__('An error occurred while triggering the job.'))); + .catch(() => + createFlash({ + message: __('An error occurred while triggering the job.'), + }), + ); }; diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js index 924b811d0d6..4045d8a0c16 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -1,6 +1,7 @@ import Vue from 'vue'; +import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../constants'; import * as types from './mutation_types'; -import { logLinesParser, updateIncrementalTrace } from './utils'; +import { logLinesParser, logLinesParserLegacy, updateIncrementalTrace } from './utils'; export default { [types.SET_JOB_ENDPOINT](state, endpoint) { @@ -20,12 +21,26 @@ export default { }, [types.RECEIVE_TRACE_SUCCESS](state, log = {}) { + const infinitelyCollapsibleSectionsFlag = + gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF]; if (log.state) { state.traceState = log.state; } if (log.append) { - state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace; + if (infinitelyCollapsibleSectionsFlag) { + if (log.lines) { + const parsedResult = logLinesParser( + log.lines, + state.auxiliaryPartialTraceHelpers, + state.trace, + ); + state.trace = parsedResult.parsedLines; + state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers; + } + } else { + state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace; + } state.traceSize += log.size; } else { @@ -33,7 +48,14 @@ export default { // the trace response will not have a defined // html or size. We keep the old value otherwise these // will be set to `null` - state.trace = log.lines ? logLinesParser(log.lines) : state.trace; + + if (infinitelyCollapsibleSectionsFlag) { + const parsedResult = logLinesParser(log.lines); + state.trace = parsedResult.parsedLines; + state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers; + } else { + state.trace = log.lines ? logLinesParserLegacy(log.lines) : state.trace; + } state.traceSize = log.size || state.traceSize; } diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js index 2fe945b2985..718324c8bad 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/jobs/store/state.js @@ -30,4 +30,7 @@ export default () => ({ selectedStage: '', stages: [], jobs: [], + + // to parse partial logs + auxiliaryPartialTraceHelpers: {}, }); diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index a0e0a0fb8bd..36391a4d433 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -104,7 +104,7 @@ export const getIncrementalLineNumber = (acc) => { * @param Array accumulator * @returns Array parsed log lines */ -export const logLinesParser = (lines = [], accumulator = []) => +export const logLinesParserLegacy = (lines = [], accumulator = []) => lines.reduce( (acc, line, index) => { const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index; @@ -131,6 +131,77 @@ export const logLinesParser = (lines = [], accumulator = []) => [...accumulator], ); +export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLines = []) => { + let currentLine = previousTraceState?.prevLineCount ?? 0; + let currentHeader = previousTraceState?.currentHeader; + let isPreviousLineHeader = previousTraceState?.isPreviousLineHeader ?? false; + const parsedLines = prevParsedLines.length > 0 ? prevParsedLines : []; + const sectionsQueue = previousTraceState?.sectionsQueue ?? []; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + // First run we can use the current index, later runs we have to retrieve the last number of lines + currentLine = previousTraceState?.prevLineCount ? currentLine + 1 : i + 1; + + if (line.section_header && !isPreviousLineHeader) { + // If there's no previous line header that means we're at the root of the log + + isPreviousLineHeader = true; + parsedLines.push(parseHeaderLine(line, currentLine)); + currentHeader = { index: parsedLines.length - 1 }; + } else if (line.section_header && isPreviousLineHeader) { + // If there's a current section, we can't push to the parsedLines array + sectionsQueue.push(currentHeader); + currentHeader = parseHeaderLine(line, currentLine); // Let's parse the incoming header line + } else if (line.section && !line.section_duration) { + // We're inside a collapsible section and want to parse a standard line + if (currentHeader?.index) { + // If the current section header is only an index, add the line as part of the lines + // array of the current collapsible section + parsedLines[currentHeader.index].lines.push(parseLine(line, currentLine)); + } else { + // Otherwise add it to the innermost collapsible section lines array + currentHeader.lines.push(parseLine(line, currentLine)); + } + } else if (line.section && line.section_duration) { + // NOTE: This marks the end of a section_header + const previousSection = sectionsQueue.pop(); + + // Add the duration to section header + // If at the root, just push the end to the current parsedLine, + // otherwise, push it to the previous sections queue + if (currentHeader?.index) { + parsedLines[currentHeader.index].line.section_duration = line.section_duration; + isPreviousLineHeader = false; + currentHeader = null; + } else { + currentHeader.line.section_duration = line.section_duration; + + if (previousSection && previousSection?.index) { + // Is the previous section on root? + parsedLines[previousSection.index].lines.push(currentHeader); + } else if (previousSection && !previousSection?.index) { + previousSection.lines.push(currentHeader); + } + + currentHeader = previousSection; + } + } else { + parsedLines.push(parseLine(line, currentLine)); + } + } + + return { + parsedLines, + auxiliaryPartialTraceHelpers: { + isPreviousLineHeader, + currentHeader, + sectionsQueue, + prevLineCount: lines.length, + }, + }; +}; + /** * Finds the repeated offset, removes the old one * @@ -177,5 +248,5 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => { export const updateIncrementalTrace = (newLog = [], oldParsed = []) => { const parsedLog = findOffsetAndRemove(newLog, oldParsed); - return logLinesParser(newLog, parsedLog); + return logLinesParserLegacy(newLog, parsedLog); }; diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js index 122f23a5bb5..1ccecf3eb53 100644 --- a/app/assets/javascripts/jobs/utils.js +++ b/app/assets/javascripts/jobs/utils.js @@ -3,10 +3,10 @@ * https?:\/\/ * * up until a disallowed character or whitespace - * [^"<>\\^`{|}\s]+ + * [^"<>()\\^`{|}\s]+ * * and a disallowed character or whitespace, including non-ending chars .,:;!? - * [^"<>\\^`{|}\s.,:;!?] + * [^"<>()\\^`{|}\s.,:;!?] */ -export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+[^"<>\\^`{|}\s.,:;!?])/g; +export const linkRegex = /(https?:\/\/[^"<>()\\^`{|}\s]+[^"<>()\\^`{|}\s.,:;!?])/g; export default { linkRegex }; diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index 2a020a66fd2..e0068edbb9b 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import Sortable from 'sortablejs'; import { dispose } from '~/tooltips'; -import { deprecatedCreateFlash as flash } from './flash'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; @@ -111,7 +111,11 @@ export default class LabelManager { } onPrioritySortUpdate() { - this.savePrioritySort().catch(() => flash(this.errorMessage)); + this.savePrioritySort().catch(() => + createFlash({ + message: this.errorMessage, + }), + ); } savePrioritySort() { @@ -123,7 +127,9 @@ export default class LabelManager { rollbackLabelPosition($label, originalAction) { const action = originalAction === 'remove' ? 'add' : 'remove'; this.toggleLabelPriority($label, action, false); - flash(this.errorMessage); + createFlash({ + message: this.errorMessage, + }); } getSortedLabelsIds() { diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index fb88e48c9a6..a62ab301227 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -5,11 +5,11 @@ import $ from 'jquery'; import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import IssuableBulkUpdateActions from '~/issuable_bulk_update_sidebar/issuable_bulk_update_actions'; import { isScopedLabel } from '~/lib/utils/common_utils'; import boardsStore from './boards/stores/boards_store'; import CreateLabelDropdown from './create_label'; -import { deprecatedCreateFlash as flash } from './flash'; -import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { sprintf, __ } from './locale'; @@ -148,7 +148,11 @@ export default class LabelsSelect { container: 'body', }); }) - .catch(() => flash(__('Error saving label update.'))); + .catch(() => + createFlash({ + message: __('Error saving label update.'), + }), + ); }; initDeprecatedJQueryDropdown($dropdown, { showMenuAbove, @@ -183,7 +187,11 @@ export default class LabelsSelect { $dropdown.data('deprecatedJQueryDropdown').positionMenuAbove(); } }) - .catch(() => flash(__('Error fetching labels.'))); + .catch(() => + createFlash({ + message: __('Error fetching labels.'), + }), + ); }, renderRow(label) { let colorEl; diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 76624c81ed5..4357918672d 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -7,6 +7,8 @@ const defaultConfig = { ADD_TAGS: ['use'], }; +const forbiddenDataAttrs = ['data-remote', 'data-url', 'data-type', 'data-method']; + // Only icons urls from `gon` are allowed const getAllowedIconUrls = (gon = window.gon) => [gon.sprite_file_icons, gon.sprite_icons].filter(Boolean); @@ -44,10 +46,19 @@ const sanitizeSvgIcon = (node) => { removeUnsafeHref(node, 'xlink:href'); }; +const sanitizeHTMLAttributes = (node) => { + forbiddenDataAttrs.forEach((attr) => { + if (node.hasAttribute(attr)) { + node.removeAttribute(attr); + } + }); +}; + addHook('afterSanitizeAttributes', (node) => { if (node.tagName.toLowerCase() === 'use') { sanitizeSvgIcon(node); } + sanitizeHTMLAttributes(node); }); export const sanitize = (val, config = defaultConfig) => dompurifySanitize(val, config); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index cec689a44ca..0804213cafa 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -2,12 +2,13 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { ApolloClient } from 'apollo-client'; import { ApolloLink } from 'apollo-link'; import { BatchHttpLink } from 'apollo-link-batch-http'; -import { createHttpLink } from 'apollo-link-http'; +import { HttpLink } from 'apollo-link-http'; import { createUploadLink } from 'apollo-upload-client'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import csrf from '~/lib/utils/csrf'; +import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; export const fetchPolicies = { @@ -18,6 +19,31 @@ export const fetchPolicies = { CACHE_ONLY: 'cache-only', }; +export const stripWhitespaceFromQuery = (url, path) => { + /* eslint-disable-next-line no-unused-vars */ + const [_, params] = url.split(path); + + if (!params) { + return url; + } + + const decoded = decodeURIComponent(params); + const paramsObj = queryToObject(decoded); + + if (!paramsObj.query) { + return url; + } + + const stripped = paramsObj.query + .split(/\s+|\n/) + .join(' ') + .trim(); + paramsObj.query = stripped; + + const reassembled = objectToQuery(paramsObj); + return `${path}?${reassembled}`; +}; + export default (resolvers = {}, config = {}) => { const { assumeImmutableResults, @@ -58,10 +84,31 @@ export default (resolvers = {}, config = {}) => { }); }); + /* + This custom fetcher intervention is to deal with an issue where we are using GET to access + eTag polling, but Apollo Client adds excessive whitespace, which causes the + request to fail on certain self-hosted stacks. When we can move + to subscriptions entirely or can land an upstream PR, this can be removed. + + Related links + Bug report: https://gitlab.com/gitlab-org/gitlab/-/issues/329895 + Moving to subscriptions: https://gitlab.com/gitlab-org/gitlab/-/issues/332485 + Apollo Client issue: https://github.com/apollographql/apollo-feature-requests/issues/182 + */ + + const fetchIntervention = (url, options) => { + return fetch(stripWhitespaceFromQuery(url, uri), options); + }; + + const requestLink = ApolloLink.split( + () => useGet, + new HttpLink({ ...httpOptions, fetch: fetchIntervention }), + new BatchHttpLink(httpOptions), + ); + const uploadsLink = ApolloLink.split( (operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest, createUploadLink(httpOptions), - useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions), ); const performanceBarLink = new ApolloLink((operation, forward) => { @@ -99,6 +146,7 @@ export default (resolvers = {}, config = {}) => { new StartupJSLink(), apolloCaptchaLink, uploadsLink, + requestLink, ]), ); diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js index 204c84b879e..0a26f78e253 100644 --- a/app/assets/javascripts/lib/utils/axios_utils.js +++ b/app/assets/javascripts/lib/utils/axios_utils.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor'; import setupAxiosStartupCalls from './axios_startup_calls'; import csrf from './csrf'; import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation'; @@ -41,6 +42,8 @@ axios.interceptors.response.use( (err) => suppressAjaxErrorsDuringNavigation(err, isUserNavigating), ); +registerCaptchaModalInterceptor(axios); + export default axios; /** diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 8666d325c1b..8a051041fbe 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -11,31 +11,10 @@ import { isObject } from './type_utility'; import { getLocationHash } from './url_utility'; export const getPagePath = (index = 0) => { - const page = $('body').attr('data-page') || ''; - + const { page = '' } = document?.body?.dataset; return page.split(':')[index]; }; -export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null; - -export const isInGroupsPage = () => getPagePath() === 'groups'; - -export const isInProjectPage = () => getPagePath() === 'projects'; - -export const getProjectSlug = () => { - if (isInProjectPage()) { - return $('body').data('project'); - } - return null; -}; - -export const getGroupSlug = () => { - if (isInProjectPage() || isInGroupsPage()) { - return $('body').data('group'); - } - return null; -}; - export const checkPageAndAction = (page, action) => { const pagePath = getPagePath(1); const actionPath = getPagePath(2); @@ -49,6 +28,8 @@ export const isInDesignPage = () => checkPageAndAction('issues', 'designs'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInEpicPage = () => checkPageAndAction('epics', 'show'); +export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null; + export const getCspNonceValue = () => { const metaTag = document.querySelector('meta[name=csp-nonce]'); return metaTag && metaTag.content; @@ -162,53 +143,6 @@ export const parseUrlPathname = (url) => { return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : `/${parsedUrl.pathname}`; }; -const splitPath = (path = '') => path.replace(/^\?/, '').split('&'); - -export const urlParamsToArray = (path = '') => - splitPath(path) - .filter((param) => param.length > 0) - .map((param) => { - const split = param.split('='); - return [decodeURI(split[0]), split[1]].join('='); - }); - -export const getUrlParamsArray = () => urlParamsToArray(window.location.search); - -/** - * Accepts encoding string which includes query params being - * sent to URL. - * - * @param {string} path Query param string - * - * @returns {object} Query params object containing key-value pairs - * with both key and values decoded into plain string. - */ -export const urlParamsToObject = (path = '') => - splitPath(path).reduce((dataParam, filterParam) => { - if (filterParam === '') { - return dataParam; - } - - const data = dataParam; - let [key, value] = filterParam.split('='); - key = /%\w+/g.test(key) ? decodeURIComponent(key) : key; - const isArray = key.includes('[]'); - key = key.replace('[]', ''); - value = decodeURIComponent(value.replace(/\+/g, ' ')); - - if (isArray) { - if (!data[key]) { - data[key] = []; - } - - data[key].push(value); - } else { - data[key] = value; - } - - return data; - }, {}); - export const isMetaKey = (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; // Identify following special clicks @@ -301,21 +235,6 @@ export const debounceByAnimationFrame = (fn) => { }; }; -/** - this will take in the `name` of the param you want to parse in the url - if the name does not exist this function will return `null` - otherwise it will return the value of the param key provided -*/ -export const getParameterByName = (name, urlToParse) => { - const url = urlToParse || window.location.href; - const parsedName = name.replace(/[[\]]/g, '\\$&'); - const regex = new RegExp(`[?&]${parsedName}(=([^&#]*)|&|#|$)`); - const results = regex.exec(url); - if (!results) return null; - if (!results[2]) return ''; - return decodeURIComponent(results[2].replace(/\+/g, ' ')); -}; - const handleSelectedRange = (range, restrictToNode) => { // Make sure this range is within the restricting container if (restrictToNode && !range.intersectsNode(restrictToNode)) return null; @@ -390,8 +309,8 @@ export const insertText = (target, text) => { }; /** - this will take in the headers from an API response and normalize them - this way we don't run into production issues when nginx gives us lowercased header keys + this will take in the headers from an API response and normalize them + this way we don't run into production issues when nginx gives us lowercased header keys */ export const normalizeHeaders = (headers) => { const upperCaseHeaders = {}; @@ -418,39 +337,6 @@ export const parseIntPagination = (paginationInformation) => ({ previousPage: parseInt(paginationInformation['X-PREV-PAGE'], 10), }); -/** - * Given a string of query parameters creates an object. - * - * @example - * `scope=all&page=2` -> { scope: 'all', page: '2'} - * `scope=all` -> { scope: 'all' } - * ``-> {} - * @param {String} query - * @returns {Object} - */ -export const parseQueryStringIntoObject = (query = '') => { - if (query === '') return {}; - - return query.split('&').reduce((acc, element) => { - const val = element.split('='); - Object.assign(acc, { - [val[0]]: decodeURIComponent(val[1]), - }); - return acc; - }, {}); -}; - -/** - * Converts object with key-value pairs - * into query-param string - * - * @param {Object} params - */ -export const objectToQueryString = (params = {}) => - Object.keys(params) - .map((param) => `${param}=${params[param]}`) - .join('&'); - export const buildUrlWithCurrentLocation = (param) => { if (param) return `${window.location.pathname}${param}`; @@ -789,7 +675,18 @@ export const searchBy = (query = '', searchSpace = {}) => { * @param {Object} label * @returns Boolean */ -export const isScopedLabel = ({ title = '' }) => title.indexOf('::') !== -1; +export const isScopedLabel = ({ title = '' } = {}) => title.indexOf('::') !== -1; + +/** + * Returns the base value of the scoped label + * + * Expected Label to be an Object with `title` as a key: + * { title: 'LabelTitle', ...otherProperties }; + * + * @param {Object} label + * @returns String + */ +export const scopedLabelKey = ({ title = '' }) => isScopedLabel({ title }) && title.split('::')[0]; // Methods to set and get Cookie export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 }); @@ -821,3 +718,5 @@ export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag]; * @returns {Array[String]} Converted array */ export const convertArrayToCamelCase = (array) => array.map((i) => convertToCamelCase(i)); + +export const isLoggedIn = () => Boolean(window.gon?.current_user_id); diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 2d4765f54b9..e41de72ded4 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,4 +1,5 @@ export const BYTES_IN_KIB = 1024; +export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250; export const HIDDEN_CLASS = 'hidden'; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js index 512b1f079a1..d68682ebed1 100644 --- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js @@ -1,10 +1,7 @@ -import $ from 'jquery'; import * as timeago from 'timeago.js'; -import { languageCode, s__ } from '../../../locale'; +import { languageCode, s__, createDateTimeFormat } from '../../../locale'; import { formatDate } from './date_format_utility'; -window.timeago = timeago; - /** * Timeago uses underscores instead of dashes to separate language from country code. * @@ -76,24 +73,44 @@ const memoizedLocale = () => { timeago.register(timeagoLanguageCode, memoizedLocale()); timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); -export const getTimeago = () => timeago; +let memoizedFormatter = null; + +function setupAbsoluteFormatter() { + if (memoizedFormatter === null) { + const formatter = createDateTimeFormat({ + dateStyle: 'medium', + timeStyle: 'short', + }); + + memoizedFormatter = { + format(date) { + return formatter.format(date instanceof Date ? date : new Date(date)); + }, + }; + } + return memoizedFormatter; +} + +export const getTimeago = () => + window.gon?.time_display_relative === false ? setupAbsoluteFormatter() : timeago; /** * For the given elements, sets a tooltip with a formatted date. - * @param {JQuery} $timeagoEls - * @param {Boolean} setTimeago + * @param {Array<Node>|NodeList} elements + * @param {Boolean} updateTooltip */ -export const localTimeAgo = ($timeagoEls, setTimeago = true) => { - $timeagoEls.each((i, el) => { - $(el).text(timeago.format($(el).attr('datetime'), timeagoLanguageCode)); +export const localTimeAgo = (elements, updateTooltip = true) => { + const { format } = getTimeago(); + elements.forEach((el) => { + el.innerText = format(el.dateTime, timeagoLanguageCode); }); - if (!setTimeago) { + if (!updateTooltip) { return; } function addTimeAgoTooltip() { - $timeagoEls.each((i, el) => { + elements.forEach((el) => { // Recreate with custom template el.setAttribute('title', formatDate(el.dateTime)); }); @@ -116,9 +133,3 @@ export const timeFor = (time, expiredLabel) => { } return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim(); }; - -window.gl = window.gl || {}; -window.gl.utils = { - ...(window.gl.utils || {}), - localTimeAgo, -}; diff --git a/app/assets/javascripts/lib/utils/finite_state_machine.js b/app/assets/javascripts/lib/utils/finite_state_machine.js new file mode 100644 index 00000000000..99eeb7cb947 --- /dev/null +++ b/app/assets/javascripts/lib/utils/finite_state_machine.js @@ -0,0 +1,101 @@ +/** + * @module finite_state_machine + */ + +/** + * The states to be used with state machine definitions + * @typedef {Object} FiniteStateMachineStates + * @property {!Object} ANY_KEY - Any key that maps to a known state + * @property {!Object} ANY_KEY.on - A dictionary of transition events for the ANY_KEY state that map to a different state + * @property {!String} ANY_KEY.on.ANY_EVENT - The resulting state that the machine should end at + */ + +/** + * An object whose minimum definition defined here can be used to guard UI state transitions + * @typedef {Object} StatelessFiniteStateMachineDefinition + * @property {FiniteStateMachineStates} states + */ + +/** + * An object whose minimum definition defined here can be used to create a live finite state machine + * @typedef {Object} LiveFiniteStateMachineDefinition + * @property {String} initial - The initial state for this machine + * @property {FiniteStateMachineStates} states + */ + +/** + * An object that allows interacting with a stateful, live finite state machine + * @typedef {Object} LiveStateMachine + * @property {String} value - The current state of this machine + * @property {Object} states - The states from when the machine definition was constructed + * @property {Function} is - {@link module:finite_state_machine~is LiveStateMachine.is} + * @property {Function} send - {@link module:finite_state_machine~send LiveStatemachine.send} + */ + +// This is not user-facing functionality +/* eslint-disable @gitlab/require-i18n-strings */ + +function hasKeys(object, keys) { + return keys.every((key) => Object.keys(object).includes(key)); +} + +/** + * Get an updated state given a machine definition, a starting state, and a transition event + * @param {StatelessFiniteStateMachineDefinition} definition + * @param {String} current - The current known state + * @param {String} event - A transition event + * @returns {String} A state value + */ +export function transition(definition, current, event) { + return definition?.states?.[current]?.on[event] || current; +} + +function startMachine({ states, initial } = {}) { + let current = initial; + + return { + /** + * A convenience function to test arbitrary input against the machine's current state + * @param {String} testState - The value to test against the machine's current state + */ + is(testState) { + return current === testState; + }, + /** + * A function to transition the live state machine using an arbitrary event + * @param {String} event - The event to send to the machine + * @returns {String} A string representing the current state. Note this may not have changed if the current state + transition event combination are not valid. + */ + send(event) { + current = transition({ states }, current, event); + + return current; + }, + get value() { + return current; + }, + set value(forcedState) { + current = forcedState; + }, + states, + }; +} + +/** + * Create a live state machine + * @param {LiveFiniteStateMachineDefinition} definition + * @returns {LiveStateMachine} A live state machine + */ +export function machine(definition) { + if (!hasKeys(definition, ['initial', 'states'])) { + throw new Error( + 'A state machine must have an initial state (`.initial`) and a dictionary of possible states (`.states`)', + ); + } else if (!hasKeys(definition.states, [definition.initial])) { + throw new Error( + `Cannot initialize the state machine to state '${definition.initial}'. Is that one of the machine's defined states?`, + ); + } else { + return startMachine(definition); + } +} diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index eaf396a7a59..5ee00464a8b 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -421,3 +421,61 @@ export const isValidSha1Hash = (str) => { export function insertFinalNewline(content, endOfLine = '\n') { return content.slice(-endOfLine.length) !== endOfLine ? `${content}${endOfLine}` : content; } + +export const markdownConfig = { + // allowedTags from GitLab's inline HTML guidelines + // https://docs.gitlab.com/ee/user/markdown.html#inline-html + ALLOWED_TAGS: [ + 'a', + 'abbr', + 'b', + 'blockquote', + 'br', + 'code', + 'dd', + 'del', + 'div', + 'dl', + 'dt', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'i', + 'img', + 'ins', + 'kbd', + 'li', + 'ol', + 'p', + 'pre', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'span', + 'strike', + 'strong', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'tr', + 'tt', + 'ul', + 'var', + ], + ALLOWED_ATTR: ['class', 'style', 'href', 'src'], + ALLOW_DATA_ATTR: false, +}; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index d68b41b7f7a..7922ff22a70 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -209,11 +209,7 @@ export function removeParams(params, url = window.location.href, skipEncoding = return `${root}${writableQuery}${writableFragment}`; } -export function getLocationHash(url = window.location.href) { - const hashIndex = url.indexOf('#'); - - return hashIndex === -1 ? null : url.substring(hashIndex + 1); -} +export const getLocationHash = (hash = window.location.hash) => hash.split('#')[1]; /** * Returns a boolean indicating whether the URL hash contains the given string value @@ -409,6 +405,55 @@ export function getWebSocketUrl(path) { return `${getWebSocketProtocol()}//${joinPaths(window.location.host, path)}`; } +const splitPath = (path = '') => path.replace(/^\?/, '').split('&'); + +export const urlParamsToArray = (path = '') => + splitPath(path) + .filter((param) => param.length > 0) + .map((param) => { + const split = param.split('='); + return [decodeURI(split[0]), split[1]].join('='); + }); + +export const getUrlParamsArray = () => urlParamsToArray(window.location.search); + +/** + * Accepts encoding string which includes query params being + * sent to URL. + * + * @param {string} path Query param string + * + * @returns {object} Query params object containing key-value pairs + * with both key and values decoded into plain string. + * + * @deprecated Please use `queryToObject(query, { gatherArrays: true });` instead. See https://gitlab.com/gitlab-org/gitlab/-/issues/328845 + */ +export const urlParamsToObject = (path = '') => + splitPath(path).reduce((dataParam, filterParam) => { + if (filterParam === '') { + return dataParam; + } + + const data = dataParam; + let [key, value] = filterParam.split('='); + key = /%\w+/g.test(key) ? decodeURIComponent(key) : key; + const isArray = key.includes('[]'); + key = key.replace('[]', ''); + value = decodeURIComponent(value.replace(/\+/g, ' ')); + + if (isArray) { + if (!data[key]) { + data[key] = []; + } + + data[key].push(value); + } else { + data[key] = value; + } + + return data; + }, {}); + /** * Convert search query into an object * @@ -450,17 +495,30 @@ export function queryToObject(query, { gatherArrays = false, legacySpacesDecode } /** + * This function accepts the `name` of the param to parse in the url + * if the name does not exist this function will return `null` + * otherwise it will return the value of the param key provided + * + * @param {String} name + * @param {String?} urlToParse + * @returns value of the parameter as string + */ +export const getParameterByName = (name, query = window.location.search) => { + return queryToObject(query)[name] || null; +}; + +/** * Convert search query object back into a search query * - * @param {Object} obj that needs to be converted + * @param {Object?} params that needs to be converted * @returns {String} * * ex: {one: 1, two: 2} into "one=1&two=2" * */ -export function objectToQuery(obj) { - return Object.keys(obj) - .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(obj[k])}`) +export function objectToQuery(params = {}) { + return Object.keys(params) + .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) .join('&'); } diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index aaa8ee40966..a1f59aa1b54 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -117,8 +117,8 @@ LineHighlighter.prototype.clearHighlight = function () { // // Returns an Array LineHighlighter.prototype.hashToRange = function (hash) { - // ?L(\d+)(?:-(\d+))?$/) - const matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/); + // ?L(\d+)(?:-L?(\d+))?$/) + const matches = hash.match(/^#?L(\d+)(?:-L?(\d+))?$/); if (matches && matches.length) { const first = parseInt(matches[1], 10); const last = matches[2] ? parseInt(matches[2], 10) : null; diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index 10518fa73d9..ad01da2eb17 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -2,7 +2,10 @@ import Jed from 'jed'; import ensureSingleLine from './ensure_single_line'; import sprintf from './sprintf'; -const languageCode = () => document.querySelector('html').getAttribute('lang') || 'en'; +const GITLAB_FALLBACK_LANGUAGE = 'en'; + +const languageCode = () => + document.querySelector('html').getAttribute('lang') || GITLAB_FALLBACK_LANGUAGE; const locale = new Jed(window.translations || {}); delete window.translations; @@ -51,12 +54,52 @@ const pgettext = (keyOrContext, key) => { }; /** + * Filters navigator languages by the set GitLab language. + * + * This allows us to decide better what a user wants as a locale, for using with the Intl browser APIs. + * If they have set their GitLab to a language, it will check whether `navigator.languages` contains matching ones. + * This function always adds `en` as a fallback in order to have date renders if all fails before it. + * + * - Example one: GitLab language is `en` and browser languages are: + * `['en-GB', 'en-US']`. This function returns `['en-GB', 'en-US', 'en']` as + * the preferred locales, the Intl APIs would try to format first as British English, + * if that isn't available US or any English. + * - Example two: GitLab language is `en` and browser languages are: + * `['de-DE', 'de']`. This function returns `['en']`, so the Intl APIs would prefer English + * formatting in order to not have German dates mixed with English GitLab UI texts. + * If the user wants for example British English formatting (24h, etc), + * they could set their browser languages to `['de-DE', 'de', 'en-GB']`. + * - Example three: GitLab language is `de` and browser languages are `['en-US', 'en']`. + * This function returns `['de', 'en']`, aligning German dates with the chosen translation of GitLab. + * + * @returns {string[]} + */ +export const getPreferredLocales = () => { + const gitlabLanguage = languageCode(); + // The GitLab language may or may not contain a country code, + // so we create the short version as well, e.g. de-AT => de + const lang = gitlabLanguage.substring(0, 2); + const locales = navigator.languages.filter((l) => l.startsWith(lang)); + if (!locales.includes(gitlabLanguage)) { + locales.push(gitlabLanguage); + } + if (!locales.includes(lang)) { + locales.push(lang); + } + if (!locales.includes(GITLAB_FALLBACK_LANGUAGE)) { + locales.push(GITLAB_FALLBACK_LANGUAGE); + } + return locales; +}; + +/** Creates an instance of Intl.DateTimeFormat for the current locale. @param formatOptions for available options, please see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat @returns {Intl.DateTimeFormat} */ -const createDateTimeFormat = (formatOptions) => Intl.DateTimeFormat(languageCode(), formatOptions); +const createDateTimeFormat = (formatOptions) => + Intl.DateTimeFormat(getPreferredLocales(), formatOptions); /** * Formats a number as a string using `toLocaleString`. diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index 39041aa1447..3db9fa01629 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -29,9 +29,6 @@ export default { LogAdvancedFilters, LogControlButtons, }, - filters: { - formatDate, - }, props: { environmentName: { type: String, @@ -114,6 +111,7 @@ export default { const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target; this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight; }, 200), + formatDate, }, }; </script> @@ -229,8 +227,8 @@ export default { <div ref="logFooter" class="py-2 px-3 text-white bg-secondary-900"> <gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')"> - <template #start>{{ timeRange.current.start | formatDate }}</template> - <template #end>{{ timeRange.current.end | formatDate }}</template> + <template #start>{{ formatDate(timeRange.current.start) }}</template> + <template #end>{{ formatDate(timeRange.current.end) }}</template> </gl-sprintf> <gl-sprintf v-if="!logs.isComplete" diff --git a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue b/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue index f8ce704942b..4e672c1d121 100644 --- a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue +++ b/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue @@ -20,7 +20,7 @@ export default { <gl-filtered-search-token :config="config" v-bind="{ ...$attrs }" v-on="$listeners"> <template #suggestions> <div class="m-1"> - <gl-loading-icon v-if="config.loading" /> + <gl-loading-icon v-if="config.loading" size="sm" /> <div v-else class="py-1 px-2 text-muted"> {{ config.noOptionsText }} </div> diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 2309f7a420f..5c14000a2aa 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -31,7 +31,7 @@ import initFrequentItemDropdowns from './frequent_items'; import initBreadcrumbs from './breadcrumb'; import initPersistentUserCallouts from './persistent_user_callouts'; import { initUserTracking, initDefaultTrackers } from './tracking'; -import initUsagePingConsent from './usage_ping_consent'; +import initServicePingConsent from './service_ping_consent'; import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; @@ -46,6 +46,9 @@ applyGitLabUIConfig(); window.jQuery = jQuery; window.$ = jQuery; +// ensure that window.gl is set up +window.gl = window.gl || {}; + // inject test utilities if necessary if (process.env.NODE_ENV !== 'production' && gon?.test_env) { import(/* webpackMode: "eager" */ './test_utils/'); @@ -86,7 +89,7 @@ function deferredInitialisation() { initBreadcrumbs(); initTodoToggle(); initLogoAnimation(); - initUsagePingConsent(); + initServicePingConsent(); initUserPopovers(); initBroadcastNotifications(); initFrequentItemDropdowns(); @@ -183,7 +186,7 @@ document.addEventListener('DOMContentLoaded', () => { return true; }); - localTimeAgo($('abbr.timeago, .js-timeago'), true); + localTimeAgo(document.querySelectorAll('abbr.timeago, .js-timeago'), true); /** * This disables form buttons while a form is submitting diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue index 1e9f79927ea..0c20f935d50 100644 --- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -38,6 +38,7 @@ export default { usersName: user.name, source: source.fullName, }, + false, ); } diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue index a08518584f3..0ec39f58930 100644 --- a/app/assets/javascripts/members/components/app.vue +++ b/app/assets/javascripts/members/components/app.vue @@ -19,6 +19,11 @@ export default { type: String, required: true, }, + tabQueryParamValue: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapState({ @@ -55,6 +60,6 @@ export default { errorMessage }}</gl-alert> <filter-sort-container /> - <members-table /> + <members-table :tab-query-param-value="tabQueryParamValue" /> </div> </template> 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 cc0533391df..33d86dec767 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 @@ -1,10 +1,13 @@ <script> import { GlFilteredSearchToken } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { getParameterByName, urlParamsToObject } from '~/lib/utils/common_utils'; -import { setUrlParams } from '~/lib/utils/url_utility'; +import { getParameterByName, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; -import { SEARCH_TOKEN_TYPE, SORT_PARAM } from '~/members/constants'; +import { + SEARCH_TOKEN_TYPE, + SORT_QUERY_PARAM_NAME, + ACTIVE_TAB_QUERY_PARAM_NAME, +} from '~/members/constants'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; @@ -64,7 +67,7 @@ export default { }, }, created() { - const query = urlParamsToObject(window.location.search); + const query = queryToObject(window.location.search); const tokens = this.tokens .filter((token) => query[token.type]) @@ -116,10 +119,15 @@ export default { return accumulator; }, {}); - const sortParam = getParameterByName(SORT_PARAM); + const sortParamValue = getParameterByName(SORT_QUERY_PARAM_NAME); + const activeTabParamValue = getParameterByName(ACTIVE_TAB_QUERY_PARAM_NAME); window.location.href = setUrlParams( - { ...params, ...(sortParam && { sort: sortParam }) }, + { + ...params, + ...(sortParamValue && { [SORT_QUERY_PARAM_NAME]: sortParamValue }), + ...(activeTabParamValue && { [ACTIVE_TAB_QUERY_PARAM_NAME]: activeTabParamValue }), + }, window.location.href, true, ); diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue index 37b9135126d..7c21e33d892 100644 --- a/app/assets/javascripts/members/components/members_tabs.vue +++ b/app/assets/javascripts/members/components/members_tabs.vue @@ -1,16 +1,18 @@ <script> import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { urlParamsToObject } from '~/lib/utils/common_utils'; +// eslint-disable-next-line import/no-deprecated +import { urlParamsToObject } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import { MEMBER_TYPES } from '../constants'; +import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES, ACTIVE_TAB_QUERY_PARAM_NAME } from '../constants'; import MembersApp from './app.vue'; const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0; export default { name: 'MembersTabs', - tabs: [ + ACTIVE_TAB_QUERY_PARAM_NAME, + TABS: [ { namespace: MEMBER_TYPES.user, title: __('Members'), @@ -19,19 +21,21 @@ export default { namespace: MEMBER_TYPES.group, title: __('Groups'), attrs: { 'data-qa-selector': 'groups_list_tab' }, + queryParamValue: TAB_QUERY_PARAM_VALUES.group, }, { namespace: MEMBER_TYPES.invite, title: __('Invited'), canManageMembersPermissionsRequired: true, + queryParamValue: TAB_QUERY_PARAM_VALUES.invite, }, { namespace: MEMBER_TYPES.accessRequest, title: __('Access requests'), canManageMembersPermissionsRequired: true, + queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest, }, ], - urlParams: [], components: { MembersApp, GlTabs, GlTab, GlBadge }, inject: ['canManageMembers'], data() { @@ -55,32 +59,22 @@ export default { }, }), urlParams() { + // eslint-disable-next-line import/no-deprecated return Object.keys(urlParamsToObject(window.location.search)); }, activeTabIndexCalculatedFromUrlParams() { - return this.$options.tabs.findIndex(({ namespace }) => { + return this.$options.TABS.findIndex(({ namespace }) => { return this.getTabUrlParams(namespace).some((urlParam) => this.urlParams.includes(urlParam), ); }); }, }, - created() { - if (this.activeTabIndexCalculatedFromUrlParams === -1) { - return; - } - - this.selectedTabIndex = this.activeTabIndexCalculatedFromUrlParams; - }, methods: { getTabUrlParams(namespace) { const state = this.$store.state[namespace]; const urlParams = []; - if (state?.pagination?.paramName) { - urlParams.push(state.pagination.paramName); - } - if (state?.filteredSearchBar?.searchParam) { urlParams.push(state.filteredSearchBar.searchParam); } @@ -110,14 +104,23 @@ export default { </script> <template> - <gl-tabs v-model="selectedTabIndex"> - <template v-for="(tab, index) in $options.tabs"> - <gl-tab v-if="showTab(tab, index)" :key="tab.namespace" :title-link-attributes="tab.attrs"> - <template slot="title"> + <gl-tabs + v-model="selectedTabIndex" + sync-active-tab-with-query-params + :query-param-name="$options.ACTIVE_TAB_QUERY_PARAM_NAME" + > + <template v-for="(tab, index) in $options.TABS"> + <gl-tab + v-if="showTab(tab, index)" + :key="tab.namespace" + :title-link-attributes="tab.attrs" + :query-param-value="tab.queryParamValue" + > + <template #title> <span>{{ tab.title }}</span> <gl-badge size="sm" class="gl-tab-counter-badge">{{ getTabCount(tab) }}</gl-badge> </template> - <members-app :namespace="tab.namespace" /> + <members-app :namespace="tab.namespace" :tab-query-param-value="tab.queryParamValue" /> </gl-tab> </template> </gl-tabs> diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 09ef98ec411..b9c80edbc49 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -5,7 +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 { FIELDS } from '../../constants'; +import { FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME } from '../../constants'; import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; import CreatedAt from './created_at.vue'; import ExpirationDatepicker from './expiration_datepicker.vue'; @@ -34,6 +34,13 @@ export default { import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), }, inject: ['namespace', 'currentUserId'], + props: { + tabQueryParamValue: { + type: String, + required: false, + default: '', + }, + }, computed: { ...mapState({ members(state) { @@ -112,7 +119,15 @@ export default { paginationLinkGenerator(page) { const { params = {}, paramName } = this.pagination; - return mergeUrlParams({ ...params, [paramName]: page }, window.location.href); + return mergeUrlParams( + { + ...params, + [ACTIVE_TAB_QUERY_PARAM_NAME]: + this.tabQueryParamValue !== '' ? this.tabQueryParamValue : null, + [paramName]: page, + }, + window.location.href, + ); }, }, }; diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index f68a8814fee..6f465245d20 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -89,6 +89,12 @@ export const MEMBER_TYPES = { accessRequest: 'accessRequest', }; +export const TAB_QUERY_PARAM_VALUES = { + group: 'groups', + invite: 'invited', + accessRequest: 'access_requests', +}; + export const DAYS_TO_EXPIRE_SOON = 7; export const LEAVE_MODAL_ID = 'member-leave-modal'; @@ -97,7 +103,8 @@ export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id'; export const SEARCH_TOKEN_TYPE = 'filtered-search-term'; -export const SORT_PARAM = 'sort'; +export const SORT_QUERY_PARAM_NAME = 'sort'; +export const ACTIVE_TAB_QUERY_PARAM_NAME = 'tab'; export const MEMBER_ACCESS_LEVEL_PROPERTY_NAME = 'access_level'; diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js index be549b40885..05f086c8f4f 100644 --- a/app/assets/javascripts/members/utils.js +++ b/app/assets/javascripts/members/utils.js @@ -1,6 +1,6 @@ import { isUndefined } from 'lodash'; -import { getParameterByName, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { setUrlParams } from '~/lib/utils/url_utility'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { getParameterByName, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { FIELDS, diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue index 04e493712ec..7168efa28ad 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue @@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { debounce } from 'lodash'; import { mapActions } from 'vuex'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import { INTERACTIVE_RESOLVE_MODE } from '../constants'; @@ -50,13 +50,13 @@ export default { methods: { ...mapActions(['setFileResolveMode', 'setPromptConfirmationState', 'updateFile']), loadEditor() { - const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite'); + const EditorPromise = import(/* webpackChunkName: 'SourceEditor' */ '~/editor/source_editor'); const DataPromise = axios.get(this.file.content_path); Promise.all([EditorPromise, DataPromise]) .then( ([ - { default: EditorLite }, + { default: SourceEditor }, { data: { content, new_path: path }, }, @@ -66,7 +66,7 @@ export default { this.originalContent = content; this.fileLoaded = true; - this.editor = new EditorLite().createInstance({ + this.editor = new SourceEditor().createInstance({ el: contentEl, blobPath: path, blobContent: content, @@ -75,7 +75,9 @@ export default { }, ) .catch(() => { - flash(__('An error occurred while loading the file')); + createFlash({ + message: __('An error occurred while loading the file'), + }); }); }, saveDiffResolution() { diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue index 3e31e2e93ae..5fcc778a714 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue @@ -120,7 +120,7 @@ export default { > <div class="js-file-title file-title file-title-flex-parent cursor-default"> <div class="file-header-content" data-testid="file-name"> - <file-icon :file-name="file.filePath" :size="18" css-classes="gl-mr-2" /> + <file-icon :file-name="file.filePath" :size="16" css-classes="gl-mr-2" /> <strong class="file-title-name">{{ file.filePath }}</strong> </div> <div class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start"> diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index feaf8b0d996..0ddb2c2334c 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -148,14 +148,6 @@ MergeRequest.prototype.initCommitMessageListeners = function () { }); }; -MergeRequest.setStatusBoxToMerged = function () { - $('.detail-page-header .status-box') - .removeClass('status-box-open') - .addClass('status-box-mr-merged') - .find('span') - .text(__('Merged')); -}; - MergeRequest.decreaseCounter = function (by = 1) { const $el = $('.js-merge-counter'); const count = Math.max(parseInt($el.text().replace(/[^\d]/, ''), 10) - by, 0); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index d5db9f43d09..1d1c0a23fab 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -3,12 +3,10 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import Cookies from 'js-cookie'; import Vue from 'vue'; -import CommitPipelinesTable from '~/commit/pipelines/pipelines_table.vue'; import createEventHub from '~/helpers/event_hub_factory'; -import initAddContextCommitsTriggers from './add_context_commits_modal'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import Diff from './diff'; -import { deprecatedCreateFlash as flash } from './flash'; +import createFlash from './flash'; import initChangesDropdown from './init_changes_dropdown'; import axios from './lib/utils/axios_utils'; import { @@ -335,17 +333,22 @@ export default class MergeRequestTabs { axios .get(`${source}.json`) .then(({ data }) => { - document.querySelector('div#commits').innerHTML = data.html; - localTimeAgo($('.js-timeago', 'div#commits')); + const commitsDiv = document.querySelector('div#commits'); + commitsDiv.innerHTML = data.html; + localTimeAgo(commitsDiv.querySelectorAll('.js-timeago')); this.commitsLoaded = true; this.scrollToContainerElement('#commits'); this.toggleLoading(false); - initAddContextCommitsTriggers(); + + return import('./add_context_commits_modal'); }) + .then((m) => m.default()) .catch(() => { this.toggleLoading(false); - flash(__('An error occurred while fetching this tab.')); + createFlash({ + message: __('An error occurred while fetching this tab.'), + }); }); } @@ -354,13 +357,16 @@ export default class MergeRequestTabs { const { mrWidgetData } = gl; this.commitPipelinesTable = new Vue({ + components: { + CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'), + }, provide: { artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint, artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder, targetProjectFullPath: mrWidgetData?.target_project_full_path || '', }, render(createElement) { - return createElement(CommitPipelinesTable, { + return createElement('commit-pipelines-table', { props: { endpoint: pipelineTableViewEl.dataset.endpoint, emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, @@ -402,7 +408,7 @@ export default class MergeRequestTabs { initChangesDropdown(this.stickyTop); - localTimeAgo($('.js-timeago', 'div#diffs')); + localTimeAgo(document.querySelectorAll('#diffs .js-timeago')); syntaxHighlight($('#diffs .js-syntax-highlight')); if (this.isDiffAction(this.currentAction)) { @@ -446,7 +452,9 @@ export default class MergeRequestTabs { }) .catch(() => { this.toggleLoading(false); - flash(__('An error occurred while fetching this tab.')); + createFlash({ + message: __('An error occurred while fetching this tab.'), + }); }); } diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index 280613bda49..b4e53c1fab6 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { deprecatedCreateFlash as flash } from './flash'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; @@ -39,7 +39,11 @@ export default class Milestone { $(tabElId).html(data.html); $target.addClass('is-loaded'); }) - .catch(() => flash(__('Error loading milestone tab'))); + .catch(() => + createFlash({ + message: __('Error loading milestone tab'), + }), + ); } } } diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index b992eaff779..0d9a2eef01a 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -7,6 +7,7 @@ import { template, escape } from 'lodash'; import Api from '~/api'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { __, sprintf } from '~/locale'; +import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; import boardsStore, { boardStoreIssueSet, boardStoreIssueDelete, @@ -93,21 +94,7 @@ export default class MilestoneSelect { // Public API includes `title` instead of `name`. name: m.title, })) - .sort((mA, mB) => { - const dueDateA = mA.due_date ? parsePikadayDate(mA.due_date) : null; - const dueDateB = mB.due_date ? parsePikadayDate(mB.due_date) : null; - - // Move all expired milestones to the bottom. - if (mA.expired) return 1; - if (mB.expired) return -1; - - // Move milestones without due dates just above expired milestones. - if (!dueDateA) return 1; - if (!dueDateB) return -1; - - // Sort by due date in ascending order. - return dueDateA - dueDateB; - }), + .sort(sortMilestonesByDueDate), ) .then((data) => { const extraOptions = []; diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue index 1db2d10db20..e8499015210 100644 --- a/app/assets/javascripts/milestones/components/milestone_combobox.vue +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -171,7 +171,7 @@ export default { <template> <gl-dropdown v-bind="$attrs" class="milestone-combobox" @shown="focusSearchBox"> - <template slot="button-content"> + <template #button-content> <span data-testid="milestone-combobox-button-content" class="gl-flex-grow-1 text-muted">{{ selectedMilestonesLabel }}</span> @@ -202,7 +202,7 @@ export default { <gl-dropdown-divider /> <template v-if="isLoading"> - <gl-loading-icon /> + <gl-loading-icon size="sm" /> <gl-dropdown-divider /> </template> <template v-else-if="showNoResults"> diff --git a/app/assets/javascripts/milestones/milestone_utils.js b/app/assets/javascripts/milestones/milestone_utils.js new file mode 100644 index 00000000000..3ae5e676138 --- /dev/null +++ b/app/assets/javascripts/milestones/milestone_utils.js @@ -0,0 +1,32 @@ +import { parsePikadayDate } from '~/lib/utils/datetime_utility'; + +/** + * This method is to be used with `Array.prototype.sort` function + * where array contains milestones with `due_date`/`dueDate` and/or + * `expired` properties. + * This method sorts given milestone params based on their expiration + * status by putting expired milestones at the bottom and upcoming + * milestones at the top of the list. + * + * @param {object} milestoneA + * @param {object} milestoneB + */ +export function sortMilestonesByDueDate(milestoneA, milestoneB) { + const rawDueDateA = milestoneA.due_date || milestoneA.dueDate; + const rawDueDateB = milestoneB.due_date || milestoneB.dueDate; + const dueDateA = rawDueDateA ? parsePikadayDate(rawDueDateA) : null; + const dueDateB = rawDueDateB ? parsePikadayDate(rawDueDateB) : null; + const expiredA = milestoneA.expired || Date.now() > dueDateA?.getTime(); + const expiredB = milestoneB.expired || Date.now() > dueDateB?.getTime(); + + // Move all expired milestones to the bottom. + if (expiredA) return 1; + if (expiredB) return -1; + + // Move milestones without due dates just above expired milestones. + if (!dueDateA) return 1; + if (!dueDateB) return -1; + + // Sort by due date in ascending order. + return dueDateA - dueDateB; +} diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js index a26c8f85958..e59da18fb77 100644 --- a/app/assets/javascripts/mirrors/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { debounce } from 'lodash'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import { hide } from '~/tooltips'; @@ -111,7 +111,11 @@ export default class MirrorRepos { return axios .put(this.mirrorEndpoint, payload) .then(() => this.removeRow($target)) - .catch(() => Flash(__('Failed to remove mirror.'))); + .catch(() => + createFlash({ + message: __('Failed to remove mirror.'), + }), + ); } /* eslint-disable class-methods-use-this */ diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index 15ded478405..5138c450feb 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { escape } from 'lodash'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -115,7 +115,9 @@ export default class SSHMirror { const failureMessage = response.data ? response.data.message : __('An error occurred while detecting host keys'); - Flash(failureMessage); + createFlash({ + message: failureMessage, + }); $btnLoadSpinner.addClass('hidden'); this.$btnDetectHostKeys.enable(); diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue index c18c13f2574..e5d7e2ea2eb 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget.vue @@ -227,7 +227,7 @@ export default { <template> <div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden"> - <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" /> + <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" size="sm" /> <span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{ errorMessage }}</span> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 99008d047af..12f5e7efc96 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -402,22 +402,20 @@ export default { @created="onChartCreated" @updated="onChartUpdated" > - <template v-if="tooltip.type === 'deployments'"> - <template slot="tooltip-title"> + <template #tooltip-title> + <template v-if="tooltip.type === 'deployments'"> {{ __('Deployed') }} </template> - <div slot="tooltip-content" class="d-flex align-items-center"> + <div v-else class="text-nowrap"> + {{ tooltip.title }} + </div> + </template> + <template #tooltip-content> + <div v-if="tooltip.type === 'deployments'" class="d-flex align-items-center"> <gl-icon name="commit" class="mr-2" /> <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> </div> - </template> - <template v-else> - <template slot="tooltip-title"> - <div class="text-nowrap"> - {{ tooltip.title }} - </div> - </template> - <template slot="tooltip-content" :tooltip="tooltip"> + <template v-else> <div v-for="(content, key) in tooltip.content" :key="key" diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue index 94cfb562ce3..8e5a0b5cda2 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue @@ -138,10 +138,10 @@ export default { </script> <template> - <!-- + <!-- This component should be replaced with a variant developed as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936 - The variant will create a dropdown with an icon, no text and no caret + The variant will create a dropdown with an icon, no text and no caret --> <gl-dropdown v-gl-tooltip @@ -177,20 +177,22 @@ export default { @formValidation="setFormValidity" /> </form> - <div slot="modal-footer"> - <gl-button @click="hideAddMetricModal"> - {{ __('Cancel') }} - </gl-button> - <gl-button - v-track-event="getAddMetricTrackingOptions()" - data-testid="add-metric-modal-submit-button" - :disabled="!customMetricsFormIsValid" - variant="success" - @click="submitCustomMetricsForm" - > - {{ __('Save changes') }} - </gl-button> - </div> + <template #modal-footer> + <div> + <gl-button @click="hideAddMetricModal"> + {{ __('Cancel') }} + </gl-button> + <gl-button + v-track-event="getAddMetricTrackingOptions()" + data-testid="add-metric-modal-submit-button" + :disabled="!customMetricsFormIsValid" + variant="success" + @click="submitCustomMetricsForm" + > + {{ __('Save changes') }} + </gl-button> + </div> + </template> </gl-modal> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index 05b5b760f0a..f53f78a3f13 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -197,7 +197,7 @@ export default { <gl-dropdown-section-header>{{ __('Environment') }}</gl-dropdown-section-header> <gl-search-box-by-type @input="debouncedEnvironmentsSearch" /> - <gl-loading-icon v-if="environmentsLoading" :inline="true" /> + <gl-loading-icon v-if="environmentsLoading" size="sm" :inline="true" /> <div v-else class="flex-fill overflow-auto"> <gl-dropdown-item v-for="environment in filteredEnvironments" diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 202d18ac721..b786d015f3b 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -389,7 +389,7 @@ export default { /> <div class="flex-grow-1"></div> <div v-if="graphDataIsLoading" class="mx-1 mt-1"> - <gl-loading-icon /> + <gl-loading-icon size="sm" /> </div> <div v-if="isContextualMenuShown" diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue index 49d7e3a48a7..fd07a41ec37 100644 --- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue @@ -88,7 +88,7 @@ export default { @change="formChange" /> <template #modal-ok> - <gl-loading-icon v-if="loading" inline color="light" /> + <gl-loading-icon v-if="loading" size="sm" inline color="light" /> {{ okButtonText }} </template> </gl-modal> diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index ecb8ef4a0d0..5b73fb4e10d 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -61,7 +61,7 @@ export default { <div v-if="showPanels" ref="graph-group" class="card prometheus-panel"> <div class="card-header d-flex align-items-center"> <h4 class="flex-grow-1">{{ name }}</h4> - <gl-loading-icon v-if="isLoading" name="loading" /> + <gl-loading-icon v-if="isLoading" size="sm" name="loading" /> <a data-testid="group-toggle-button" :aria-label="__('Toggle collapse')" diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js index 094590804c1..e00c2abfbef 100644 --- a/app/assets/javascripts/namespaces/leave_by_url.js +++ b/app/assets/javascripts/namespaces/leave_by_url.js @@ -1,6 +1,6 @@ -import { deprecatedCreateFlash as Flash } from '~/flash'; -import { getParameterByName } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; import { initRails } from '~/lib/utils/rails_ujs'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; const PARAMETER_NAME = 'leave'; @@ -18,8 +18,10 @@ export default function leaveByUrl(namespaceType) { if (leaveLink) { leaveLink.click(); } else { - Flash( - sprintf(__('You do not have permission to leave this %{namespaceType}.'), { namespaceType }), - ); + createFlash({ + message: sprintf(__('You do not have permission to leave this %{namespaceType}.'), { + namespaceType, + }), + }); } } diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue index cac8fecb6b1..97856eaf256 100644 --- a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue +++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue @@ -72,7 +72,7 @@ export default { <template> <div class="gl-display-flex gl-align-items-stretch"> <div - class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10 gl-py-3 gl-px-5" + class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10 gl-p-3" :class="menuClass" data-testid="menu-sidebar" > @@ -81,7 +81,7 @@ export default { <keep-alive-slots v-show="activeView" :slot-key="activeView" - class="gl-w-grid-size-40 gl-overflow-hidden gl-py-3 gl-px-5" + class="gl-w-grid-size-40 gl-overflow-hidden gl-p-3" data-testid="menu-subview" data-qa-selector="menu_subview_container" > diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue index 08b2fbf2ed1..07c6fa7773a 100644 --- a/app/assets/javascripts/nav/components/top_nav_menu_item.vue +++ b/app/assets/javascripts/nav/components/top_nav_menu_item.vue @@ -42,7 +42,7 @@ export default { v-on="$listeners" > <span class="gl-display-flex"> - <gl-icon v-if="menuItem.icon" :name="menuItem.icon" :class="{ 'gl-mr-2!': !iconOnly }" /> + <gl-icon v-if="menuItem.icon" :name="menuItem.icon" :class="{ 'gl-mr-3!': !iconOnly }" /> <template v-if="!iconOnly"> {{ menuItem.title }} <gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" /> diff --git a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue index 442af512350..b8555df53df 100644 --- a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue +++ b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue @@ -1,7 +1,7 @@ <script> import TopNavMenuItem from './top_nav_menu_item.vue'; -const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100'; +const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-50'; export default { components: { diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index a7fcce02ab3..0f4cec67ce8 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -3,7 +3,7 @@ import katex from 'katex'; import marked from 'marked'; import { sanitize } from '~/lib/dompurify'; -import { hasContent } from '~/lib/utils/text_utility'; +import { hasContent, markdownConfig } from '~/lib/utils/text_utility'; import Prompt from './prompt.vue'; const renderer = new marked.Renderer(); @@ -140,63 +140,7 @@ export default { markdown() { renderer.attachments = this.cell.attachments; - return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), { - // allowedTags from GitLab's inline HTML guidelines - // https://docs.gitlab.com/ee/user/markdown.html#inline-html - ALLOWED_TAGS: [ - 'a', - 'abbr', - 'b', - 'blockquote', - 'br', - 'code', - 'dd', - 'del', - 'div', - 'dl', - 'dt', - 'em', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'hr', - 'i', - 'img', - 'ins', - 'kbd', - 'li', - 'ol', - 'p', - 'pre', - 'q', - 'rp', - 'rt', - 'ruby', - 's', - 'samp', - 'span', - 'strike', - 'strong', - 'sub', - 'summary', - 'sup', - 'table', - 'tbody', - 'td', - 'tfoot', - 'th', - 'thead', - 'tr', - 'tt', - 'ul', - 'var', - ], - ALLOWED_ATTR: ['class', 'style', 'href', 'src'], - ALLOW_DATA_ATTR: false, - }); + return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), markdownConfig); }, }, }; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index c324c846f47..ef51587734d 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -358,7 +358,7 @@ export default class Notes { setupNewNote($note) { // Update datetime format on the recent note - localTimeAgo($note.find('.js-timeago'), false); + localTimeAgo($note.find('.js-timeago').get(), false); this.collapseLongCommitList(); this.taskList.init(); @@ -511,7 +511,7 @@ export default class Notes { Notes.animateAppendNote(noteEntity.html, discussionContainer); } - localTimeAgo($('.js-timeago'), false); + localTimeAgo(document.querySelectorAll('.js-timeago'), false); Notes.checkMergeRequestStatus(); return this.updateNotesCount(1); } @@ -628,7 +628,6 @@ export default class Notes { message: __( 'Your comment could not be submitted! Please check your network connection and try again.', ), - type: 'alert', parent: formParentTimeline.get(0), }); } diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 7213658bdf2..9504ed78778 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -14,7 +14,7 @@ import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import Autosave from '~/autosave'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { statusBoxState } from '~/issuable/components/status_box.vue'; import httpStatusCodes from '~/lib/utils/http_status'; import { @@ -293,7 +293,11 @@ export default { toggleState() .then(() => statusBoxState.updateStatus && statusBoxState.updateStatus()) .then(refreshUserMergeRequestCounts) - .catch(() => Flash(constants.toggleStateErrorMessage[this.noteableType][this.openState])); + .catch(() => + createFlash({ + message: constants.toggleStateErrorMessage[this.noteableType][this.openState], + }), + ); }, discard(shouldClear = true) { // `blur` is needed to clear slash commands autocomplete cache if event fired. diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index dfe2763d8bd..0892276ff3b 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -130,15 +130,18 @@ export default { @handleDeleteNote="$emit('deleteNote')" @startReplying="$emit('startReplying')" > - <note-edited-text - v-if="discussion.resolved" - slot="discussion-resolved-text" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" - /> - <slot slot="avatar-badge" name="avatar-badge"></slot> + <template #discussion-resolved-text> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" + /> + </template> + <template #avatar-badge> + <slot name="avatar-badge"></slot> + </template> </component> <discussion-notes-replies-wrapper :is-diff-discussion="discussion.diff_discussion"> <toggle-replies-widget @@ -175,7 +178,9 @@ export default { :discussion-resolve-path="discussion.resolve_path" @handleDeleteNote="$emit('deleteNote')" > - <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> + <template #avatar-badge> + <slot v-if="index === 0" name="avatar-badge"></slot> + </template> </component> <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot> </template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 0f72b4f2dba..44d0c741d5a 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -3,7 +3,7 @@ import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui import { mapActions, mapGetters } from 'vuex'; import Api from '~/api'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import eventHub from '~/sidebar/event_hub'; @@ -234,7 +234,11 @@ export default { assignee_ids: assignees.map((assignee) => assignee.id), }) .then(() => this.handleAssigneeUpdate(assignees)) - .catch(() => flash(__('Something went wrong while updating assignees'))); + .catch(() => + createFlash({ + message: __('Something went wrong while updating assignees'), + }), + ); } }, setAwardEmoji(awardName) { diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 9eb7b928ea4..835750cc137 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,8 +1,8 @@ <script> import { mapActions, mapGetters } from 'vuex'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import AwardsList from '~/vue_shared/components/awards_list.vue'; -import { deprecatedCreateFlash as Flash } from '../../flash'; export default { components: { @@ -48,7 +48,11 @@ export default { awardName, }; - this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.'))); + this.toggleAwardRequest(data).catch(() => + createFlash({ + message: __('Something went wrong on our end.'), + }), + ); }, }, }; diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 6932af61c69..1a4a6c137a6 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -216,6 +216,7 @@ export default { <gl-loading-icon v-if="showSpinner" ref="spinner" + size="sm" class="editing-spinner" :label="__('Comment is being updated')" /> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 1af9e4be373..b99579fb9a7 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -2,11 +2,12 @@ import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { mapActions, mapGetters } from 'vuex'; import DraftNote from '~/batch_comments/components/draft_note.vue'; +import createFlash from '~/flash'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; +import { isLoggedIn } from '~/lib/utils/common_utils'; 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 { deprecatedCreateFlash as Flash } from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; @@ -85,7 +86,7 @@ export default { return this.getUserData; }, isLoggedIn() { - return Boolean(gon.current_user_id); + return isLoggedIn(); }, autosaveKey() { return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); @@ -220,7 +221,10 @@ export default { const msg = __( 'Your comment could not be submitted! Please check your network connection and try again.', ); - Flash(msg, 'alert', this.$el); + createFlash({ + message: msg, + parent: this.$el, + }); this.$refs.noteForm.note = noteText; callback(err); }); @@ -262,7 +266,9 @@ export default { @startReplying="showReplyForm" @deleteNote="deleteNoteHandler" > - <slot slot="avatar-badge" name="avatar-badge"></slot> + <template #avatar-badge> + <slot name="avatar-badge"></slot> + </template> <template #footer="{ showReplies }"> <draft-note v-if="showDraft(discussion.reply_id)" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 0feb77be653..5ea431224ce 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -4,15 +4,16 @@ import $ from 'jquery'; import { escape, isEmpty } from 'lodash'; import { mapGetters, mapActions } from 'vuex'; import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; +import createFlash from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import { deprecatedCreateFlash as Flash } from '../../flash'; 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'; +import { renderMarkdown } from '../utils'; import { getStartLineNumber, getEndLineNumber, @@ -247,7 +248,9 @@ export default { this.isDeleting = false; }) .catch(() => { - Flash(__('Something went wrong while deleting your note. Please try again.')); + createFlash({ + message: __('Something went wrong while deleting your note. Please try again.'), + }); this.isDeleting = false; }); } @@ -298,7 +301,7 @@ export default { this.isRequesting = true; this.oldContent = this.note.note_html; // eslint-disable-next-line vue/no-mutating-props - this.note.note_html = escape(noteText); + this.note.note_html = renderMarkdown(noteText); this.updateNote(data) .then(() => { @@ -316,7 +319,10 @@ export default { this.setSelectedCommentPositionHover(); this.$nextTick(() => { const msg = __('Something went wrong while editing your comment. Please try again.'); - Flash(msg, 'alert', this.$el); + createFlash({ + message: msg, + parent: this.$el, + }); this.recoverNoteContent(noteText); callback(); }); @@ -387,7 +393,9 @@ export default { :img-alt="author.name" :img-size="40" > - <slot slot="avatar-badge" name="avatar-badge"></slot> + <template #avatar-badge> + <slot name="avatar-badge"></slot> + </template> </user-avatar-link> </div> <div class="timeline-content"> @@ -398,7 +406,9 @@ export default { :note-id="note.id" :is-confidential="note.confidential" > - <slot slot="note-header-info" name="note-header-info"></slot> + <template #note-header-info> + <slot name="note-header-info"></slot> + </template> <span v-if="commit" v-safe-html="actionText"></span> <span v-else-if="note.created_at" class="d-none d-sm-inline">·</span> </note-header> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 433f75a752d..29c60b96d8a 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,13 +1,13 @@ <script> import { mapGetters, mapActions } from 'vuex'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; +import createFlash from '~/flash'; import { __ } from '~/locale'; 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 { deprecatedCreateFlash as Flash } from '../../flash'; 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'; @@ -66,6 +66,7 @@ export default { data() { return { currentFilter: null, + renderSkeleton: !this.shouldShow, }; }, computed: { @@ -93,7 +94,7 @@ export default { return this.noteableData.noteableType; }, allDiscussions() { - if (this.isLoading) { + if (this.renderSkeleton || this.isLoading) { const prerenderedNotesCount = parseInt(this.notesData.prerenderedNotesCount, 10) || 0; return new Array(prerenderedNotesCount).fill({ @@ -122,6 +123,10 @@ export default { if (!this.isNotesFetched) { this.fetchNotes(); } + + setTimeout(() => { + this.renderSkeleton = !this.shouldShow; + }); }, discussionTabCounterText(val) { if (this.discussionsCount) { @@ -216,7 +221,9 @@ export default { .catch(() => { this.setLoadingState(false); this.setNotesFetchedState(true); - Flash(__('Something went wrong while fetching comments. Please try again.')); + createFlash({ + message: __('Something went wrong while fetching comments. Please try again.'), + }); }); }, initPolling() { diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index 27ed8e203b0..9783def1b46 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; export default { @@ -46,7 +46,10 @@ export default { this.isResolving = false; const msg = __('Something went wrong while resolving this discussion. Please try again.'); - Flash(msg, 'alert', this.$el); + createFlash({ + message: msg, + parent: this.$el, + }); }); }, }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 086e9122c60..6a4a3263e4a 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import Visibility from 'visibilityjs'; import Vue from 'vue'; import Api from '~/api'; +import createFlash from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; @@ -9,7 +10,6 @@ import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_co 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 { deprecatedCreateFlash as Flash } from '../../flash'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import Poll from '../../lib/utils/poll'; import { create } from '../../lib/utils/recurrence'; @@ -312,25 +312,23 @@ export const saveNote = ({ commit, dispatch }, noteData) => { $('.notes-form .flash-container').hide(); // hide previous flash notification commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders - if (replyId) { - if (hasQuickActions) { - placeholderText = utils.stripQuickActions(placeholderText); - } + if (hasQuickActions) { + placeholderText = utils.stripQuickActions(placeholderText); + } - if (placeholderText.length) { - commit(types.SHOW_PLACEHOLDER_NOTE, { - noteBody: placeholderText, - replyId, - }); - } + if (placeholderText.length) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + noteBody: placeholderText, + replyId, + }); + } - if (hasQuickActions) { - commit(types.SHOW_PLACEHOLDER_NOTE, { - isSystemNote: true, - noteBody: utils.getQuickActionText(note), - replyId, - }); - } + if (hasQuickActions) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + isSystemNote: true, + noteBody: utils.getQuickActionText(note), + replyId, + }); } const processQuickActions = (res) => { @@ -354,7 +352,11 @@ export const saveNote = ({ commit, dispatch }, noteData) => { $('.js-gfm-input').trigger('clear-commands-cache.atwho'); - Flash(message || __('Commands applied'), 'notice', noteData.flashContainer); + createFlash({ + message: message || __('Commands applied'), + type: 'notice', + parent: noteData.flashContainer, + }); } return res; @@ -375,11 +377,10 @@ export const saveNote = ({ commit, dispatch }, noteData) => { awardsHandler.scrollToAwards(); }) .catch(() => { - Flash( - __('Something went wrong while adding your award. Please try again.'), - 'alert', - noteData.flashContainer, - ); + createFlash({ + message: __('Something went wrong while adding your award. Please try again.'), + parent: noteData.flashContainer, + }); }) .then(() => res); }; @@ -397,9 +398,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }; const removePlaceholder = (res) => { - if (replyId) { - commit(types.REMOVE_PLACEHOLDER_NOTES); - } + commit(types.REMOVE_PLACEHOLDER_NOTES); return res; }; @@ -417,7 +416,10 @@ export const saveNote = ({ commit, dispatch }, noteData) => { const errorMsg = sprintf(__('Your comment could not be submitted because %{error}'), { error: base[0].toLowerCase(), }); - Flash(errorMsg, 'alert', noteData.flashContainer); + createFlash({ + message: errorMsg, + parent: noteData.flashContainer, + }); return { ...data, hasFlash: true }; } } @@ -480,7 +482,9 @@ export const poll = ({ commit, state, getters, dispatch }) => { }); notePollOccurrenceTracking.handle(2, () => { // On the second failure in a row, show the alert and try one more time (hoping to succeed and clear the error) - flashContainer = Flash(__('Something went wrong while fetching latest comments.')); + flashContainer = createFlash({ + message: __('Something went wrong while fetching latest comments.'), + }); setTimeout(() => eTagPoll.restart(), NOTES_POLLING_INTERVAL); }); @@ -570,7 +574,9 @@ export const filterDiscussion = ({ dispatch }, { path, filter, persistFilter }) .catch(() => { dispatch('setLoadingState', false); dispatch('setNotesFetchedState', true); - Flash(__('Something went wrong while fetching comments. Please try again.')); + createFlash({ + message: __('Something went wrong while fetching comments. Please try again.'), + }); }); }; @@ -613,7 +619,10 @@ export const submitSuggestion = ( const flashMessage = errorMessage || defaultMessage; - Flash(__(flashMessage), 'alert', flashContainer); + createFlash({ + message: __(flashMessage), + parent: flashContainer, + }); }) .finally(() => { commit(types.SET_RESOLVING_DISCUSSION, false); @@ -646,7 +655,10 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai const flashMessage = errorMessage || defaultMessage; - Flash(__(flashMessage), 'alert', flashContainer); + createFlash({ + message: __(flashMessage), + parent: flashContainer, + }); }) .finally(() => { commit(types.SET_APPLYING_BATCH_STATE, false); @@ -685,7 +697,9 @@ export const fetchDescriptionVersion = ({ dispatch }, { endpoint, startingVersio }) .catch((error) => { dispatch('receiveDescriptionVersionError', error); - Flash(__('Something went wrong while fetching description changes. Please try again.')); + createFlash({ + message: __('Something went wrong while fetching description changes. Please try again.'), + }); }); }; @@ -717,7 +731,9 @@ export const softDeleteDescriptionVersion = ( }) .catch((error) => { dispatch('receiveDeleteDescriptionVersionError', error); - Flash(__('Something went wrong while deleting description changes. Please try again.')); + createFlash({ + message: __('Something went wrong while deleting description changes. Please try again.'), + }); // Throw an error here because a component like SystemNote - // needs to know if the request failed to reset its internal state. diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index b04b1d28ffa..956221d69ae 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -279,7 +279,7 @@ export const getDiscussion = (state) => (discussionId) => export const commentsDisabled = (state) => state.commentsDisabled; export const suggestionsCount = (state, getters) => - Object.values(getters.notesById).filter((n) => n.suggestions.length).length; + Object.values(getters.notesById).filter((n) => n.suggestions?.length).length; export const hasDrafts = (state, getters, rootState, rootGetters) => Boolean(rootGetters['batchComments/hasDrafts']); diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js index 7966a884eab..ec18a570960 100644 --- a/app/assets/javascripts/notes/utils.js +++ b/app/assets/javascripts/notes/utils.js @@ -1,4 +1,7 @@ /* eslint-disable @gitlab/require-i18n-strings */ +import marked from 'marked'; +import { sanitize } from '~/lib/dompurify'; +import { markdownConfig } from '~/lib/utils/text_utility'; /** * Tracks snowplow event when User toggles timeline view @@ -10,3 +13,7 @@ export const trackToggleTimelineView = (enabled) => ({ label: 'Status', property: enabled, }); + +export const renderMarkdown = (rawMarkdown) => { + return sanitize(marked(rawMarkdown), markdownConfig); +}; diff --git a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue index 2b5cff35fc8..182948c39f4 100644 --- a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue +++ b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue @@ -73,7 +73,7 @@ export default { this.events = this.buildEvents(events); } catch (error) { - this.$toast.show(this.$options.i18n.loadNotificationLevelErrorMessage, { type: 'error' }); + this.$toast.show(this.$options.i18n.loadNotificationLevelErrorMessage); } finally { this.isLoading = false; } @@ -93,7 +93,7 @@ export default { this.events = this.buildEvents(events); } catch (error) { - this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' }); + this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage); } }, }, @@ -132,7 +132,7 @@ export default { @change="updateEvent($event, event)" > <strong>{{ event.name }}</strong - ><gl-loading-icon v-if="event.loading" :inline="true" class="gl-ml-2" /> + ><gl-loading-icon v-if="event.loading" size="sm" :inline="true" class="gl-ml-2" /> </gl-form-checkbox> </gl-form-group> </template> diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown.vue b/app/assets/javascripts/notifications/components/notifications_dropdown.vue index 4963b9386c1..69eb2115bf4 100644 --- a/app/assets/javascripts/notifications/components/notifications_dropdown.vue +++ b/app/assets/javascripts/notifications/components/notifications_dropdown.vue @@ -104,7 +104,7 @@ export default { this.selectedNotificationLevel = level; this.openNotificationsModal(); } catch (error) { - this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' }); + this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage); } finally { this.isLoading = false; } diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js index 969904bc6d0..529eb7d207b 100644 --- a/app/assets/javascripts/operation_settings/store/actions.js +++ b/app/assets/javascripts/operation_settings/store/actions.js @@ -37,6 +37,5 @@ export const receiveSaveChangesError = (_, error) => { createFlash({ message: `${__('There was an error saving your changes.')} ${message}`, - type: 'alert', }); }; diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages/details/components/app.vue index 55ffe10a608..59da32e6666 100644 --- a/app/assets/javascripts/packages/details/components/app.vue +++ b/app/assets/javascripts/packages/details/components/app.vue @@ -11,8 +11,8 @@ import { GlSprintf, } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import { objectToQueryString } from '~/lib/utils/common_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { objectToQuery } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; import PackageListRow from '../../shared/components/package_list_row.vue'; @@ -114,7 +114,7 @@ export default { !this.groupListUrl || document.referrer.includes(this.projectName) ? this.projectListUrl : this.groupListUrl; // to avoid security issue url are supplied from backend - const modalQuery = objectToQueryString({ [SHOW_DELETE_SUCCESS_ALERT]: true }); + const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true }); window.location.replace(`${returnTo}?${modalQuery}`); }, handleFileDelete(file) { diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index d871c2e4d24..2c6fd94024e 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -86,6 +86,14 @@ export const PACKAGE_TYPES = [ title: s__('PackageRegistry|RubyGems'), type: PackageType.RUBYGEMS, }, + { + title: s__('PackageRegistry|Debian'), + type: PackageType.DEBIAN, + }, + { + title: s__('PackageRegistry|Helm'), + type: PackageType.HELM, + }, ]; export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry'); diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js index 0ef6a3d0d12..b4cdca34d92 100644 --- a/app/assets/javascripts/packages/shared/constants.js +++ b/app/assets/javascripts/packages/shared/constants.js @@ -9,6 +9,8 @@ export const PackageType = { COMPOSER: 'composer', RUBYGEMS: 'rubygems', GENERIC: 'generic', + DEBIAN: 'debian', + HELM: 'helm', }; // we want this separated from the main dictionary to avoid it being pulled in the search of package diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js index bd35a47ca4d..7e86e5b2991 100644 --- a/app/assets/javascripts/packages/shared/utils.js +++ b/app/assets/javascripts/packages/shared/utils.js @@ -25,6 +25,10 @@ export const getPackageTypeLabel = (packageType) => { return s__('PackageRegistry|Composer'); case PackageType.GENERIC: return s__('PackageRegistry|Generic'); + case PackageType.DEBIAN: + return s__('PackageRegistry|Debian'); + case PackageType.HELM: + return s__('PackageRegistry|Helm'); default: return null; } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue new file mode 100644 index 00000000000..e2a2fb1430d --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue @@ -0,0 +1,301 @@ +<script> +/* + * The commented part of this component needs to be re-enabled in the refactor process, + * See here for more info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64939 + */ +import { + GlBadge, + GlButton, + GlModal, + GlModalDirective, + GlTooltipDirective, + GlEmptyState, + GlTab, + GlTabs, + GlSprintf, +} from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { objectToQuery } from '~/lib/utils/url_utility'; +import { s__, __ } from '~/locale'; +// import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue'; +// import DependencyRow from '~/packages/details/components/dependency_row.vue'; +// import InstallationCommands from '~/packages/details/components/installation_commands.vue'; +// import PackageFiles from '~/packages/details/components/package_files.vue'; +// import PackageHistory from '~/packages/details/components/package_history.vue'; +// import PackageListRow from '~/packages/shared/components/package_list_row.vue'; +import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import { + PackageType, + TrackingActions, + SHOW_DELETE_SUCCESS_ALERT, +} from '~/packages/shared/constants'; +import { packageTypeToTrackCategory } from '~/packages/shared/utils'; +import Tracking from '~/tracking'; + +export default { + name: 'PackagesApp', + components: { + GlBadge, + GlButton, + GlEmptyState, + GlModal, + GlTab, + GlTabs, + GlSprintf, + PackageTitle: () => import('~/packages/details/components/package_title.vue'), + TerraformTitle: () => + import('~/packages_and_registries/infrastructure_registry/components/details_title.vue'), + PackagesListLoader, + // PackageListRow, + // DependencyRow, + // PackageHistory, + // AdditionalMetadata, + // InstallationCommands, + // PackageFiles, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + mixins: [Tracking.mixin()], + inject: [ + 'titleComponent', + 'projectName', + 'canDelete', + 'svgPath', + 'npmPath', + 'npmHelpPath', + 'projectListUrl', + 'groupListUrl', + ], + trackingActions: { ...TrackingActions }, + data() { + return { + fileToDelete: null, + packageEntity: {}, + }; + }, + computed: { + packageFiles() { + return this.packageEntity.packageFiles; + }, + isLoading() { + return false; + }, + isValidPackage() { + return Boolean(this.packageEntity.name); + }, + tracking() { + return { + category: packageTypeToTrackCategory(this.packageEntity.package_type), + }; + }, + hasVersions() { + return this.packageEntity.versions?.length > 0; + }, + packageDependencies() { + return this.packageEntity.dependency_links || []; + }, + showDependencies() { + return this.packageEntity.package_type === PackageType.NUGET; + }, + showFiles() { + return this.packageEntity?.package_type !== PackageType.COMPOSER; + }, + }, + methods: { + formatSize(size) { + return numberToHumanSize(size); + }, + getPackageVersions() { + if (!this.packageEntity.versions) { + // this.fetchPackageVersions(); + } + }, + async confirmPackageDeletion() { + this.track(TrackingActions.DELETE_PACKAGE); + + await this.deletePackage(); + + const returnTo = + !this.groupListUrl || document.referrer.includes(this.projectName) + ? this.projectListUrl + : this.groupListUrl; // to avoid security issue url are supplied from backend + + const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true }); + + window.location.replace(`${returnTo}?${modalQuery}`); + }, + handleFileDelete(file) { + this.track(TrackingActions.REQUEST_DELETE_PACKAGE_FILE); + this.fileToDelete = { ...file }; + this.$refs.deleteFileModal.show(); + }, + confirmFileDelete() { + this.track(TrackingActions.DELETE_PACKAGE_FILE); + // this.deletePackageFile(this.fileToDelete.id); + this.fileToDelete = null; + }, + }, + i18n: { + deleteModalTitle: s__(`PackageRegistry|Delete Package Version`), + deleteModalContent: s__( + `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`, + ), + deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`), + deleteFileModalContent: s__( + `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`, + ), + }, + modal: { + packageDeletePrimaryAction: { + text: __('Delete'), + attributes: [ + { variant: 'danger' }, + { category: 'primary' }, + { 'data-qa-selector': 'delete_modal_button' }, + ], + }, + fileDeletePrimaryAction: { + text: __('Delete'), + attributes: [{ variant: 'danger' }, { category: 'primary' }], + }, + cancelAction: { + text: __('Cancel'), + }, + }, +}; +</script> + +<template> + <gl-empty-state + v-if="!isValidPackage" + :title="s__('PackageRegistry|Unable to load package')" + :description="s__('PackageRegistry|There was a problem fetching the details for this package.')" + :svg-path="svgPath" + /> + + <div v-else class="packages-app"> + <component :is="titleComponent"> + <template #delete-button> + <gl-button + v-if="canDelete" + v-gl-modal="'delete-modal'" + class="js-delete-button" + variant="danger" + category="primary" + data-qa-selector="delete_button" + > + {{ __('Delete') }} + </gl-button> + </template> + </component> + + <gl-tabs> + <gl-tab :title="__('Detail')"> + <div data-qa-selector="package_information_content"> + <!-- <package-history :package-entity="packageEntity" :project-name="projectName" /> + + <installation-commands + :package-entity="packageEntity" + :npm-path="npmPath" + :npm-help-path="npmHelpPath" + /> + + <additional-metadata :package-entity="packageEntity" /> --> + </div> + + <!-- <package-files + v-if="showFiles" + :package-files="packageFiles" + :can-delete="canDelete" + @download-file="track($options.trackingActions.PULL_PACKAGE)" + @delete-file="handleFileDelete" + /> --> + </gl-tab> + + <gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab"> + <template #title> + <span>{{ __('Dependencies') }}</span> + <gl-badge size="sm" data-testid="dependencies-badge">{{ + packageDependencies.length + }}</gl-badge> + </template> + + <template v-if="packageDependencies.length > 0"> + <dependency-row + v-for="(dep, index) in packageDependencies" + :key="index" + :dependency="dep" + /> + </template> + + <p v-else class="gl-mt-3" data-testid="no-dependencies-message"> + {{ s__('PackageRegistry|This NuGet package has no dependencies.') }} + </p> + </gl-tab> + + <gl-tab + :title="__('Other versions')" + title-item-class="js-versions-tab" + @click="getPackageVersions" + > + <template v-if="isLoading && !hasVersions"> + <packages-list-loader /> + </template> + + <template v-else-if="hasVersions"> + <!-- <package-list-row + v-for="v in packageEntity.versions" + :key="v.id" + :package-entity="{ name: packageEntity.name, ...v }" + :package-link="v.id.toString()" + :disable-delete="true" + :show-package-type="false" + /> --> + </template> + + <p v-else class="gl-mt-3" data-testid="no-versions-message"> + {{ s__('PackageRegistry|There are no other versions of this package.') }} + </p> + </gl-tab> + </gl-tabs> + + <gl-modal + ref="deleteModal" + class="js-delete-modal" + modal-id="delete-modal" + :action-primary="$options.modal.packageDeletePrimaryAction" + :action-cancel="$options.modal.cancelAction" + @primary="confirmPackageDeletion" + @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)" + > + <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template> + <gl-sprintf :message="$options.i18n.deleteModalContent"> + <template #version> + <strong>{{ packageEntity.version }}</strong> + </template> + + <template #name> + <strong>{{ packageEntity.name }}</strong> + </template> + </gl-sprintf> + </gl-modal> + + <gl-modal + ref="deleteFileModal" + modal-id="delete-file-modal" + :action-primary="$options.modal.fileDeletePrimaryAction" + :action-cancel="$options.modal.cancelAction" + @primary="confirmFileDelete" + @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)" + > + <template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template> + <gl-sprintf v-if="fileToDelete" :message="$options.i18n.deleteFileModalContent"> + <template #filename> + <strong>{{ fileToDelete.file_name }}</strong> + </template> + </gl-sprintf> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js new file mode 100644 index 00000000000..309b35a8084 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import Translate from '~/vue_shared/translate'; +import PackagesApp from '../components/details/app.vue'; + +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-vue-packages-detail-new'); + if (!el) { + return null; + } + + const { canDelete, ...datasetOptions } = el.dataset; + return new Vue({ + el, + provide: { + canDelete: parseBoolean(canDelete), + titleComponent: 'PackageTitle', + ...datasetOptions, + }, + render(createElement) { + return createElement(PackagesApp); + }, + }); +}; diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index 01d4861f5c2..ec3be43196c 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -86,7 +86,7 @@ export default { this.alertMessage = ERROR_UPDATING_SETTINGS; } else { this.dismissAlert(); - this.$toast.show(SUCCESS_UPDATING_SETTINGS, { type: 'success' }); + this.$toast.show(SUCCESS_UPDATING_SETTINGS); } }) .catch((e) => { diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js index a2256c5c371..d29489a0b33 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -3,19 +3,19 @@ import { s__, __ } from '~/locale'; export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Package Registry'); export const PACKAGE_SETTINGS_DESCRIPTION = s__( - 'PackageRegistry|GitLab Packages allows organizations to utilize GitLab as a private repository for a variety of common package formats. %{linkStart}More Information%{linkEnd}', + 'PackageRegistry|Use GitLab as a private registry for common package formats. %{linkStart}Learn more.%{linkEnd}', ); export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates'); export const DUPLICATES_ALLOWED_DISABLED = s__( - 'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Packages with the same name and version are rejected.', + 'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Reject packages with the same name and version.', ); export const DUPLICATES_ALLOWED_ENABLED = s__( - 'PackageRegistry|%{boldStart}Allow duplicates%{boldEnd} - Packages with the same name and version are accepted.', + 'PackageRegistry|%{boldStart}Allow duplicates%{boldEnd} - Accept packages with the same name and version.', ); export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions'); export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( - 'PackageRegistry|Packages can be published if their name or version matches this regex', + 'PackageRegistry|Publish packages if their name or version matches this regex.', ); export const SUCCESS_UPDATING_SETTINGS = s__('PackageRegistry|Settings saved successfully'); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue index 41be70a3ad5..6030af9d2c3 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue @@ -88,8 +88,6 @@ export default { return { ...this.value, cadence: this.findDefaultOption('cadence'), - keepN: this.findDefaultOption('keepN'), - olderThan: this.findDefaultOption('olderThan'), }; }, showLoadingIcon() { @@ -158,14 +156,14 @@ export default { .then(({ data }) => { const errorMessage = data?.updateContainerExpirationPolicy?.errors[0]; if (errorMessage) { - this.$toast.show(errorMessage, { type: 'error' }); + this.$toast.show(errorMessage); } else { - this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }); + this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE); } }) .catch((error) => { this.setApiErrors(error); - this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }); + this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE); }) .finally(() => { this.mutationLoading = false; diff --git a/app/assets/javascripts/packages_and_registries/settings/project/utils.js b/app/assets/javascripts/packages_and_registries/settings/project/utils.js index 4a2d7c7d466..b577a051862 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/utils.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/utils.js @@ -11,11 +11,14 @@ export const olderThanTranslationGenerator = (variable) => n__('%d day', '%d day export const keepNTranslationGenerator = (variable) => n__('%d tag per image name', '%d tags per image name', variable); -export const optionLabelGenerator = (collection, translationFn) => - collection.map((option) => ({ +export const optionLabelGenerator = (collection, translationFn) => { + const result = collection.map((option) => ({ ...option, label: translationFn(option.variable), })); + result.unshift({ key: null, label: '' }); + return result; +}; export const formOptionsGenerator = () => { return { diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index 3ad9d80b4f2..aa2f539b6e2 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -1,8 +1,7 @@ import $ from 'jquery'; import 'vendor/jquery.endless-scroll'; import axios from '~/lib/utils/axios_utils'; -import { getParameterByName } from '~/lib/utils/common_utils'; -import { removeParams } from '~/lib/utils/url_utility'; +import { removeParams, getParameterByName } from '~/lib/utils/url_utility'; const ENDLESS_SCROLL_BOTTOM_PX = 400; const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000; diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js index a88d35796f7..ab29f9149f7 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/index.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js @@ -5,4 +5,4 @@ import AbuseReports from './abuse_reports'; new AbuseReports(); /* eslint-disable-line no-new */ new UsersSelect(); /* eslint-disable-line no-new */ -document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior); +initDeprecatedRemoveRowBehavior(); diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js index a2fca238613..a5305777dd5 100644 --- a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js @@ -1,3 +1,3 @@ import setup from '~/admin/application_settings/setup_metrics_and_profiling'; -document.addEventListener('DOMContentLoaded', setup); +setup(); diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js new file mode 100644 index 00000000000..bf27b1a81ff --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js @@ -0,0 +1,41 @@ +import { __ } from '~/locale'; + +export const HELPER_TEXT_SERVICE_PING_DISABLED = __( + 'To enable Registration Features, make sure "Enable service ping" is checked.', +); + +export const HELPER_TEXT_SERVICE_PING_ENABLED = __( + 'You can enable Registration Features because Service Ping is enabled. To continue using Registration Features in the future, you will also need to register with GitLab via a new cloud licensing service.', +); + +function setHelperText(usagePingCheckbox) { + const helperTextId = document.getElementById('service_ping_features_helper_text'); + + const usagePingFeaturesLabel = document.getElementById('service_ping_features_label'); + + const usagePingFeaturesCheckbox = document.getElementById( + 'application_setting_usage_ping_features_enabled', + ); + + helperTextId.textContent = usagePingCheckbox.checked + ? HELPER_TEXT_SERVICE_PING_ENABLED + : HELPER_TEXT_SERVICE_PING_DISABLED; + + usagePingFeaturesLabel.classList.toggle('gl-cursor-not-allowed', !usagePingCheckbox.checked); + + usagePingFeaturesCheckbox.disabled = !usagePingCheckbox.checked; + + if (!usagePingCheckbox.checked) { + usagePingFeaturesCheckbox.disabled = true; + usagePingFeaturesCheckbox.checked = false; + } +} + +export default function initSetHelperText() { + const usagePingCheckbox = document.getElementById('application_setting_usage_ping_enabled'); + + setHelperText(usagePingCheckbox); + usagePingCheckbox.addEventListener('change', () => { + setHelperText(usagePingCheckbox); + }); +} 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 bc1d4dd6122..08f6633f424 100644 --- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js +++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '../../../flash'; +import createFlash from '~/flash'; import axios from '../../../lib/utils/axios_utils'; import { __ } from '../../../locale'; @@ -38,7 +38,9 @@ export default class PayloadPreviewer { }) .catch(() => { this.spinner.classList.remove('d-inline-flex'); - flash(__('Error fetching payload data.')); + createFlash({ + message: __('Error fetching payload data.'), + }); }); } 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 5a16716fe2d..2a7e6a45cdd 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js @@ -1,8 +1,9 @@ import $ from 'jquery'; import { debounce } from 'lodash'; -import { deprecatedCreateFlash as flash } from '~/flash'; +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 () => { @@ -30,7 +31,11 @@ export default () => { .then(({ data }) => { $jsBroadcastMessagePreview.html(data.message); }) - .catch(() => flash(__('An error occurred while rendering preview broadcast message'))); + .catch(() => + createFlash({ + message: __('An error occurred while rendering preview broadcast message'), + }), + ); } }; @@ -61,7 +66,7 @@ export default () => { 'input', debounce(() => { reloadPreview(); - }, 250), + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), ); const updateColorPreview = () => { diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index.js index b7db6443658..f687423594d 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/index.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/index.js @@ -1,7 +1,5 @@ import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; import initBroadcastMessagesForm from './broadcast_message'; -document.addEventListener('DOMContentLoaded', () => { - initBroadcastMessagesForm(); - initDeprecatedRemoveRowBehavior(); -}); +initBroadcastMessagesForm(); +initDeprecatedRemoveRowBehavior(); diff --git a/app/assets/javascripts/pages/admin/clusters/destroy/index.js b/app/assets/javascripts/pages/admin/clusters/destroy/index.js index 8001d2dd1da..487e7a14a16 100644 --- a/app/assets/javascripts/pages/admin/clusters/destroy/index.js +++ b/app/assets/javascripts/pages/admin/clusters/destroy/index.js @@ -1,5 +1,3 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new -}); +new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/clusters/edit/index.js b/app/assets/javascripts/pages/admin/clusters/edit/index.js index 8001d2dd1da..487e7a14a16 100644 --- a/app/assets/javascripts/pages/admin/clusters/edit/index.js +++ b/app/assets/javascripts/pages/admin/clusters/edit/index.js @@ -1,5 +1,3 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new -}); +new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js index 4d04c37caa7..f398b1cee82 100644 --- a/app/assets/javascripts/pages/admin/clusters/index.js +++ b/app/assets/javascripts/pages/admin/clusters/index.js @@ -1,5 +1,3 @@ import initCreateCluster from '~/create_cluster/init_create_cluster'; -document.addEventListener('DOMContentLoaded', () => { - initCreateCluster(document, gon); -}); +initCreateCluster(document, gon); diff --git a/app/assets/javascripts/pages/admin/clusters/index/index.js b/app/assets/javascripts/pages/admin/clusters/index/index.js index a99e0dfa4f0..a1ba920b322 100644 --- a/app/assets/javascripts/pages/admin/clusters/index/index.js +++ b/app/assets/javascripts/pages/admin/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/admin/clusters/new/index.js b/app/assets/javascripts/pages/admin/clusters/new/index.js index 876bab0b339..de9ded87ef3 100644 --- a/app/assets/javascripts/pages/admin/clusters/new/index.js +++ b/app/assets/javascripts/pages/admin/clusters/new/index.js @@ -1,5 +1,3 @@ import initNewCluster from '~/clusters/new_cluster'; -document.addEventListener('DOMContentLoaded', () => { - initNewCluster(); -}); +initNewCluster(); diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js index 9d94973af0d..524b2c6f66a 100644 --- a/app/assets/javascripts/pages/admin/clusters/show/index.js +++ b/app/assets/javascripts/pages/admin/clusters/show/index.js @@ -2,8 +2,6 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initIntegrationForm from '~/clusters/forms/show'; import initClusterHealth from '~/pages/projects/clusters/show/cluster_health'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new - initClusterHealth(); - initIntegrationForm(); -}); +new ClustersBundle(); // eslint-disable-line no-new +initClusterHealth(); +initIntegrationForm(); diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js index d6fa1be29b0..a94a60af7ff 100644 --- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js +++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js @@ -1,5 +1,5 @@ import initDevOpsScore from '~/analytics/devops_report/devops_score'; -import initDevOpsScoreDisabledUsagePing from '~/analytics/devops_report/devops_score_disabled_usage_ping'; +import initDevOpsScoreDisabledServicePing from '~/analytics/devops_report/devops_score_disabled_service_ping'; -initDevOpsScoreDisabledUsagePing(); +initDevOpsScoreDisabledServicePing(); initDevOpsScore(); diff --git a/app/assets/javascripts/pages/admin/identities/index.js b/app/assets/javascripts/pages/admin/identities/index.js new file mode 100644 index 00000000000..a9f5f00cb9b --- /dev/null +++ b/app/assets/javascripts/pages/admin/identities/index.js @@ -0,0 +1,6 @@ +import { initAdminUserActions, initDeleteUserModals } from '~/admin/users'; +import initConfirmModal from '~/confirm_modal'; + +initAdminUserActions(); +initDeleteUserModals(); +initConfirmModal(); diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js index dc1bb88bf4b..8fbc8dc17bc 100644 --- a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js +++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js @@ -1,3 +1,8 @@ import { initExpiresAtField } from '~/access_tokens'; +import { initAdminUserActions, initDeleteUserModals } from '~/admin/users'; +import initConfirmModal from '~/confirm_modal'; +initAdminUserActions(); +initDeleteUserModals(); initExpiresAtField(); +initConfirmModal(); diff --git a/app/assets/javascripts/pages/admin/integrations/edit/index.js b/app/assets/javascripts/pages/admin/integrations/edit/index.js index ba4b271f09e..8002fa8bf78 100644 --- a/app/assets/javascripts/pages/admin/integrations/edit/index.js +++ b/app/assets/javascripts/pages/admin/integrations/edit/index.js @@ -1,7 +1,7 @@ import IntegrationSettingsForm from '~/integrations/integration_settings_form'; import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; -document.addEventListener('DOMContentLoaded', () => { +function initIntegrations() { const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring'); const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); integrationSettingsForm.init(); @@ -10,4 +10,6 @@ document.addEventListener('DOMContentLoaded', () => { const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); prometheusMetrics.loadActiveMetrics(); } -}); +} + +initIntegrations(); diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index 46ddb95299d..a4d89889d57 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -5,7 +5,7 @@ import stopJobsModal from './components/stop_jobs_modal.vue'; Vue.use(Translate); -document.addEventListener('DOMContentLoaded', () => { +function initJobs() { const buttonId = 'js-stop-jobs-button'; const modalId = 'stop-jobs-modal'; const stopJobsButton = document.getElementById(buttonId); @@ -31,4 +31,6 @@ document.addEventListener('DOMContentLoaded', () => { }, }); } -}); +} + +initJobs(); diff --git a/app/assets/javascripts/pages/admin/keys/index.js b/app/assets/javascripts/pages/admin/keys/index.js index 45b83ffcd67..868c8e33077 100644 --- a/app/assets/javascripts/pages/admin/keys/index.js +++ b/app/assets/javascripts/pages/admin/keys/index.js @@ -1,5 +1,3 @@ import initConfirmModal from '~/confirm_modal'; -document.addEventListener('DOMContentLoaded', () => { - initConfirmModal(); -}); +initConfirmModal(); diff --git a/app/assets/javascripts/pages/admin/labels/index/index.js b/app/assets/javascripts/pages/admin/labels/index/index.js index 17ee7c03ed6..0ceab3b922f 100644 --- a/app/assets/javascripts/pages/admin/labels/index/index.js +++ b/app/assets/javascripts/pages/admin/labels/index/index.js @@ -1,4 +1,4 @@ -document.addEventListener('DOMContentLoaded', () => { +function initLabels() { const pagination = document.querySelector('.labels .gl-pagination'); const emptyState = document.querySelector('.labels .nothing-here-block.hidden'); @@ -18,4 +18,6 @@ document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.js-remove-label').forEach((row) => { row.addEventListener('ajax:success', removeLabelSuccessCallback); }); -}); +} + +initLabels(); diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue index b92fc8d125d..055d6f40c14 100644 --- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue +++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue @@ -46,7 +46,7 @@ export default { return sprintf( s__(`AdminProjects| You’re about to permanently delete the project %{projectName}, its repository, - and all related resources including issues, merge requests, etc.. Once you confirm and press + and all related resources, including issues and merge requests. Once you confirm and press %{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`), { projectName: `<strong>${escape(this.projectName)}</strong>`, diff --git a/app/assets/javascripts/pages/admin/projects/index/index.js b/app/assets/javascripts/pages/admin/projects/index/index.js index cc9a9b6cc38..c6cf4a46dba 100644 --- a/app/assets/javascripts/pages/admin/projects/index/index.js +++ b/app/assets/javascripts/pages/admin/projects/index/index.js @@ -13,9 +13,11 @@ import deleteProjectModal from './components/delete_project_modal.vue'; const deleteModal = new Vue({ el: deleteProjectModalEl, - data: { - deleteProjectUrl: '', - projectName: '', + data() { + return { + deleteProjectUrl: '', + projectName: '', + }; }, mounted() { const deleteProjectButtons = document.querySelectorAll('.delete-project-button'); diff --git a/app/assets/javascripts/pages/admin/spam_logs/index.js b/app/assets/javascripts/pages/admin/spam_logs/index.js index e5ab5d43bbf..ac850a6467b 100644 --- a/app/assets/javascripts/pages/admin/spam_logs/index.js +++ b/app/assets/javascripts/pages/admin/spam_logs/index.js @@ -1,3 +1,3 @@ import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; -document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior); +initDeprecatedRemoveRowBehavior(); diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js index 9a8b0c9990f..41e99a3baf5 100644 --- a/app/assets/javascripts/pages/admin/users/index.js +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -1,64 +1,7 @@ -import Vue from 'vue'; - -import { initAdminUsersApp } from '~/admin/users'; +import { initAdminUsersApp, initDeleteUserModals, initAdminUserActions } from '~/admin/users'; import initConfirmModal from '~/confirm_modal'; -import csrf from '~/lib/utils/csrf'; -import Translate from '~/vue_shared/translate'; -import ModalManager from './components/user_modal_manager.vue'; - -const CONFIRM_DELETE_BUTTON_SELECTOR = '.js-delete-user-modal-button'; -const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts'; -const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal'; - -function loadModalsConfigurationFromHtml(modalsElement) { - const modalsConfiguration = {}; - - if (!modalsElement) { - /* eslint-disable-next-line @gitlab/require-i18n-strings */ - throw new Error('Modals content element not found!'); - } - - Array.from(modalsElement.children).forEach((node) => { - const { modal, ...config } = node.dataset; - modalsConfiguration[modal] = { - title: node.dataset.title, - ...config, - content: node.innerHTML, - }; - }); - - return modalsConfiguration; -} - -document.addEventListener('DOMContentLoaded', () => { - Vue.use(Translate); - - initAdminUsersApp(); - - const modalConfiguration = loadModalsConfigurationFromHtml( - document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR), - ); - - // eslint-disable-next-line no-new - new Vue({ - el: MODAL_MANAGER_SELECTOR, - functional: true, - methods: { - show(...args) { - this.$refs.manager.show(...args); - }, - }, - render(h) { - return h(ModalManager, { - ref: 'manager', - props: { - selector: CONFIRM_DELETE_BUTTON_SELECTOR, - modalConfiguration, - csrfToken: csrf.token, - }, - }); - }, - }); - initConfirmModal(); -}); +initAdminUsersApp(); +initAdminUserActions(); +initDeleteUserModals(); +initConfirmModal(); diff --git a/app/assets/javascripts/pages/admin/users/keys/index.js b/app/assets/javascripts/pages/admin/users/keys/index.js deleted file mode 100644 index 45b83ffcd67..00000000000 --- a/app/assets/javascripts/pages/admin/users/keys/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import initConfirmModal from '~/confirm_modal'; - -document.addEventListener('DOMContentLoaded', () => { - initConfirmModal(); -}); diff --git a/app/assets/javascripts/pages/dashboard/groups/index/index.js b/app/assets/javascripts/pages/dashboard/groups/index/index.js index b9277106a71..c14848c4798 100644 --- a/app/assets/javascripts/pages/dashboard/groups/index/index.js +++ b/app/assets/javascripts/pages/dashboard/groups/index/index.js @@ -1,5 +1,3 @@ import initGroupsList from '~/groups'; -document.addEventListener('DOMContentLoaded', () => { - initGroupsList(); -}); +initGroupsList(); diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js index 397149aaa9e..1f3e458fe17 100644 --- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js +++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js @@ -2,8 +2,6 @@ import Milestone from '~/milestone'; import Sidebar from '~/right_sidebar'; import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; -document.addEventListener('DOMContentLoaded', () => { - new Milestone(); // eslint-disable-line no-new - new Sidebar(); // eslint-disable-line no-new - new MountMilestoneSidebar(); // eslint-disable-line no-new -}); +new Milestone(); // eslint-disable-line no-new +new Sidebar(); // eslint-disable-line no-new +new MountMilestoneSidebar(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index 42341436b55..946076cfb29 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import { getGroups } from '~/api/groups_api'; import { getProjects } from '~/api/projects_api'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { isMetaClick } from '~/lib/utils/common_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; @@ -103,7 +103,9 @@ export default class Todos { }) .catch(() => { this.updateRowState(target, true); - return flash(__('Error updating status of to-do item.')); + return createFlash({ + message: __('Error updating status of to-do item.'), + }); }); } @@ -145,7 +147,11 @@ export default class Todos { this.updateAllState(target, data); this.updateBadges(data); }) - .catch(() => flash(__('Error updating status for all to-do items.'))); + .catch(() => + createFlash({ + message: __('Error updating status for all to-do items.'), + }), + ); } updateAllState(target, data) { diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js index 01001d4f3ff..6c9378b7231 100644 --- a/app/assets/javascripts/pages/explore/projects/index.js +++ b/app/assets/javascripts/pages/explore/projects/index.js @@ -1,5 +1,3 @@ import ProjectsList from '~/projects_list'; -document.addEventListener('DOMContentLoaded', () => { - new ProjectsList(); // eslint-disable-line no-new -}); +new ProjectsList(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/clusters/destroy/index.js b/app/assets/javascripts/pages/groups/clusters/destroy/index.js index 8001d2dd1da..487e7a14a16 100644 --- a/app/assets/javascripts/pages/groups/clusters/destroy/index.js +++ b/app/assets/javascripts/pages/groups/clusters/destroy/index.js @@ -1,5 +1,3 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new -}); +new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/clusters/edit/index.js b/app/assets/javascripts/pages/groups/clusters/edit/index.js index 8001d2dd1da..487e7a14a16 100644 --- a/app/assets/javascripts/pages/groups/clusters/edit/index.js +++ b/app/assets/javascripts/pages/groups/clusters/edit/index.js @@ -1,5 +1,3 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new -}); +new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/clusters/index.js b/app/assets/javascripts/pages/groups/clusters/index.js index d5ce5d076a2..4d48bd4be2b 100644 --- a/app/assets/javascripts/pages/groups/clusters/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index.js @@ -1,7 +1,5 @@ import initIntegrationForm from '~/clusters/forms/show/index'; import initCreateCluster from '~/create_cluster/init_create_cluster'; -document.addEventListener('DOMContentLoaded', () => { - initCreateCluster(document, gon); - initIntegrationForm(); -}); +initCreateCluster(document, gon); +initIntegrationForm(); diff --git a/app/assets/javascripts/pages/groups/clusters/new/index.js b/app/assets/javascripts/pages/groups/clusters/new/index.js index 876bab0b339..de9ded87ef3 100644 --- a/app/assets/javascripts/pages/groups/clusters/new/index.js +++ b/app/assets/javascripts/pages/groups/clusters/new/index.js @@ -1,5 +1,3 @@ import initNewCluster from '~/clusters/new_cluster'; -document.addEventListener('DOMContentLoaded', () => { - initNewCluster(); -}); +initNewCluster(); diff --git a/app/assets/javascripts/pages/groups/clusters/show/index.js b/app/assets/javascripts/pages/groups/clusters/show/index.js index ccf631b2c53..5d202a8824f 100644 --- a/app/assets/javascripts/pages/groups/clusters/show/index.js +++ b/app/assets/javascripts/pages/groups/clusters/show/index.js @@ -1,7 +1,5 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initClusterHealth from '~/pages/projects/clusters/show/cluster_health'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new - initClusterHealth(); -}); +new ClustersBundle(); // eslint-disable-line no-new +initClusterHealth(); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 76db578f6f9..342c054471d 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,5 +1,5 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; -import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; +import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { mountIssuablesListApp } from '~/issues_list'; import initManualOrdering from '~/manual_ordering'; import { FILTERED_SEARCH } from '~/pages/constants'; diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 2f6f9bb16e1..02a0a50f984 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,6 +1,6 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; +import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/pages/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js index 2a2cc5faebe..914e2831185 100644 --- a/app/assets/javascripts/pages/groups/milestones/show/index.js +++ b/app/assets/javascripts/pages/groups/milestones/show/index.js @@ -1,7 +1,5 @@ import initDeleteMilestoneModal from '~/pages/milestones/shared/delete_milestone_modal_init'; import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show'; -document.addEventListener('DOMContentLoaded', () => { - initMilestonesShow(); - initDeleteMilestoneModal(); -}); +initMilestonesShow(); +initDeleteMilestoneModal(); diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js index a0ff98645fb..c58be202043 100644 --- a/app/assets/javascripts/pages/groups/new/group_path_validator.js +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -1,6 +1,6 @@ import { debounce } from 'lodash'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import InputValidator from '~/validators/input_validator'; import fetchGroupPathAvailability from './fetch_group_path_availability'; @@ -12,7 +12,6 @@ const parentIdSelector = 'group_parent_id'; const successMessageSelector = '.validation-success'; const pendingMessageSelector = '.validation-pending'; const unavailableMessageSelector = '.validation-error'; -const suggestionsMessageSelector = '.gl-path-suggestions'; const inputGroupSelector = '.input-group'; export default class GroupPathValidator extends InputValidator { @@ -57,21 +56,19 @@ export default class GroupPathValidator extends InputValidator { ); if (data.exists) { - GroupPathValidator.showSuggestions(inputDomElement, data.suggests); + const [suggestedSlug] = data.suggests; + const targetDomElement = document.querySelector('.js-autofill-group-path'); + targetDomElement.value = suggestedSlug; } }) - .catch(() => flash(__('An error occurred while validating group path'))); + .catch(() => + createFlash({ + message: __('An error occurred while validating group path'), + }), + ); } } - static showSuggestions(inputDomElement, suggestions) { - const messageElement = inputDomElement.parentElement.parentElement.querySelector( - suggestionsMessageSelector, - ); - const textSuggestions = suggestions && suggestions.length > 0 ? suggestions.join(', ') : 'none'; - messageElement.textContent = textSuggestions; - } - static setMessageVisibility(inputDomElement, messageSelector, isVisible = true) { const messageElement = inputDomElement .closest(inputGroupSelector) diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue index 16f68b94c9a..34f9fe778ea 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue @@ -1,6 +1,6 @@ <script> import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; @@ -98,17 +98,17 @@ Once deleted, it cannot be undone or recovered.`), }); if (error.response && error.response.status === 404) { - Flash( - sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), { + createFlash({ + message: sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), { milestoneTitle: this.milestoneTitle, }), - ); + }); } else { - Flash( - sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), { + createFlash({ + message: sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), { milestoneTitle: this.milestoneTitle, }), - ); + }); } throw error; }); diff --git a/app/assets/javascripts/pages/profiles/notifications/show/index.js b/app/assets/javascripts/pages/profiles/notifications/show/index.js index 51ba6c7a01e..6aa0f260cc0 100644 --- a/app/assets/javascripts/pages/profiles/notifications/show/index.js +++ b/app/assets/javascripts/pages/profiles/notifications/show/index.js @@ -1,5 +1,3 @@ import initNotificationsDropdown from '~/notifications'; -document.addEventListener('DOMContentLoaded', () => { - initNotificationsDropdown(); -}); +initNotificationsDropdown(); diff --git a/app/assets/javascripts/pages/projects/artifacts/browse/index.js b/app/assets/javascripts/pages/projects/artifacts/browse/index.js index 58ba6a500a3..60680ec7d1d 100644 --- a/app/assets/javascripts/pages/projects/artifacts/browse/index.js +++ b/app/assets/javascripts/pages/projects/artifacts/browse/index.js @@ -1,7 +1,5 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import BuildArtifacts from '~/build_artifacts'; -document.addEventListener('DOMContentLoaded', () => { - new ShortcutsNavigation(); // eslint-disable-line no-new - new BuildArtifacts(); // eslint-disable-line no-new -}); +new ShortcutsNavigation(); // eslint-disable-line no-new +new BuildArtifacts(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/artifacts/file/index.js b/app/assets/javascripts/pages/projects/artifacts/file/index.js index eb5ecc27c43..057ef157374 100644 --- a/app/assets/javascripts/pages/projects/artifacts/file/index.js +++ b/app/assets/javascripts/pages/projects/artifacts/file/index.js @@ -1,7 +1,5 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import BlobViewer from '~/blob/viewer/index'; -document.addEventListener('DOMContentLoaded', () => { - new ShortcutsNavigation(); // eslint-disable-line no-new - new BlobViewer(); // eslint-disable-line no-new -}); +new ShortcutsNavigation(); // eslint-disable-line no-new +new BlobViewer(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/clusters/destroy/index.js b/app/assets/javascripts/pages/projects/clusters/destroy/index.js index 8001d2dd1da..487e7a14a16 100644 --- a/app/assets/javascripts/pages/projects/clusters/destroy/index.js +++ b/app/assets/javascripts/pages/projects/clusters/destroy/index.js @@ -1,5 +1,3 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new -}); +new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/clusters/edit/index.js b/app/assets/javascripts/pages/projects/clusters/edit/index.js index 8001d2dd1da..487e7a14a16 100644 --- a/app/assets/javascripts/pages/projects/clusters/edit/index.js +++ b/app/assets/javascripts/pages/projects/clusters/edit/index.js @@ -1,5 +1,3 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new -}); +new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/clusters/index.js b/app/assets/javascripts/pages/projects/clusters/index.js index 4d04c37caa7..f398b1cee82 100644 --- a/app/assets/javascripts/pages/projects/clusters/index.js +++ b/app/assets/javascripts/pages/projects/clusters/index.js @@ -1,5 +1,3 @@ import initCreateCluster from '~/create_cluster/init_create_cluster'; -document.addEventListener('DOMContentLoaded', () => { - initCreateCluster(document, gon); -}); +initCreateCluster(document, gon); diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index 1d019285e23..71ab5a0b19c 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -3,9 +3,7 @@ import initIntegrationForm from '~/clusters/forms/show'; import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; import initClusterHealth from './cluster_health'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new - initGkeNamespace(); - initClusterHealth(); - initIntegrationForm(); -}); +new ClustersBundle(); // eslint-disable-line no-new +initGkeNamespace(); +initClusterHealth(); +initIntegrationForm(); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index d75c3cc6b8b..e3b30560fef 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import loadAwardsHandler from '~/awards_handler'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import Diff from '~/diff'; -import flash from '~/flash'; +import createFlash from '~/flash'; import initChangesDropdown from '~/init_changes_dropdown'; import initNotes from '~/init_notes'; import axios from '~/lib/utils/axios_utils'; @@ -39,7 +39,7 @@ if (filesContainer.length) { new Diff(); }) .catch(() => { - flash({ message: __('An error occurred while retrieving diff files') }); + createFlash({ message: __('An error occurred while retrieving diff files') }); }); } else { new Diff(); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 75c3b6d564c..795ae713c08 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -39,6 +39,14 @@ const initFormField = ({ value, required = true, skipValidation = false }) => ({ feedback: null, }); +function sortNamespaces(namespaces) { + if (!namespaces || !namespaces?.length) { + return namespaces; + } + + return namespaces.sort((a, b) => a.full_name.localeCompare(b.full_name)); +} + export default { components: { GlForm, @@ -206,7 +214,7 @@ export default { methods: { async fetchNamespaces() { const { data } = await axios.get(this.endpoint); - this.namespaces = data.namespaces; + this.namespaces = sortNamespaces(data.namespaces); }, isVisibilityLevelDisabled(visibility) { return !this.allowedVisibilityLevels.includes(visibility); @@ -301,11 +309,11 @@ export default { :state="form.fields.namespace.state" required > - <template slot="first"> + <template #first> <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option> </template> <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace"> - {{ namespace.name }} + {{ namespace.full_name }} </option> </gl-form-select> </gl-form-input-group> diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue index 88f4bba5e2a..d41488acf46 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue @@ -101,7 +101,7 @@ export default { v-if="isGroupPendingRemoval" variant="warning" class="gl-display-none gl-sm-display-flex gl-mt-3 gl-mr-1" - >{{ __('pending removal') }}</gl-badge + >{{ __('pending deletion') }}</gl-badge > <user-access-role-badge v-if="group.permission" class="gl-mt-3"> {{ group.permission }} diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 1eab3becbc3..8ec6e5e66b3 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,7 +1,9 @@ import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; +import initTerraformNotification from '../../projects/terraform_notification'; import { initSidebarTracking } from '../shared/nav/sidebar_tracking'; import Project from './project'; new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new initSidebarTracking(); +initTerraformNotification(); diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue index aaa9bb906b2..e708cd32fff 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -85,28 +85,29 @@ export default { :action-cancel="$options.cancelProps" @primary="onSubmit" > - <div slot="modal-title" class="modal-title-with-label"> - <gl-sprintf - :message=" - s__( - 'Labels|%{spanStart}Promote label%{spanEnd} %{labelTitle} %{spanStart}to Group Label?%{spanEnd}', - ) - " - > - <template #labelTitle> - <span - class="label color-label" - :style="`background-color: ${labelColor}; color: ${labelTextColor};`" - > - {{ labelTitle }} - </span> - </template> - <template #span="{ content }" - ><span>{{ content }}</span></template + <template #modal-title> + <div class="modal-title-with-label"> + <gl-sprintf + :message=" + s__( + 'Labels|%{spanStart}Promote label%{spanEnd} %{labelTitle} %{spanStart}to Group Label?%{spanEnd}', + ) + " > - </gl-sprintf> - </div> - + <template #labelTitle> + <span + class="label color-label" + :style="`background-color: ${labelColor}; color: ${labelTextColor};`" + > + {{ labelTitle }} + </span> + </template> + <template #span="{ content }" + ><span>{{ content }}</span></template + > + </gl-sprintf> + </div> + </template> {{ text }} </gl-modal> </template> diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js index 05019915fc9..545a39f4cf1 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js @@ -1,7 +1,5 @@ import initMergeConflicts from '~/merge_conflicts/merge_conflicts_bundle'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -document.addEventListener('DOMContentLoaded', () => { - initSidebarBundle(); - initMergeConflicts(); -}); +initSidebarBundle(); +initMergeConflicts(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js index 8d152ec4ba6..d61209f904d 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js @@ -15,7 +15,7 @@ const updateCommitList = (url, $loadingIndicator, $commitList, params) => { .then(({ data }) => { $loadingIndicator.hide(); $commitList.html(data); - localTimeAgo($('.js-timeago', $commitList)); + localTimeAgo($commitList.get(0).querySelectorAll('.js-timeago')); }); }; diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js index 68ab7021cf3..e5f97530c02 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; @@ -37,7 +37,11 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( callback(data); } }) - .catch(() => flash(__('Error fetching refs'))); + .catch(() => + createFlash({ + message: __('Error fetching refs'), + }), + ); }, selectable: true, filterable: true, diff --git a/app/assets/javascripts/pages/projects/new/components/app.vue b/app/assets/javascripts/pages/projects/new/components/app.vue index 60a4fbc3e6b..6e9efc50be8 100644 --- a/app/assets/javascripts/pages/projects/new/components/app.vue +++ b/app/assets/javascripts/pages/projects/new/components/app.vue @@ -4,12 +4,10 @@ import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-cr import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg'; import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg'; import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import { experiment } from '~/experimentation/utils'; import { s__ } from '~/locale'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; import NewProjectPushTipPopover from './new_project_push_tip_popover.vue'; -const NEW_REPO_EXPERIMENT = 'new_repo'; const CI_CD_PANEL = 'cicd_for_external_repo'; const PANELS = [ { @@ -79,28 +77,8 @@ export default { }, computed: { - decoratedPanels() { - const PANEL_TITLES = experiment(NEW_REPO_EXPERIMENT, { - use: () => ({ - blank: s__('ProjectsNew|Create blank project'), - import: s__('ProjectsNew|Import project'), - }), - try: () => ({ - blank: s__('ProjectsNew|Create blank project/repository'), - import: s__('ProjectsNew|Import project/repository'), - }), - }); - - return PANELS.map(({ key, title, ...el }) => ({ - ...el, - title: PANEL_TITLES[key] ?? title, - })); - }, - availablePanels() { - return this.isCiCdAvailable - ? this.decoratedPanels - : this.decoratedPanels.filter((p) => p.name !== CI_CD_PANEL); + return this.isCiCdAvailable ? PANELS : PANELS.filter((p) => p.name !== CI_CD_PANEL); }, }, @@ -112,7 +90,6 @@ export default { } }, }, - EXPERIMENT: NEW_REPO_EXPERIMENT, }; </script> @@ -122,7 +99,6 @@ export default { :panels="availablePanels" :jump-to-last-persisted-panel="hasErrors" :title="s__('ProjectsNew|Create new project')" - :experiment="$options.EXPERIMENT" persistence-key="new_project_last_active_tab" @panel-change="resetProjectErrors" > diff --git a/app/assets/javascripts/pages/projects/packages/packages/show/index.js b/app/assets/javascripts/pages/projects/packages/packages/show/index.js index 1afb900ed88..ee06f247ddc 100644 --- a/app/assets/javascripts/pages/projects/packages/packages/show/index.js +++ b/app/assets/javascripts/pages/projects/packages/packages/show/index.js @@ -1,3 +1,11 @@ -import initPackageDetail from '~/packages/details/'; - -initPackageDetail(); +(async function initPackage() { + let app; + if (document.getElementById('js-vue-packages-detail-new')) { + app = await import( + /* webpackChunkName: 'new_package_app' */ `~/packages_and_registries/package_registry/pages/details.js` + ); + } else { + app = await import('~/packages/details/'); + } + app.default(); +})(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js index d65be6bc69e..6dd21380bec 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js @@ -1,3 +1,3 @@ import initForm from '../shared/init_form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js index d65be6bc69e..6dd21380bec 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js @@ -1,3 +1,3 @@ import initForm from '../shared/init_form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js index d65be6bc69e..6dd21380bec 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js @@ -1,3 +1,3 @@ import initForm from '../shared/init_form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js index d65be6bc69e..6dd21380bec 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js @@ -1,3 +1,3 @@ import initForm from '../shared/init_form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 3b24c2c128b..9e93f709937 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; import initClonePanel from '~/clone_panel'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { serializeForm } from '~/lib/utils/forms'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -78,7 +78,11 @@ export default class Project { }, }) .then(({ data }) => callback(data)) - .catch(() => flash(__('An error occurred while getting projects'))); + .catch(() => + createFlash({ + message: __('An error occurred while getting projects'), + }), + ); }, selectable: true, filterable: true, diff --git a/app/assets/javascripts/pages/projects/security/configuration/index.js b/app/assets/javascripts/pages/projects/security/configuration/index.js index 101cb8356b2..8bba3d7af54 100644 --- a/app/assets/javascripts/pages/projects/security/configuration/index.js +++ b/app/assets/javascripts/pages/projects/security/configuration/index.js @@ -1,3 +1,3 @@ -import { initStaticSecurityConfiguration } from '~/security_configuration'; +import { initCESecurityConfiguration } from '~/security_configuration'; -initStaticSecurityConfiguration(document.querySelector('#js-security-configuration-static')); +initCESecurityConfiguration(document.querySelector('#js-security-configuration-static')); 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 db7b3bad6ed..e88dbf20e1b 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 @@ -8,6 +8,7 @@ import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deploy import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; import initSettingsPanels from '~/settings_panels'; +import { initTokenAccess } from '~/token_access'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -40,4 +41,5 @@ document.addEventListener('DOMContentLoaded', () => { initSharedRunnersToggle(); initInstallRunner(); initRunnerAwsDeployments(); + initTokenAccess(); }); 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 11e6b4577e0..6fcaa3ab04b 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 @@ -104,6 +104,11 @@ export default { required: false, default: '', }, + issuesHelpPath: { + type: String, + required: false, + default: '', + }, lfsHelpPath: { type: String, required: false, @@ -438,8 +443,13 @@ export default { > <project-setting-row ref="issues-settings" + :help-path="issuesHelpPath" :label="$options.i18n.issuesLabel" - :help-text="s__('ProjectSettings|Lightweight issue tracking system.')" + :help-text=" + s__( + 'ProjectSettings|Flexible tool to collaboratively develop ideas and plan work in this project.', + ) + " > <project-feature-setting v-model="issuesAccessLevel" diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index 4104025aa59..ae605edeaf0 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -2,8 +2,6 @@ import NoEmojiValidator from '~/emoji/no_emoji_validator'; import LengthValidator from '~/pages/sessions/new/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; -document.addEventListener('DOMContentLoaded', () => { - new UsernameValidator(); // eslint-disable-line no-new - new LengthValidator(); // eslint-disable-line no-new - new NoEmojiValidator(); // eslint-disable-line no-new -}); +new UsernameValidator(); // eslint-disable-line no-new +new LengthValidator(); // eslint-disable-line no-new +new NoEmojiValidator(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index d39f56cfd03..465aed88c01 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import initVueAlerts from '~/vue_alerts'; import NoEmojiValidator from '../../../emoji/no_emoji_validator'; import LengthValidator from './length_validator'; import OAuthRememberMe from './oauth_remember_me'; @@ -19,4 +20,5 @@ document.addEventListener('DOMContentLoaded', () => { // Save the URL fragment from the current window location. This will be present if the user was // redirected to sign-in after attempting to access a protected URL that included a fragment. preserveUrlFragment(window.location.hash); + initVueAlerts(); }); diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js index 338fe1b66f2..7ea744a68a6 100644 --- a/app/assets/javascripts/pages/sessions/new/username_validator.js +++ b/app/assets/javascripts/pages/sessions/new/username_validator.js @@ -1,6 +1,6 @@ import { debounce } from 'lodash'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import InputValidator from '~/validators/input_validator'; @@ -50,7 +50,11 @@ export default class UsernameValidator extends InputValidator { usernameTaken ? unavailableMessageSelector : successMessageSelector, ); }) - .catch(() => flash(__('An error occurred while validating username'))); + .catch(() => + createFlash({ + message: __('An error occurred while validating username'), + }), + ); } } 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 26f6d1d683a..e883fecb170 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -212,13 +212,20 @@ export default { .then(({ data }) => data.body); }, - handleFormSubmit() { + async handleFormSubmit(e) { + e.preventDefault(); + if (this.useContentEditor) { this.content = this.contentEditor.getSerializedContent(); this.trackFormSubmit(); } + // Wait until form field values are refreshed + await this.$nextTick(); + + e.target.submit(); + this.isDirty = false; }, @@ -257,6 +264,7 @@ export default { this.contentEditor || createContentEditor({ renderMarkdown: (markdown) => this.getContentHTML(markdown), + uploadsPath: this.pageInfo.uploadsPath, tiptapOptions: { onUpdate: () => this.handleContentChange(), }, @@ -454,7 +462,7 @@ export default { </markdown-field> <div v-if="isContentEditorActive"> - <gl-alert class="gl-mb-6" variant="tip" :dismissable="false"> + <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 @@ -468,7 +476,11 @@ export default { > </gl-sprintf> </gl-alert> - <gl-loading-icon v-if="isContentEditorLoading" class="bordered-box gl-w-full gl-py-6" /> + <gl-loading-icon + v-if="isContentEditorLoading" + size="sm" + class="bordered-box gl-w-full gl-py-6" + /> <content-editor v-else :content-editor="contentEditor" /> <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" /> </div> diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js index c04cd0b3fa4..42aefe81325 100644 --- a/app/assets/javascripts/pages/shared/wikis/index.js +++ b/app/assets/javascripts/pages/shared/wikis/index.js @@ -27,8 +27,10 @@ const createModalVueApp = () => { // eslint-disable-next-line no-new new Vue({ el: deleteWikiModalWrapperEl, - data: { - deleteWikiUrl: '', + data() { + return { + deleteWikiUrl: '', + }; }, render(createElement) { return createElement(deleteWikiModal, { diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 03dba699461..0fab4678bc3 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -2,7 +2,7 @@ import { select } from 'd3-selection'; import dateFormat from 'dateformat'; import $ from 'jquery'; import { last } from 'lodash'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; import { n__, s__, __ } from '~/locale'; @@ -295,7 +295,11 @@ export default class ActivityCalendar { responseType: 'text', }) .then(({ data }) => $(this.activitiesContainer).html(data)) - .catch(() => flash(__('An error occurred while retrieving calendar activity'))); + .catch(() => + createFlash({ + message: __('An error occurred while retrieving calendar activity'), + }), + ); } else { this.currentSelectedDate = ''; $(this.activitiesContainer).html(''); diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index f9d70845560..90eafa85886 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -166,7 +166,7 @@ export default class UserTabs { const tabSelector = `div#${action}`; this.$parentEl.find(tabSelector).html(data.html); this.loaded[action] = true; - localTimeAgo($('.js-timeago', tabSelector)); + localTimeAgo(document.querySelectorAll(`${tabSelector} .js-timeago`)); this.toggleLoading(false); }) @@ -209,7 +209,7 @@ export default class UserTabs { container, url: $(`${container} .overview-content-list`).data('href'), ...options, - postRenderCallback: () => localTimeAgo($('.js-timeago', container)), + postRenderCallback: () => localTimeAgo(document.querySelectorAll(`${container} .js-timeago`)), }); } diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js index 1db80057d0c..b9a9ef215af 100644 --- a/app/assets/javascripts/performance/constants.js +++ b/app/assets/javascripts/performance/constants.js @@ -83,7 +83,9 @@ export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_links_per_job_ra // Marks export const REPO_BLOB_LOAD_VIEWER_START = 'blobviewer-load-viewer-start'; +export const REPO_BLOB_SWITCH_TO_VIEWER_START = 'blobviewer-switch-to-viewerr-start'; export const REPO_BLOB_LOAD_VIEWER_FINISH = 'blobviewer-load-viewer-finish'; // Measures -export const REPO_BLOB_LOAD_VIEWER = 'Repository File Viewer: loading the content'; +export const REPO_BLOB_LOAD_VIEWER = 'Repository File Viewer: loading the viewer'; +export const REPO_BLOB_SWITCH_VIEWER = 'Repository File Viewer: switching the viewer'; diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 04efc459a21..f163a7c3a8e 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -27,9 +27,7 @@ export default { title: { type: String, required: false, - default() { - return this.metric; - }, + default: null, }, header: { type: String, @@ -101,6 +99,9 @@ export default { return ''; }, + actualTitle() { + return this.title ?? this.metric; + }, }, methods: { toggleBacktrace(toggledIndex) { @@ -214,7 +215,7 @@ export default { <div></div> </template> </gl-modal> - {{ title }} + {{ actualTitle }} <request-warning :html-id="htmlId" :warnings="warnings" /> </div> </template> diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index e845c8b9df4..bc83844b8b9 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as Flash } from './flash'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { parseBoolean } from './lib/utils/common_utils'; import { __ } from './locale'; @@ -62,7 +62,11 @@ export default class PersistentUserCallout { } }) .catch(() => { - Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); + createFlash({ + message: __( + 'An error occurred while dismissing the alert. Refresh the page and try again.', + ), + }); }); } @@ -79,11 +83,11 @@ export default class PersistentUserCallout { window.location.assign(href); }) .catch(() => { - Flash( - __( + createFlash({ + message: __( 'An error occurred while acknowledging the notification. Refresh the page and try again.', ), - ); + }); }); } diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue index f6e88738002..f1fe8cf10fd 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -103,6 +103,7 @@ export default { v-model="targetBranch" class="gl-font-monospace!" required + data-qa-selector="target_branch_field" /> <gl-form-checkbox v-if="!isCurrentBranchTarget" diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue index 455990f2791..853e839a7ab 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue @@ -2,14 +2,14 @@ import { GlIcon } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { s__ } from '~/locale'; -import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; export default { i18n: { viewOnlyMessage: s__('Pipelines|Merged YAML is view only'), }, components: { - EditorLite, + SourceEditor, GlIcon, }, inject: ['ciConfigPath'], @@ -41,7 +41,7 @@ export default { {{ $options.i18n.viewOnlyMessage }} </div> <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1"> - <editor-lite + <source-editor ref="editor" :value="mergedYaml" :file-name="ciConfigPath" 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 new file mode 100644 index 00000000000..b4e9ab81d38 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue @@ -0,0 +1,38 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../constants'; + +export default { + i18n: { + browseTemplates: __('Browse templates'), + }, + TEMPLATE_REPOSITORY_URL, + components: { + GlButton, + }, + mixins: [Tracking.mixin()], + methods: { + trackTemplateBrowsing() { + const { label, actions } = pipelineEditorTrackingOptions; + + this.track(actions.browse_templates, { label }); + }, + }, +}; +</script> + +<template> + <div class="gl-bg-gray-10 gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1"> + <gl-button + :href="$options.TEMPLATE_REPOSITORY_URL" + size="small" + icon="external-link" + target="_blank" + @click="trackTemplateBrowsing" + > + {{ $options.i18n.browseTemplates }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue index d373f74a5c4..77ede396496 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue @@ -1,13 +1,13 @@ <script> import { EDITOR_READY_EVENT } from '~/editor/constants'; -import { CiSchemaExtension } from '~/editor/extensions/editor_ci_schema_ext'; -import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getCommitSha from '../../graphql/queries/client/commit_sha.graphql'; export default { components: { - EditorLite, + SourceEditor, }, mixins: [glFeatureFlagMixin()], inject: ['ciConfigPath', 'projectPath', 'projectNamespace', 'defaultBranch'], @@ -43,8 +43,8 @@ export default { }; </script> <template> - <div class="gl-border-solid gl-border-gray-100 gl-border-1"> - <editor-lite + <div class="gl-border-solid gl-border-gray-100 gl-border-1 gl-border-t-none!"> + <source-editor ref="editor" :file-name="ciConfigPath" v-bind="$attrs" diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index 05b87abecd5..ee6d4ff7c4d 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -158,6 +158,12 @@ export default { const updatedPath = setUrlParams({ branch_name: newBranch }); historyPushState(updatedPath); + this.$emit('updateCommitSha', { newBranch }); + + // refetching the content will cause a lot of components to re-render, + // including the text editor which uses the commit sha to register the CI schema + // so we need to make sure the commit sha is updated first + await this.$nextTick(); this.$emit('refetchContent'); }, async setSearchTerm(newSearchTerm) { @@ -205,6 +211,7 @@ export default { :header-text="$options.i18n.dropdownHeader" :text="currentBranch" icon="branch" + data-qa-selector="branch_selector_button" > <gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" /> <gl-dropdown-section-header> @@ -222,6 +229,7 @@ export default { :key="branch" :is-checked="currentBranch === branch" :is-check-item="true" + data-qa-selector="menu_branch_button" @click="selectBranch(branch)" > {{ branch }} diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index 368a026bdaa..6af3361e7e6 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -66,6 +66,7 @@ export default { }, data() { return { + commitSha: '', hasError: false, }; }, diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index d1534655a00..8bffd893473 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -87,7 +87,7 @@ export default { <template> <div> <template v-if="isLoading"> - <gl-loading-icon inline /> + <gl-loading-icon size="sm" inline /> {{ $options.i18n.loading }} </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 c3dcc00af6e..e463fcf379d 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -16,6 +16,7 @@ import { } from '../constants'; import getAppStatus from '../graphql/queries/client/app_status.graphql'; import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue'; +import CiEditorHeader from './editor/ci_editor_header.vue'; import TextEditor from './editor/text_editor.vue'; import CiLint from './lint/ci_lint.vue'; import EditorTab from './ui/editor_tab.vue'; @@ -49,6 +50,7 @@ export default { }, components: { CiConfigMergedPreview, + CiEditorHeader, CiLint, EditorTab, GlAlert, @@ -107,6 +109,7 @@ export default { data-testid="editor-tab" @click="setCurrentTab($options.tabConstants.CREATE_TAB)" > + <ci-editor-header /> <text-editor :value="ciFileContent" v-on="$listeners" /> </editor-tab> <editor-tab diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 1467abd7289..d05b06d16db 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -33,3 +33,13 @@ export const BRANCH_PAGINATION_LIMIT = 20; export const BRANCH_SEARCH_DEBOUNCE = '500'; export const STARTER_TEMPLATE_NAME = 'Getting-Started'; + +export const pipelineEditorTrackingOptions = { + label: 'pipeline_editor', + actions: { + browse_templates: 'browse_templates', + }, +}; + +export const TEMPLATE_REPOSITORY_URL = + 'https://gitlab.com/gitlab-org/gitlab-foss/tree/master/lib/gitlab/ci/templates'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql new file mode 100644 index 00000000000..dce17cad808 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateCommitSha($commitSha: String) { + updateCommitSha(commitSha: $commitSha) @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql index 9f1b5b13088..5500244b430 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql @@ -1,5 +1,11 @@ -query getBlobContent($projectPath: ID!, $path: String, $ref: String!) { - blobContent(projectPath: $projectPath, path: $path, ref: $ref) @client { - rawData +query getBlobContent($projectPath: ID!, $path: String!, $ref: String) { + project(fullPath: $projectPath) { + repository { + blobs(paths: [$path], ref: $ref) { + nodes { + rawBlob + } + } + } } } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql index 30c18a96536..df7de6a1f54 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql @@ -1,7 +1,7 @@ #import "~/pipelines/graphql/fragments/pipeline_stages_connection.fragment.graphql" -query getCiConfigData($projectPath: ID!, $content: String!) { - ciConfig(projectPath: $projectPath, content: $content) { +query getCiConfigData($projectPath: ID!, $sha: String, $content: String!) { + ciConfig(projectPath: $projectPath, sha: $sha, content: $content) { errors mergedYaml status diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql new file mode 100644 index 00000000000..219c23bb22b --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql @@ -0,0 +1,12 @@ +query getLatestCommitSha($projectPath: ID!, $ref: String) { + project(fullPath: $projectPath) { + pipelines(ref: $ref) { + nodes { + id + sha + path + commitPath + } + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index 8cead7f3315..2bec2006e95 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -1,20 +1,10 @@ import produce from 'immer'; -import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; +import getCommitShaQuery from './queries/client/commit_sha.graphql'; import getCurrentBranchQuery from './queries/client/current_branch.graphql'; import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql'; export const resolvers = { - Query: { - blobContent(_, { projectPath, path, ref }) { - return { - __typename: 'BlobContent', - rawData: Api.getRawFile(projectPath, path, { ref }).then(({ data }) => { - return data; - }), - }; - }, - }, Mutation: { lintCI: (_, { endpoint, content, dry_run }) => { return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({ @@ -42,7 +32,15 @@ export const resolvers = { __typename: 'CiLintContent', })); }, - updateCurrentBranch: (_, { currentBranch = undefined }, { cache }) => { + updateCommitSha: (_, { commitSha }, { cache }) => { + cache.writeQuery({ + query: getCommitShaQuery, + data: produce(cache.readQuery({ query: getCommitShaQuery }), (draftData) => { + draftData.commitSha = commitSha; + }), + }); + }, + updateCurrentBranch: (_, { currentBranch }, { cache }) => { cache.writeQuery({ query: getCurrentBranchQuery, data: produce(cache.readQuery({ query: getCurrentBranchQuery }), (draftData) => { @@ -50,7 +48,7 @@ export const resolvers = { }), }); }, - updateLastCommitBranch: (_, { lastCommitBranch = undefined }, { cache }) => { + updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => { cache.writeQuery({ query: getLastCommitBranchQuery, data: produce(cache.readQuery({ query: getLastCommitBranchQuery }), (draftData) => { diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index c24e6523352..0e8a6805a59 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { fetchPolicies } from '~/lib/graphql'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; @@ -16,12 +16,15 @@ import { LOAD_FAILURE_UNKNOWN, STARTER_TEMPLATE_NAME, } from './constants'; +import updateCommitShaMutation from './graphql/mutations/update_commit_sha.mutation.graphql'; import getBlobContent from './graphql/queries/blob_content.graphql'; import getCiConfigData from './graphql/queries/ci_config.graphql'; import getAppStatus from './graphql/queries/client/app_status.graphql'; +import getCommitSha from './graphql/queries/client/commit_sha.graphql'; import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql'; import getTemplate from './graphql/queries/get_starter_template.query.graphql'; +import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql'; import PipelineEditorHome from './pipeline_editor_home.vue'; export default { @@ -42,6 +45,7 @@ export default { }, data() { return { + starterTemplateName: STARTER_TEMPLATE_NAME, ciConfigData: {}, failureType: null, failureReasons: [], @@ -76,22 +80,40 @@ export default { }; }, update(data) { - return data?.blobContent?.rawData; + return data?.project?.repository?.blobs?.nodes[0]?.rawBlob; }, result({ data }) { - const fileContent = data?.blobContent?.rawData ?? ''; + const nodes = data?.project?.repository?.blobs?.nodes; + if (!nodes) { + this.reportFailure(LOAD_FAILURE_UNKNOWN); + } else { + const rawBlob = nodes[0]?.rawBlob; + const fileContent = rawBlob ?? ''; - this.lastCommittedContent = fileContent; - this.currentCiFileContent = fileContent; + this.lastCommittedContent = fileContent; + this.currentCiFileContent = fileContent; - // make sure to reset the start screen flag during a refetch - // e.g. when switching branches - if (fileContent.length) { - this.showStartScreen = false; + // If rawBlob is defined and returns a string, it means that there is + // a CI config file with empty content. If `rawBlob` is not defined + // at all, it means there was no file found. + const hasCIFile = rawBlob === '' || fileContent.length > 0; + + if (!fileContent.length) { + this.setAppStatus(EDITOR_APP_STATUS_EMPTY); + } + + if (!hasCIFile) { + this.showStartScreen = true; + } else if (fileContent.length) { + // If the file content is > 0, then we make sure to reset the + // start screen flag during a refetch + // e.g. when switching branches + this.showStartScreen = false; + } } }, - error(error) { - this.handleBlobContentError(error); + error() { + this.reportFailure(LOAD_FAILURE_UNKNOWN); }, watchLoading(isLoading) { if (isLoading) { @@ -107,6 +129,7 @@ export default { variables() { return { projectPath: this.projectFullPath, + sha: this.commitSha, content: this.currentCiFileContent, }; }, @@ -132,6 +155,9 @@ export default { appStatus: { query: getAppStatus, }, + commitSha: { + query: getCommitSha, + }, currentBranch: { query: getCurrentBranch, }, @@ -143,7 +169,7 @@ export default { variables() { return { projectPath: this.projectFullPath, - templateName: STARTER_TEMPLATE_NAME, + templateName: this.starterTemplateName, }; }, skip({ isNewCiConfigFile }) { @@ -186,23 +212,10 @@ export default { } }, }, + mounted() { + this.loadTemplateFromURL(); + }, methods: { - handleBlobContentError(error = {}) { - const { networkError } = error; - - const { response } = networkError; - // 404 for missing CI file - // 400 for blank projects with no repository - if ( - response?.status === httpStatusCodes.NOT_FOUND || - response?.status === httpStatusCodes.BAD_REQUEST - ) { - this.setAppStatus(EDITOR_APP_STATUS_EMPTY); - this.showStartScreen = true; - } else { - this.reportFailure(LOAD_FAILURE_UNKNOWN); - } - }, hideFailure() { this.showFailure = false; }, @@ -244,6 +257,38 @@ export default { updateCiConfig(ciFileContent) { this.currentCiFileContent = ciFileContent; }, + async updateCommitSha({ newBranch }) { + let fetchResults; + + try { + fetchResults = await this.$apollo.query({ + query: getLatestCommitShaQuery, + variables: { + projectPath: this.projectFullPath, + ref: newBranch, + }, + }); + } catch { + this.showFetchError(); + return; + } + + if (fetchResults.errors?.length > 0) { + this.showFetchError(); + return; + } + + const pipelineNodes = fetchResults?.data?.project?.pipelines?.nodes ?? []; + if (pipelineNodes.length === 0) { + return; + } + + const commitSha = pipelineNodes[0].sha; + this.$apollo.mutate({ + mutation: updateCommitShaMutation, + variables: { commitSha }, + }); + }, updateOnCommit({ type }) { this.reportSuccess(type); @@ -257,6 +302,14 @@ export default { // if the user has made changes to the file that are unsaved. this.lastCommittedContent = this.currentCiFileContent; }, + loadTemplateFromURL() { + const templateName = queryToObject(window.location.search)?.template; + + if (templateName) { + this.starterTemplateName = templateName; + this.setNewEmptyCiConfigFile(); + } + }, }, }; </script> @@ -288,6 +341,7 @@ export default { @showError="showErrorAlert" @refetchContent="refetchContent" @updateCiConfig="updateCiConfig" + @updateCommitSha="updateCommitSha" /> <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" /> </div> diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js index 91a064a0fb8..a6c9f3cb746 100644 --- a/app/assets/javascripts/pipeline_new/constants.js +++ b/app/assets/javascripts/pipeline_new/constants.js @@ -1,6 +1,8 @@ +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + export const VARIABLE_TYPE = 'env_var'; export const FILE_TYPE = 'file'; -export const DEBOUNCE_REFS_SEARCH_MS = 250; +export const DEBOUNCE_REFS_SEARCH_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export const CONFIG_VARIABLES_TIMEOUT = 5000; export const BRANCH_REF_TYPE = 'branch'; export const TAG_REF_TYPE = 'tag'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 71ec81b8969..ea45b5e3ec7 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -101,9 +101,6 @@ export default { showJobLinks() { return !this.isStageView && this.showLinks; }, - shouldShowStageName() { - return !this.isStageView; - }, // The show downstream check prevents showing redundant linked columns showDownstreamPipelines() { return ( @@ -165,8 +162,10 @@ export default { <div class="js-pipeline-graph"> <div ref="mainPipelineContainer" - class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap gl-border-t-solid gl-border-t-1 gl-border-gray-100" - :class="{ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline }" + class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap" + :class="{ + 'gl-pipeline-min-h gl-py-5 gl-overflow-auto gl-border-t-solid gl-border-t-1 gl-border-gray-100': !isLinkedPipeline, + }" > <linked-graph-wrapper> <template #upstream> @@ -202,11 +201,12 @@ export default { :groups="column.groups" :action="column.status.action" :highlighted-jobs="highlightedJobs" - :show-stage-name="shouldShowStageName" + :is-stage-view="isStageView" :job-hovered="hoveredJobName" :source-job-hovered="hoveredSourceJobName" :pipeline-expanded="pipelineExpanded" :pipeline-id="pipeline.id" + :user-permissions="pipeline.userPermissions" @refreshPipelineGraph="$emit('refreshPipelineGraph')" @jobHover="setJob" @updateMeasurements="getMeasurements" 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 fb45738f8d1..a948a57c144 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -105,7 +105,7 @@ export default { return this.pipeline; } - return unwrapPipelineData(this.pipelineProjectPath, data); + return unwrapPipelineData(this.pipelineProjectPath, JSON.parse(JSON.stringify(data))); }, error(err) { this.reportFailure({ type: LOAD_FAILURE, skipSentry: true }); @@ -114,7 +114,7 @@ export default { this.$options.name, `| type: ${LOAD_FAILURE} , info: ${serializeLoadErrors(err)}`, { - projectPath: this.projectPath, + projectPath: this.pipelineProjectPath, pipelineIid: this.pipelineIid, pipelineStages: this.pipeline?.stages?.length || 0, nbOfDownstreams: this.pipeline?.downstream?.length || 0, diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index b3c5af5418f..dd8a354511a 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -161,7 +161,7 @@ export default { :size="24" css-classes="gl-top-0 gl-pr-2" /> - <div v-else class="gl-pr-2"><gl-loading-icon inline /></div> + <div v-else class="gl-pr-2"><gl-loading-icon size="sm" inline /></div> <div class="gl-display-flex gl-flex-direction-column gl-w-13"> <span class="gl-text-truncate" data-testid="downstream-title"> {{ downstreamTitle }} diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 45113ecff41..52ee40bd982 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -118,7 +118,7 @@ export default { return this.currentPipeline; } - return unwrapPipelineData(projectPath, data); + return unwrapPipelineData(projectPath, JSON.parse(JSON.stringify(data))); }, result() { this.loadingPipelineId = null; 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 81d59f1ef65..d34ae8036ed 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -40,6 +40,11 @@ export default { required: false, default: () => [], }, + isStageView: { + type: Boolean, + required: false, + default: false, + }, jobHovered: { type: String, required: false, @@ -50,16 +55,15 @@ export default { required: false, default: () => ({}), }, - showStageName: { - type: Boolean, - required: false, - default: false, - }, sourceJobHovered: { type: String, required: false, default: '', }, + userPermissions: { + type: Object, + required: true, + }, }, titleClasses: [ 'gl-font-weight-bold', @@ -69,20 +73,11 @@ export default { 'gl-pl-3', ], computed: { - /* - currentGroups and filteredGroups are part of - a test to hunt down a bug - (see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57142). - - They should be removed when the bug is rectified. - */ - currentGroups() { - return this.glFeatures.pipelineFilterJobs ? this.filteredGroups : this.groups; + canUpdatePipeline() { + return this.userPermissions.updatePipeline; }, - filteredGroups() { - return this.groups.map((group) => { - return { ...group, jobs: group.jobs.filter(Boolean) }; - }); + columnSpacingClass() { + return this.isStageView ? 'gl-px-6' : 'gl-px-9'; }, formattedTitle() { return capitalize(escape(this.name)); @@ -90,6 +85,9 @@ export default { hasAction() { return !isEmpty(this.action); }, + showStageName() { + return !this.isStageView; + }, }, errorCaptured(err, _vm, info) { reportToSentry('stage_column_component', `error: ${err}, info: ${info}`); @@ -123,7 +121,7 @@ export default { }; </script> <template> - <main-graph-wrapper class="gl-px-6" data-testid="stage-column"> + <main-graph-wrapper :class="columnSpacingClass" data-testid="stage-column"> <template #stages> <div data-testid="stage-column-title" @@ -132,7 +130,7 @@ export default { > <div>{{ formattedTitle }}</div> <action-component - v-if="hasAction" + v-if="hasAction && canUpdatePipeline" :action-icon="action.icon" :tooltip-text="action.title" :link="action.path" @@ -143,7 +141,7 @@ export default { </template> <template #jobs> <div - v-for="group in currentGroups" + v-for="group in groups" :id="groupId(group)" :key="getGroupId(group)" data-testid="stage-column-group" diff --git a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js index 7c62acbe8de..83f2466f0bf 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js @@ -75,11 +75,11 @@ export const generateLinksData = ({ links }, containerID, modifier = '') => { // until we can safely draw the bezier to look nice. // The adjustment number here is a magic number to make things // look nice and should change if the padding changes. This goes well - // with gl-px-6. gl-px-8 is more like 100. - const straightLineDestinationX = targetNodeX - 60; + // with gl-px-9 which we translate with 100px here. + const straightLineDestinationX = targetNodeX - 100; const controlPointX = straightLineDestinationX + (targetNodeX - straightLineDestinationX) / 2; - if (straightLineDestinationX > 0) { + if (straightLineDestinationX > firstPointCoordinateX) { path.lineTo(straightLineDestinationX, sourceNodeY); } diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue index d19215e7895..efad43ddd4f 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue @@ -99,7 +99,7 @@ export default { class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" @click.stop="onClickAction" > - <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" /> + <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" /> <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" /> </gl-button> </template> diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index f1d9ced807b..b36c9c0d049 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -1,4 +1,4 @@ -import { isEqual, memoize, uniqWith } from 'lodash'; +import { memoize } from 'lodash'; import { createSankey } from './dag/drawing_utils'; /* @@ -113,11 +113,24 @@ export const filterByAncestors = (links, nodeDict) => return !allAncestors.includes(source); }); +/* + A peformant alternative to lodash's isEqual. Because findIndex always finds + the first instance of a match, if the found index is not the first, we know + it is in fact a duplicate. +*/ +const deduplicate = (item, itemIndex, arr) => { + const foundIdx = arr.findIndex((test) => { + return test.source === item.source && test.target === item.target; + }); + + return foundIdx === itemIndex; +}; + export const parseData = (nodes) => { const nodeDict = createNodeDict(nodes); const allLinks = makeLinksFromNodes(nodes, nodeDict); const filteredLinks = filterByAncestors(allLinks, nodeDict); - const links = uniqWith(filteredLinks, isEqual); + const links = filteredLinks.filter(deduplicate); return { nodes, links }; }; diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue index 01baf0a42d5..836333c8bde 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue @@ -14,7 +14,7 @@ export default { type: Number, required: true, }, - isHighlighted: { + isHovered: { type: Boolean, required: false, default: false, @@ -42,7 +42,7 @@ export default { jobPillClasses() { return [ { 'gl-opacity-3': this.isFadedOut }, - this.isHighlighted ? 'gl-shadow-blue-200-x0-y0-b4-s2' : 'gl-inset-border-2-green-400', + { 'gl-bg-gray-50 gl-inset-border-1-gray-200': this.isHovered }, ]; }, }, @@ -57,15 +57,17 @@ export default { }; </script> <template> - <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> - <div - :id="id" - class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" - :class="jobPillClasses" - @mouseover="onMouseEnter" - @mouseleave="onMouseLeave" - > - {{ jobName }} - </div> - </tooltip-on-truncate> + <div class="gl-w-full"> + <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> + <div + :id="id" + class="gl-bg-white gl-inset-border-1-gray-100 gl-text-center gl-text-truncate gl-rounded-6 gl-mb-3 gl-px-5 gl-py-3 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" + :class="jobPillClasses" + @mouseover="onMouseEnter" + @mouseleave="onMouseLeave" + > + {{ jobName }} + </div> + </tooltip-on-truncate> + </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 3ba0d7d0120..78771b6a072 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -4,14 +4,14 @@ import { __ } from '~/locale'; import { DRAW_FAILURE, DEFAULT } from '../../constants'; import LinksLayer from '../graph_shared/links_layer.vue'; import JobPill from './job_pill.vue'; -import StagePill from './stage_pill.vue'; +import StageName from './stage_name.vue'; export default { components: { GlAlert, JobPill, LinksLayer, - StagePill, + StageName, }, CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF', BASE_CONTAINER_ID: 'pipeline-graph-container', @@ -21,6 +21,11 @@ export default { [DRAW_FAILURE]: __('Could not draw the lines for job relationships'), [DEFAULT]: __('An unknown error occurred.'), }, + // The combination of gl-w-full gl-min-w-full and gl-max-w-15 is necessary. + // The max width and the width make sure the ellipsis to work and the min width + // is for when there is less text than the stage column width (which the width 100% does not fix) + jobWrapperClasses: + 'gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8 gl-min-w-full gl-max-w-15', props: { pipelineData: { required: true, @@ -85,23 +90,8 @@ export default { height: this.$refs[this.$options.CONTAINER_REF].scrollHeight, }; }, - getStageBackgroundClasses(index) { - const { length } = this.pipelineStages; - // It's possible for a graph to have only one stage, in which - // case we concatenate both the left and right rounding classes - if (length === 1) { - return 'gl-rounded-bottom-left-6 gl-rounded-top-left-6 gl-rounded-bottom-right-6 gl-rounded-top-right-6'; - } - - if (index === 0) { - return 'gl-rounded-bottom-left-6 gl-rounded-top-left-6'; - } - - if (index === length - 1) { - return 'gl-rounded-bottom-right-6 gl-rounded-top-right-6'; - } - - return ''; + isFadedOut(jobName) { + return this.highlightedJobs.length > 1 && !this.isJobHighlighted(jobName); }, isJobHighlighted(jobName) { return this.highlightedJobs.includes(jobName); @@ -137,7 +127,12 @@ export default { > {{ failure.text }} </gl-alert> - <div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container"> + <div + :id="containerId" + :ref="$options.CONTAINER_REF" + class="gl-bg-gray-10 gl-overflow-auto" + data-testid="graph-container" + > <links-layer :pipeline-data="pipelineStages" :pipeline-id="$options.PIPELINE_ID" @@ -152,23 +147,17 @@ export default { :key="`${stage.name}-${index}`" class="gl-flex-direction-column" > - <div - class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5" - :class="getStageBackgroundClasses(index)" - data-testid="stage-background" - > - <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" /> + <div class="gl-display-flex gl-align-items-center gl-w-full gl-px-9 gl-py-4 gl-mb-5"> + <stage-name :stage-name="stage.name" /> </div> - <div - class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8" - > + <div :class="$options.jobWrapperClasses"> <job-pill v-for="group in stage.groups" :key="group.name" :job-name="group.name" :pipeline-id="$options.PIPELINE_ID" - :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)" - :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)" + :is-hovered="highlightedJob === group.name" + :is-faded-out="isFadedOut(group.name)" @on-mouse-enter="setHoveredJob" @on-mouse-leave="removeHoveredJob" /> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue index df48426f24e..367a18af248 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue @@ -1,4 +1,5 @@ <script> +import { capitalize, escape } from 'lodash'; import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; export default { @@ -10,26 +11,18 @@ export default { type: String, required: true, }, - isEmpty: { - type: Boolean, - required: false, - default: false, - }, }, computed: { - emptyClass() { - return this.isEmpty ? 'gl-bg-gray-200' : 'gl-bg-gray-600'; + formattedTitle() { + return capitalize(escape(this.stageName)); }, }, }; </script> <template> <tooltip-on-truncate :title="stageName" truncate-target="child" placement="top"> - <div - class="gl-px-5 gl-py-2 gl-text-white gl-text-center gl-text-truncate gl-rounded-pill gl-w-20" - :class="emptyClass" - > - {{ stageName }} + <div class="gl-py-2 gl-text-truncate gl-font-weight-bold gl-w-20"> + {{ formattedTitle }} </div> </tooltip-on-truncate> </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 104a3caab4c..1ce6654e0e9 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -16,7 +16,6 @@ export default { consuming tasks, so you can spend more time creating.`), aboutRunnersBtnText: s__('Pipelines|Learn about Runners'), installRunnersBtnText: s__('Pipelines|Install GitLab Runners'), - getStartedBtnText: s__('Pipelines|Get started with CI/CD'), codeQualityTitle: s__('Pipelines|Improve code quality with GitLab CI/CD'), codeQualityDescription: s__(`Pipelines|To keep your codebase simple, readable, and accessible to contributors, use GitLab CI/CD @@ -55,9 +54,6 @@ export default { ciHelpPagePath() { return helpPagePath('ci/quick_start/index.md'); }, - isPipelineEmptyStateTemplatesExperimentActive() { - return this.canSetCi && Boolean(getExperimentData('pipeline_empty_state_templates')); - }, isCodeQualityExperimentActive() { return this.canSetCi && Boolean(getExperimentData('code_quality_walkthrough')); }, @@ -81,37 +77,8 @@ export default { </script> <template> <div> - <gitlab-experiment - v-if="isPipelineEmptyStateTemplatesExperimentActive" - name="pipeline_empty_state_templates" - > - <template #control> - <gl-empty-state - :title="$options.i18n.title" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.description" - :primary-button-text="$options.i18n.getStartedBtnText" - :primary-button-link="ciHelpPagePath" - /> - </template> - <template #candidate> - <pipelines-ci-templates /> - </template> - </gitlab-experiment> - <gitlab-experiment v-else-if="isCodeQualityExperimentActive" name="code_quality_walkthrough"> - <template #control> - <gl-empty-state - :title="$options.i18n.title" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.description" - > - <template #actions> - <gl-button :href="ciHelpPagePath" variant="confirm" @click="trackClick()"> - {{ $options.i18n.getStartedBtnText }} - </gl-button> - </template> - </gl-empty-state> - </template> + <gitlab-experiment v-if="isCodeQualityExperimentActive" name="code_quality_walkthrough"> + <template #control><pipelines-ci-templates /></template> <template #candidate> <gl-empty-state :title="$options.i18n.codeQualityTitle" @@ -127,23 +94,7 @@ export default { </template> </gitlab-experiment> <gitlab-experiment v-else-if="isCiRunnerTemplatesExperimentActive" name="ci_runner_templates"> - <template #control> - <gl-empty-state - :title="$options.i18n.title" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.description" - > - <template #actions> - <gl-button - :href="ciHelpPagePath" - variant="confirm" - @click="trackCiRunnerTemplatesClick('get_started_button_clicked')" - > - {{ $options.i18n.getStartedBtnText }} - </gl-button> - </template> - </gl-empty-state> - </template> + <template #control><pipelines-ci-templates /></template> <template #candidate> <gl-empty-state :title="$options.i18n.title" @@ -169,14 +120,7 @@ export default { </gl-empty-state> </template> </gitlab-experiment> - <gl-empty-state - v-else-if="canSetCi" - :title="$options.i18n.title" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.description" - :primary-button-text="$options.i18n.getStartedBtnText" - :primary-button-link="ciHelpPagePath" - /> + <pipelines-ci-templates v-else-if="canSetCi" /> <gl-empty-state v-else title="" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue index d7bd2d731b1..5e18f636b52 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue @@ -97,7 +97,7 @@ export default { {{ $options.i18n.artifactsFetchErrorMessage }} </gl-alert> - <gl-loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" size="sm" /> <gl-dropdown-item v-for="(artifact, i) in artifacts" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue index bf992b84387..7552ddb61dc 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue @@ -13,7 +13,7 @@ */ import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import eventHub from '../../event_hub'; @@ -83,7 +83,9 @@ export default { this.$refs.dropdown.hide(); this.isLoading = false; - Flash(__('Something went wrong on our end.')); + createFlash({ + message: __('Something went wrong on our end.'), + }); }); }, isDropdownOpen() { @@ -118,7 +120,7 @@ export default { <gl-icon :name="borderlessIcon" /> </span> </template> - <gl-loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" size="sm" /> <ul v-else class="js-builds-dropdown-list scrollable-menu" 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 52c8ef2cf26..fc8f31c5b7e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -60,19 +60,20 @@ export default { data-testid="pipeline-url-link" data-qa-selector="pipeline_url_link" > - <span class="pipeline-id">#{{ pipeline.id }}</span> + #{{ pipeline.id }} </gl-link> <div class="label-container"> - <gl-link v-if="isScheduled" :href="pipelineScheduleUrl" target="__blank"> - <gl-badge - v-gl-tooltip - :title="__('This pipeline was triggered by a schedule.')" - variant="info" - size="sm" - data-testid="pipeline-url-scheduled" - >{{ __('Scheduled') }}</gl-badge - > - </gl-link> + <gl-badge + v-if="isScheduled" + v-gl-tooltip + :href="pipelineScheduleUrl" + target="__blank" + :title="__('This pipeline was triggered by a schedule.')" + variant="info" + size="sm" + data-testid="pipeline-url-scheduled" + >{{ __('Scheduled') }}</gl-badge + > <gl-badge v-if="pipeline.flags.latest" v-gl-tooltip diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 8bb2657c161..e3373178239 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -2,7 +2,7 @@ import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { isEqual } from 'lodash'; import createFlash from '~/flash'; -import { getParameterByName } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 147fff52101..36629d9f1f1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue @@ -96,7 +96,7 @@ export default { {{ $options.i18n.artifactsFetchErrorMessage }} </gl-alert> - <gl-loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" size="sm" /> <gl-alert v-else-if="!hasArtifacts" variant="info" :dismissible="false"> {{ $options.i18n.noArtifacts }} diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue index c2ec8c57fd7..c6c81d5253b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue @@ -1,17 +1,19 @@ <script> -import { GlButton, GlCard, GlSprintf } from '@gitlab/ui'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { GlAvatar, GlButton, GlCard, GlSprintf } from '@gitlab/ui'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { s__, sprintf } from '~/locale'; -import { HELLO_WORLD_TEMPLATE_KEY } from '../../constants'; +import { STARTER_TEMPLATE_NAME } from '~/pipeline_editor/constants'; +import Tracking from '~/tracking'; export default { components: { + GlAvatar, GlButton, GlCard, GlSprintf, }, - HELLO_WORLD_TEMPLATE_KEY, + mixins: [Tracking.mixin()], + STARTER_TEMPLATE_NAME, i18n: { cta: s__('Pipelines|Use template'), testTemplates: { @@ -19,10 +21,10 @@ export default { subtitle: s__( 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.', ), - helloWorld: { - title: s__('Pipelines|“Hello world” with GitLab CI/CD'), + gettingStarted: { + title: s__('Pipelines|Get started with GitLab CI/CD'), description: s__( - 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a simple pipeline that runs a “Hello world” script.', + 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a basic 3 stage CI/CD pipeline.', ), }, }, @@ -34,31 +36,30 @@ export default { description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'), }, }, - inject: ['addCiYmlPath', 'suggestedCiTemplates'], + inject: ['pipelineEditorPath', 'suggestedCiTemplates'], data() { const templates = this.suggestedCiTemplates.map(({ name, logo }) => { return { name, logo, - link: mergeUrlParams({ template: name }, this.addCiYmlPath), + link: mergeUrlParams({ template: name }, this.pipelineEditorPath), description: sprintf(this.$options.i18n.templates.description, { name }), }; }); return { templates, - helloWorldTemplateUrl: mergeUrlParams( - { template: HELLO_WORLD_TEMPLATE_KEY }, - this.addCiYmlPath, + gettingStartedTemplateUrl: mergeUrlParams( + { template: STARTER_TEMPLATE_NAME }, + this.pipelineEditorPath, ), }; }, methods: { trackEvent(template) { - const tracking = new ExperimentTracking('pipeline_empty_state_templates', { + this.track('template_clicked', { label: template, }); - tracking.event('template_clicked'); }, }, }; @@ -81,18 +82,18 @@ export default { <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> <div class="gl-mb-3"> <strong class="gl-text-gray-800 gl-mb-2">{{ - $options.i18n.testTemplates.helloWorld.title + $options.i18n.testTemplates.gettingStarted.title }}</strong> </div> - <p class="gl-font-sm">{{ $options.i18n.testTemplates.helloWorld.description }}</p> + <p class="gl-font-sm">{{ $options.i18n.testTemplates.gettingStarted.description }}</p> </div> <gl-button category="primary" variant="confirm" - :href="helloWorldTemplateUrl" + :href="gettingStartedTemplateUrl" data-testid="test-template-link" - @click="trackEvent($options.HELLO_WORLD_TEMPLATE_KEY)" + @click="trackEvent($options.STARTER_TEMPLATE_NAME)" > {{ $options.i18n.cta }} </gl-button> @@ -109,11 +110,12 @@ export default { 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"> - <img - width="64" - height="64" + <gl-avatar :src="template.logo" - class="gl-mr-6" + :size="64" + class="gl-mr-6 gl-bg-white dark-mode-override" + shape="rect" + :alt="template.name" data-testid="template-logo" /> <div class="gl-flex-direction-row"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue index 15ff7da35e1..5409e68cdc4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue @@ -60,7 +60,7 @@ export default { @input="searchBranches" > <template #suggestions> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <template v-else> <gl-filtered-search-suggestion v-for="(branch, index) in branches" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue index af62c492748..afcdd63b664 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue @@ -55,7 +55,7 @@ export default { <template> <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" @input="searchTags"> <template #suggestions> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <template v-else> <gl-filtered-search-suggestion v-for="(tag, index) in tags" :key="index" :value="tag"> {{ tag }} 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 bc661f37493..33115d72b9c 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 @@ -98,7 +98,7 @@ export default { }}</gl-filtered-search-suggestion> <gl-dropdown-divider /> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <template v-else> <gl-filtered-search-suggestion v-for="user in users" diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 01705e7726f..21b114825a6 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -35,6 +35,3 @@ export const POST_FAILURE = 'post_failure'; export const UNSUPPORTED_DATA = 'unsupported_data'; export const CHILD_VIEW = 'child'; - -// The key of the template is the same as the filename -export const HELLO_WORLD_TEMPLATE_KEY = 'Hello-World'; diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js index 9f15b6c4ae3..5c34f4e4f7e 100644 --- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; export default { @@ -13,7 +13,9 @@ export default { }) .catch(() => { this.mediator.store.toggleLoading(pipeline); - flash(__('An error occurred while fetching the pipeline.')); + createFlash({ + message: __('An error occurred while fetching the pipeline.'), + }); }); }, /** @@ -53,9 +55,11 @@ export default { requestRefreshPipelineGraph() { // When an action is clicked // (whether in the dropdown or in the main nodes, we refresh the big graph) - this.mediator - .refreshPipeline() - .catch(() => flash(__('An error occurred while making the request.'))); + this.mediator.refreshPipeline().catch(() => + createFlash({ + message: __('An error occurred while making the request.'), + }), + ); }, }, }; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 9ab4753fec8..e8d5ed175ba 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { parseBoolean } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import Translate from '~/vue_shared/translate'; @@ -96,14 +96,18 @@ export default async function initPipelineDetailsBundle() { try { createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag); } catch { - Flash(__('An error occurred while loading a section of this page.')); + createFlash({ + message: __('An error occurred while loading a section of this page.'), + }); } if (canShowNewPipelineDetails) { try { createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); } catch { - Flash(__('An error occurred while loading the pipeline.')); + createFlash({ + message: __('An error occurred while loading the pipeline.'), + }); } } else { const { default: PipelinesMediator } = await import( diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index 09637c25654..72c4fedc64c 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -1,5 +1,5 @@ import Visibility from 'visibilityjs'; -import { deprecatedCreateFlash as Flash } from '../flash'; +import createFlash from '~/flash'; import Poll from '../lib/utils/poll'; import { __ } from '../locale'; import PipelineService from './services/pipeline_service'; @@ -47,7 +47,9 @@ export default class pipelinesMediator { errorCallback() { this.state.isLoading = false; - Flash(__('An error occurred while fetching the pipeline.')); + createFlash({ + message: __('An error occurred while fetching the pipeline.'), + }); } refreshPipeline() { diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js index c3be487caae..7a922acd0b3 100644 --- a/app/assets/javascripts/pipelines/pipeline_shared_client.js +++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js @@ -5,6 +5,7 @@ export const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( {}, { + assumeImmutableResults: true, useGet: true, }, ), diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 925a96ea1aa..c4c2b5f2927 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -29,7 +29,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { errorStateSvgPath, noPipelinesSvgPath, newPipelinePath, - addCiYmlPath, + pipelineEditorPath, suggestedCiTemplates, canCreatePipeline, hasGitlabCi, @@ -44,7 +44,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { return new Vue({ el, provide: { - addCiYmlPath, + pipelineEditorPath, artifactsEndpoint, artifactsEndpointPlaceholder, suggestedCiTemplates: JSON.parse(suggestedCiTemplates), diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index 07d8f3cc5f1..a0129dd536b 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -131,7 +131,8 @@ export default { <div class="col-lg-8"> <div class="form-group"> <gl-button - variant="success" + category="primary" + variant="confirm" name="commit" type="submit" :disabled="!isSubmitEnabled" diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index dad2c18fb18..c49ade2bbb8 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,11 +1,11 @@ import $ from 'jquery'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { parseBoolean } from '~/lib/utils/common_utils'; import { Rails } from '~/lib/utils/rails_ujs'; import TimezoneDropdown, { formatTimezone, } from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; -import { deprecatedCreateFlash as flash } from '../flash'; export default class Profile { constructor({ form } = {}) { @@ -83,14 +83,21 @@ export default class Profile { this.updateHeaderAvatar(); } - flash(data.message, 'notice'); + createFlash({ + message: data.message, + type: 'notice', + }); }) .then(() => { window.scrollTo(0, 0); // Enable submit button after requests ends self.form.find(':input[disabled]').enable(); }) - .catch((error) => flash(error.message)); + .catch((error) => + createFlash({ + message: error.message, + }), + ); } updateHeaderAvatar() { diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index f44661cb139..d295c06928f 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -2,7 +2,7 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import $ from 'jquery'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; @@ -88,7 +88,11 @@ export default class ProjectFindFile { this.findFile(); this.element.find('.files-slider tr.tree-item').eq(0).addClass('selected').focus(); }) - .catch(() => flash(__('An error occurred while loading filenames'))); + .catch(() => + createFlash({ + message: __('An error occurred while loading filenames'), + }), + ); } // render result diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js index e6dd4145cb8..f7804c2faa4 100644 --- a/app/assets/javascripts/project_label_subscription.js +++ b/app/assets/javascripts/project_label_subscription.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { fixTitle } from '~/tooltips'; -import { deprecatedCreateFlash as flash } from './flash'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; @@ -60,7 +60,11 @@ export default class ProjectLabelSubscription { return button; }); }) - .catch(() => flash(__('There was an error subscribing to this label.'))); + .catch(() => + createFlash({ + message: __('There was an error subscribing to this label.'), + }), + ); } static setNewTitle($button, originalTitle, newStatus) { diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue index cc5bc703994..52da8aaba4d 100644 --- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue @@ -99,7 +99,7 @@ export default { {{ branch }} </gl-dropdown-item> <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon"> - <gl-loading-icon class="gl-mx-auto" /> + <gl-loading-icon size="sm" class="gl-mx-auto" /> </gl-dropdown-text> <gl-dropdown-text v-if="!filteredResults.length && !isFetching" diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue index 1566232751d..c8a0a3417f3 100644 --- a/app/assets/javascripts/projects/commits/components/author_select.vue +++ b/app/assets/javascripts/projects/commits/components/author_select.vue @@ -9,8 +9,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; import { mapState, mapActions } from 'vuex'; -import { urlParamsToObject } from '~/lib/utils/common_utils'; -import { redirectTo } from '~/lib/utils/url_utility'; +import { redirectTo, queryToObject } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; const tooltipMessage = __('Searching by both author and message is currently not supported.'); @@ -52,7 +51,7 @@ export default { }, mounted() { this.fetchAuthors(); - const params = urlParamsToObject(window.location.search); + const params = queryToObject(window.location.search); const { search: searchParam, author: authorParam } = params; const commitsSearchInput = this.projectCommitsEl.querySelector('#commits-search'); diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue index 81d23a563e2..06711e4025a 100644 --- a/app/assets/javascripts/projects/components/project_delete_button.vue +++ b/app/assets/javascripts/projects/components/project_delete_button.vue @@ -24,9 +24,6 @@ export default { alertBody: __( 'Once a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.', ), - modalBody: __( - "This action cannot be undone. You will lose this project's repository and all related resources, including issues, merge requests, etc.", - ), }, }; </script> @@ -46,7 +43,6 @@ export default { </template> </gl-sprintf> </gl-alert> - <p>{{ $options.strings.modalBody }}</p> </template> </shared-delete-button> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue index 1c4413bef71..0b0560f63c1 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue @@ -225,11 +225,21 @@ export default { { name: 'success', data: this.mergeLabelsAndValues(labels, success), + areaStyle: { + color: this.$options.successColor, + }, + lineStyle: { + color: this.$options.successColor, + }, + itemStyle: { + color: this.$options.successColor, + }, }, ], }; }, }, + successColor: '#608b2f', chartContainerHeight: CHART_CONTAINER_HEIGHT, timesChartOptions: { height: INNER_CHART_HEIGHT, diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 04ea6f760f6..ee02f446795 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -74,6 +74,7 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => { const bindEvents = () => { const $newProjectForm = $('#new_project'); const $projectImportUrl = $('#project_import_url'); + const $projectImportUrlWarning = $('.js-import-url-warning'); const $projectPath = $('.tab-pane.active #project_path'); const $useTemplateBtn = $('.template-button > input'); const $projectFieldsForm = $('.project-fields-form'); @@ -134,7 +135,25 @@ const bindEvents = () => { $projectPath.val($projectPath.val().trim()); }); - $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl)); + function updateUrlPathWarningVisibility() { + const url = $projectImportUrl.val(); + const URL_PATTERN = /(?:git|https?):\/\/.*\/.*\.git$/; + const isUrlValid = URL_PATTERN.test(url); + $projectImportUrlWarning.toggleClass('hide', isUrlValid); + } + + let isProjectImportUrlDirty = false; + $projectImportUrl.on('blur', () => { + isProjectImportUrlDirty = true; + updateUrlPathWarningVisibility(); + }); + $projectImportUrl.on('keyup', () => { + deriveProjectPathFromUrl($projectImportUrl); + // defer error message till first input blur + if (isProjectImportUrlDirty) { + updateUrlPathWarningVisibility(); + } + }); $('.js-import-git-toggle-button').on('click', () => { const $projectMirror = $('#project_mirror'); diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue index 0786a74f6b1..e4edb950a1e 100644 --- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue +++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue @@ -1,15 +1,23 @@ <script> import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; const DEFAULT_ERROR_MESSAGE = __('An error occurred while updating the configuration.'); +const REQUIRES_VALIDATION_TEXT = s__( + `Billings|Shared runners cannot be enabled until a valid credit card is on file.`, +); export default { + i18n: { + REQUIRES_VALIDATION_TEXT, + }, components: { GlAlert, GlToggle, GlTooltip, + CcValidationRequiredAlert: () => + import('ee_component/billings/components/cc_validation_required_alert.vue'), }, props: { isDisabledAndUnoverridable: { @@ -20,6 +28,10 @@ export default { type: Boolean, required: true, }, + isCreditCardValidationRequired: { + type: Boolean, + required: false, + }, updatePath: { type: String, required: true, @@ -28,14 +40,24 @@ export default { data() { return { isLoading: false, - isSharedRunnerEnabled: false, + isSharedRunnerEnabled: this.isEnabled, errorMessage: null, + successfulValidation: false, }; }, - created() { - this.isSharedRunnerEnabled = this.isEnabled; + computed: { + showCreditCardValidation() { + return ( + this.isCreditCardValidationRequired && + !this.isSharedRunnerEnabled && + !this.successfulValidation + ); + }, }, methods: { + creditCardValidated() { + this.successfulValidation = true; + }, toggleSharedRunners() { this.isLoading = true; this.errorMessage = null; @@ -61,16 +83,25 @@ export default { <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" :dismissible="false"> {{ errorMessage }} </gl-alert> - <div ref="sharedRunnersToggle"> - <gl-toggle - :disabled="isDisabledAndUnoverridable" - :is-loading="isLoading" - :label="__('Enable shared runners for this project')" - :value="isSharedRunnerEnabled" - data-testid="toggle-shared-runners" - @change="toggleSharedRunners" - /> - </div> + + <cc-validation-required-alert + v-if="showCreditCardValidation" + class="gl-pb-5" + :custom-message="$options.i18n.REQUIRES_VALIDATION_TEXT" + @verifiedCreditCard="creditCardValidated" + /> + + <gl-toggle + v-else + ref="sharedRunnersToggle" + :disabled="isDisabledAndUnoverridable" + :is-loading="isLoading" + :label="__('Enable shared runners for this project')" + :value="isSharedRunnerEnabled" + data-testid="toggle-shared-runners" + @change="toggleSharedRunners" + /> + <gl-tooltip v-if="isDisabledAndUnoverridable" :target="() => $refs.sharedRunnersToggle"> {{ __('Shared runners are disabled on group level') }} </gl-tooltip> diff --git a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js index eaeb5848b68..5ca864a412b 100644 --- a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js +++ b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js @@ -4,7 +4,12 @@ import SharedRunnersToggle from '~/projects/settings/components/shared_runners_t export default (containerId = 'toggle-shared-runners-form') => { const containerEl = document.getElementById(containerId); - const { isDisabledAndUnoverridable, isEnabled, updatePath } = containerEl.dataset; + const { + isDisabledAndUnoverridable, + isEnabled, + updatePath, + isCreditCardValidationRequired, + } = containerEl.dataset; return new Vue({ el: containerEl, @@ -13,6 +18,7 @@ export default (containerId = 'toggle-shared-runners-form') => { props: { isDisabledAndUnoverridable: parseBoolean(isDisabledAndUnoverridable), isEnabled: parseBoolean(isEnabled), + isCreditCardValidationRequired: parseBoolean(isCreditCardValidationRequired), updatePath, }, }); diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index fb00f58abae..4c083ed5496 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert } from '@gitlab/ui'; +import { GlAlert, GlSafeHtmlDirective } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; import ServiceDeskSetting from './service_desk_setting.vue'; @@ -9,6 +9,9 @@ export default { GlAlert, ServiceDeskSetting, }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, inject: { initialIsEnabled: { default: false, @@ -121,7 +124,7 @@ export default { <template> <div> <gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss"> - {{ alertMessage }} + <span v-safe-html="alertMessage"></span> </gl-alert> <service-desk-setting :is-enabled="isEnabled" diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index 3294a37c26a..34d53e2de0c 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -144,7 +144,7 @@ export default { </span> </template> <template v-else> - <gl-loading-icon :inline="true" /> + <gl-loading-icon size="sm" :inline="true" /> <span class="sr-only">{{ __('Fetching incoming email') }}</span> </template> diff --git a/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue b/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue new file mode 100644 index 00000000000..0b398eddc9c --- /dev/null +++ b/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue @@ -0,0 +1,65 @@ +<script> +import { GlBanner } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { parseBoolean, setCookie, getCookie } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; + +export default { + name: 'TerraformNotification', + i18n: { + title: s__('TerraformBanner|Using Terraform? Try the GitLab Managed Terraform State'), + description: s__( + 'TerraformBanner|The GitLab managed Terraform state backend can store your Terraform state easily and securely, and spares you from setting up additional remote resources. Its features include: versioning, encryption of the state file both in transit and at rest, locking, and remote Terraform plan/apply execution.', + ), + buttonText: s__("TerraformBanner|Learn more about GitLab's Backend State"), + }, + components: { + GlBanner, + }, + props: { + projectId: { + type: Number, + required: true, + }, + }, + data() { + return { + isVisible: true, + }; + }, + computed: { + bannerDissmisedKey() { + return `terraform_notification_dismissed_for_project_${this.projectId}`; + }, + docsUrl() { + return helpPagePath('user/infrastructure/terraform_state'); + }, + }, + created() { + if (parseBoolean(getCookie(this.bannerDissmisedKey))) { + this.isVisible = false; + } + }, + methods: { + handleClose() { + setCookie(this.bannerDissmisedKey, true); + this.isVisible = false; + }, + }, +}; +</script> +<template> + <div v-if="isVisible"> + <div class="gl-py-5"> + <gl-banner + :title="$options.i18n.title" + :button-text="$options.i18n.buttonText" + :button-link="docsUrl" + variant="introduction" + @close="handleClose" + > + <p>{{ $options.i18n.description }}</p> + </gl-banner> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/terraform_notification/index.js b/app/assets/javascripts/projects/terraform_notification/index.js new file mode 100644 index 00000000000..eb04f109a8e --- /dev/null +++ b/app/assets/javascripts/projects/terraform_notification/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import TerraformNotification from './components/terraform_notification.vue'; + +export default () => { + const el = document.querySelector('.js-terraform-notification'); + + if (!el) { + return false; + } + + const { projectId } = el.dataset; + + return new Vue({ + el, + render: (createElement) => + createElement(TerraformNotification, { props: { projectId: Number(projectId) } }), + }); +}; diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index f3d12e0dd00..f6f409873c8 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import Visibility from 'visibilityjs'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; import { __, s__, sprintf } from '~/locale'; import ciIcon from '~/vue_shared/components/ci_icon.vue'; @@ -57,7 +57,9 @@ export default { group: 'notfound', }; this.isLoading = false; - Flash(s__('Something went wrong on our end')); + createFlash({ + message: s__('Something went wrong on our end'), + }); }, initPolling() { this.poll = new Poll({ diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 726ddba1014..d0d2c1400a7 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import CreateItemDropdown from '~/create_item_dropdown'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import AccessorUtilities from '~/lib/utils/accessor'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -135,6 +135,10 @@ export default class ProtectedBranchCreate { .then(() => { window.location.reload(); }) - .catch(() => Flash(__('Failed to protect the branch'))); + .catch(() => + createFlash({ + message: __('Failed to protect the branch'), + }), + ); } } diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js index ae7855d4638..1fe9a753e1e 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit.js +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '../flash'; +import createFlash from '~/flash'; import axios from '../lib/utils/axios_utils'; import { FAILED_TO_UPDATE_TAG_MESSAGE } from './constants'; import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; @@ -49,7 +49,9 @@ export default class ProtectedTagEdit { this.$allowedToCreateDropdownButton.enable(); window.scrollTo({ top: 0, behavior: 'smooth' }); - flash(FAILED_TO_UPDATE_TAG_MESSAGE); + createFlash({ + message: FAILED_TO_UPDATE_TAG_MESSAGE, + }); }); } } diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js index 44d0f50b832..1cef986a83d 100644 --- a/app/assets/javascripts/ref/constants.js +++ b/app/assets/javascripts/ref/constants.js @@ -1,3 +1,4 @@ +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __ } from '~/locale'; export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES'; @@ -7,7 +8,7 @@ export const ALL_REF_TYPES = Object.freeze([REF_TYPE_BRANCHES, REF_TYPE_TAGS, RE export const X_TOTAL_HEADER = 'x-total'; -export const SEARCH_DEBOUNCE_MS = 250; +export const SEARCH_DEBOUNCE_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export const DEFAULT_I18N = Object.freeze({ dropdownHeader: __('Select Git revision'), diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue new file mode 100644 index 00000000000..8d9e221af4c --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue @@ -0,0 +1,71 @@ +<script> +import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CLEANUP_STATUS_SCHEDULED, + CLEANUP_STATUS_ONGOING, + CLEANUP_STATUS_UNFINISHED, + UNFINISHED_STATUS, + UNSCHEDULED_STATUS, + SCHEDULED_STATUS, + ONGOING_STATUS, +} from '../../constants/index'; + +export default { + name: 'CleanupStatus', + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + status: { + type: String, + required: true, + validator(value) { + return [UNFINISHED_STATUS, UNSCHEDULED_STATUS, SCHEDULED_STATUS, ONGOING_STATUS].includes( + value, + ); + }, + }, + }, + i18n: { + CLEANUP_STATUS_SCHEDULED, + CLEANUP_STATUS_ONGOING, + CLEANUP_STATUS_UNFINISHED, + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + }, + computed: { + showStatus() { + return this.status !== UNSCHEDULED_STATUS; + }, + failedDelete() { + return this.status === UNFINISHED_STATUS; + }, + statusText() { + return this.$options.i18n[`CLEANUP_STATUS_${this.status}`]; + }, + expireIconClass() { + return this.failedDelete ? 'gl-text-orange-500' : ''; + }, + }, +}; +</script> + +<template> + <div v-if="showStatus" class="gl-display-inline-flex gl-align-items-center"> + <gl-icon name="expire" data-testid="main-icon" :class="expireIconClass" /> + <span class="gl-mx-2"> + {{ statusText }} + </span> + <gl-icon + v-if="failedDelete" + v-gl-tooltip="{ title: $options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE }" + :size="14" + class="gl-text-black-normal" + data-testid="extra-info" + name="information" + /> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue index 930ad01c758..c1ec523574a 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -16,6 +16,7 @@ import { ROOT_IMAGE_TEXT, } from '../../constants/index'; import DeleteButton from '../delete_button.vue'; +import CleanupStatus from './cleanup_status.vue'; export default { name: 'ImageListRow', @@ -26,6 +27,7 @@ export default { GlIcon, ListItem, GlSkeletonLoader, + CleanupStatus, }, directives: { GlTooltip: GlTooltipDirective, @@ -112,27 +114,24 @@ export default { :title="item.location" category="tertiary" /> - <gl-icon - v-if="warningIconText" - v-gl-tooltip="{ title: warningIconText }" - data-testid="warning-icon" - name="warning" - class="gl-text-orange-500" - /> </template> <template #left-secondary> - <span - v-if="!metadataLoading" - class="gl-display-flex gl-align-items-center" - data-testid="tags-count" - > - <gl-icon name="tag" class="gl-mr-2" /> - <gl-sprintf :message="tagsCountText"> - <template #count> - {{ item.tagsCount }} - </template> - </gl-sprintf> - </span> + <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="tagsCountText"> + <template #count> + {{ item.tagsCount }} + </template> + </gl-sprintf> + </span> + + <cleanup-status + v-if="item.expirationPolicyCleanupStatus" + class="ml-2" + :status="item.expirationPolicyCleanupStatus" + /> + </template> <div v-else class="gl-w-full"> <gl-skeleton-loader :width="900" :height="16" preserve-aspect-ratio="xMinYMax meet"> diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index 5dcc042a9c4..9b4c06349e2 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -89,6 +89,10 @@ export const CLEANUP_DISABLED_TOOLTIP = s__( 'ContainerRegistry|Cleanup is disabled for this project', ); +export const CLEANUP_STATUS_SCHEDULED = s__('ContainerRegistry|Cleanup will run soon'); +export const CLEANUP_STATUS_ONGOING = s__('ContainerRegistry|Cleanup is ongoing'); +export const CLEANUP_STATUS_UNFINISHED = s__('ContainerRegistry|Cleanup timed out'); + export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while scheduling the image for deletion.', ); 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 825a4a02b71..8f486fb1b07 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import Sortable from 'sortablejs'; -import sortableConfig from 'ee_else_ce/sortable/sortable_config'; +import sortableConfig from '~/sortable/sortable_config'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; export default { @@ -102,7 +102,12 @@ export default { class="related-issues-loading-icon" data-qa-selector="related_issues_loading_placeholder" > - <gl-loading-icon ref="loadingIcon" label="Fetching linked issues" class="gl-mt-2" /> + <gl-loading-icon + ref="loadingIcon" + size="sm" + label="Fetching linked issues" + class="gl-mt-2" + /> </div> <ul ref="list" :class="{ 'content-list': !canReorder }" class="related-items-list"> <li diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue index ccb92d2aedc..6fb1d1ed365 100644 --- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue @@ -94,7 +94,7 @@ export default { </div> <div> <div v-if="isFetchingMergeRequests" class="qa-related-merge-requests-loading-icon"> - <gl-loading-icon label="Fetching related merge requests" class="py-2" /> + <gl-loading-icon size="sm" label="Fetching related merge requests" class="py-2" /> </div> <ul v-else class="content-list related-items-list"> <li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0"> diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 3774f97a060..39140216bc5 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -1,8 +1,7 @@ <script> import { GlButton, GlFormInput, GlFormGroup, GlSprintf } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; -import { getParameterByName } from '~/lib/utils/common_utils'; -import { isSameOriginUrl } from '~/lib/utils/url_utility'; +import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index 31d335fa15d..c2c91f406a1 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -1,7 +1,7 @@ <script> import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import { getParameterByName } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import ReleaseBlock from './release_block.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; diff --git a/app/assets/javascripts/releases/components/app_index_apollo_client.vue b/app/assets/javascripts/releases/components/app_index_apollo_client.vue index ea0aa409577..f49c44a399f 100644 --- a/app/assets/javascripts/releases/components/app_index_apollo_client.vue +++ b/app/assets/javascripts/releases/components/app_index_apollo_client.vue @@ -1,12 +1,12 @@ <script> import { GlButton } from '@gitlab/ui'; +import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql'; import createFlash from '~/flash'; -import { historyPushState, getParameterByName } from '~/lib/utils/common_utils'; +import { historyPushState } from '~/lib/utils/common_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; -import { setUrlParams } from '~/lib/utils/url_utility'; +import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants'; -import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; import { convertAllReleasesGraphQLResponse } from '~/releases/util'; import ReleaseBlock from './release_block.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql index 3a742db7d9e..3a927dfc756 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql @@ -1,4 +1,5 @@ fragment Release on Release { + __typename name tagName tagPath @@ -7,15 +8,20 @@ fragment Release on Release { createdAt upcomingRelease assets { + __typename count sources { + __typename nodes { + __typename format url } } links { + __typename nodes { + __typename id name url @@ -26,13 +32,16 @@ fragment Release on Release { } } evidences { + __typename nodes { + __typename filepath collectedAt sha } } links { + __typename editUrl selfUrl openedIssuesUrl @@ -42,22 +51,27 @@ fragment Release on Release { closedMergeRequestsUrl } commit { + __typename sha webUrl title } author { + __typename webUrl avatarUrl username } milestones { + __typename nodes { + __typename id title description webPath stats { + __typename totalIssuesCount closedIssuesCount } diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql index 47c5afefd78..75a73acb9ae 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql @@ -9,6 +9,7 @@ fragment ReleaseForEditing on Release { name url linkType + directAssetPath } } } 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 10e4d883e62..f2d89dbe682 100644 --- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql @@ -1,5 +1,11 @@ #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 allReleases( $fullPath: ID! $first: Int @@ -9,11 +15,14 @@ query allReleases( $sort: ReleaseSort ) { project(fullPath: $fullPath) { + __typename releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) { + __typename nodes { ...Release } pageInfo { + __typename startCursor hasPreviousPage hasNextPage diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index 5955ec3352e..576f099248e 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -165,6 +165,7 @@ const createReleaseLink = async ({ state, link }) => { name: link.name, url: link.url, linkType: link.linkType.toUpperCase(), + directAssetPath: link.directAssetPath, }, }, }); diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js index 56f46a3938e..6014d9d6ad8 100644 --- a/app/assets/javascripts/reports/components/issue_body.js +++ b/app/assets/javascripts/reports/components/issue_body.js @@ -1,3 +1,4 @@ +import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; import AccessibilityIssueBody from '../accessibility_report/components/accessibility_issue_body.vue'; import CodequalityIssueBody from '../codequality_report/components/codequality_issue_body.vue'; import TestIssueBody from '../grouped_test_report/components/test_issue_body.vue'; @@ -13,3 +14,11 @@ export const componentNames = { CodequalityIssueBody: CodequalityIssueBody.name, TestIssueBody: TestIssueBody.name, }; + +export const iconComponents = { + IssueStatusIcon, +}; + +export const iconComponentNames = { + IssueStatusIcon: IssueStatusIcon.name, +}; diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index df20d5c19ba..8871da8fbd7 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -1,12 +1,16 @@ <script> -import { components, componentNames } from 'ee_else_ce/reports/components/issue_body'; -import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; +import { + components, + componentNames, + iconComponents, + iconComponentNames, +} from 'ee_else_ce/reports/components/issue_body'; export default { name: 'ReportItem', components: { - IssueStatusIcon, ...components, + ...iconComponents, }, props: { issue: { @@ -19,6 +23,12 @@ export default { default: '', validator: (value) => value === '' || Object.values(componentNames).includes(value), }, + iconComponent: { + type: String, + required: false, + default: iconComponentNames.IssueStatusIcon, + validator: (value) => Object.values(iconComponentNames).includes(value), + }, // failed || success status: { type: String, @@ -48,11 +58,12 @@ export default { class="report-block-list-issue align-items-center" data-qa-selector="report_item_row" > - <issue-status-icon + <component + :is="iconComponent" v-if="showReportSectionStatusIcon" :status="status" :status-icon-size="statusIconSize" - class="gl-mr-3" + class="gl-mr-2" /> <component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" /> diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index acd90ebf1b1..7f7ea2adc0e 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -16,6 +16,7 @@ export const STATUS_NEUTRAL = 'neutral'; export const ICON_WARNING = 'warning'; export const ICON_SUCCESS = 'success'; export const ICON_NOTFOUND = 'notfound'; +export const ICON_PENDING = 'pending'; export const status = { LOADING, diff --git a/app/assets/javascripts/repository/components/blob_replace.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index 91d7811eb6d..273825b996a 100644 --- a/app/assets/javascripts/repository/components/blob_replace.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -1,18 +1,22 @@ <script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { sprintf, __ } from '~/locale'; import getRefMixin from '../mixins/get_ref'; +import DeleteBlobModal from './delete_blob_modal.vue'; import UploadBlobModal from './upload_blob_modal.vue'; export default { i18n: { replace: __('Replace'), replacePrimaryBtnText: __('Replace file'), + delete: __('Delete'), }, components: { + GlButtonGroup, GlButton, UploadBlobModal, + DeleteBlobModal, }, directives: { GlModal: GlModalDirective, @@ -39,31 +43,50 @@ export default { type: String, required: true, }, + deletePath: { + type: String, + required: true, + }, canPushCode: { type: Boolean, required: true, }, + emptyRepo: { + type: Boolean, + required: true, + }, }, computed: { replaceModalId() { return uniqueId('replace-modal'); }, - title() { + replaceModalTitle() { return sprintf(__('Replace %{name}'), { name: this.name }); }, + deleteModalId() { + return uniqueId('delete-modal'); + }, + deleteModalTitle() { + return sprintf(__('Delete %{name}'), { name: this.name }); + }, }, }; </script> <template> <div class="gl-mr-3"> - <gl-button v-gl-modal="replaceModalId"> - {{ $options.i18n.replace }} - </gl-button> + <gl-button-group> + <gl-button v-gl-modal="replaceModalId"> + {{ $options.i18n.replace }} + </gl-button> + <gl-button v-gl-modal="deleteModalId"> + {{ $options.i18n.delete }} + </gl-button> + </gl-button-group> <upload-blob-modal :modal-id="replaceModalId" - :modal-title="title" - :commit-message="title" + :modal-title="replaceModalTitle" + :commit-message="replaceModalTitle" :target-branch="targetBranch || ref" :original-branch="originalBranch || ref" :can-push-code="canPushCode" @@ -71,5 +94,15 @@ export default { :replace-path="replacePath" :primary-btn-text="$options.i18n.replacePrimaryBtnText" /> + <delete-blob-modal + :modal-id="deleteModalId" + :modal-title="deleteModalTitle" + :delete-path="deletePath" + :commit-message="deleteModalTitle" + :target-branch="targetBranch || ref" + :original-branch="originalBranch || ref" + :can-push-code="canPushCode" + :empty-repo="emptyRepo" + /> </div> </template> diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 7fbf331d585..09ac60c94c7 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -5,16 +5,19 @@ import BlobContent from '~/blob/components/blob_content.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants'; import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import blobInfoQuery from '../queries/blob_info.query.graphql'; -import BlobHeaderEdit from './blob_header_edit.vue'; -import BlobReplace from './blob_replace.vue'; +import BlobButtonGroup from './blob_button_group.vue'; +import BlobEdit from './blob_edit.vue'; +import { loadViewer, viewerProps } from './blob_viewers'; export default { components: { BlobHeader, - BlobHeaderEdit, - BlobReplace, + BlobEdit, + BlobButtonGroup, BlobContent, GlLoadingIcon, }, @@ -31,9 +34,12 @@ export default { this.switchViewer( this.hasRichViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER, ); + if (this.hasRichViewer && !this.blobViewer) { + this.loadLegacyViewer(); + } }, error() { - createFlash({ message: __('An error occurred while loading the file. Please try again.') }); + this.displayError(); }, }, }, @@ -54,9 +60,16 @@ export default { }, data() { return { + legacyRichViewer: null, + isBinary: false, + isLoadingLegacyViewer: false, activeViewerType: SIMPLE_BLOB_VIEWER, project: { + userPermissions: { + pushCode: false, + }, repository: { + empty: true, blobs: { nodes: [ { @@ -77,10 +90,10 @@ export default { canLock: false, isLocked: false, lockLink: '', - canModifyBlob: true, forkPath: '', simpleViewer: {}, richViewer: null, + webPath: '', }, ], }, @@ -90,10 +103,10 @@ export default { }, computed: { isLoggedIn() { - return Boolean(gon.current_user_id); + return isLoggedIn(); }, isLoading() { - return this.$apollo.queries.project.loading; + return this.$apollo.queries.project.loading || this.isLoadingLegacyViewer; }, blobInfo() { const nodes = this.project?.repository?.blobs?.nodes; @@ -110,8 +123,30 @@ export default { hasRenderError() { return Boolean(this.viewer.renderError); }, + blobViewer() { + const { fileType } = this.viewer; + return loadViewer(fileType); + }, + viewerProps() { + const { fileType } = this.viewer; + return viewerProps(fileType, this.blobInfo); + }, }, methods: { + loadLegacyViewer() { + this.isLoadingLegacyViewer = true; + axios + .get(`${this.blobInfo.webPath}?format=json&viewer=rich`) + .then(({ data: { html, binary } }) => { + this.legacyRichViewer = html; + this.isBinary = binary; + this.isLoadingLegacyViewer = false; + }) + .catch(() => this.displayError()); + }, + displayError() { + createFlash({ message: __('An error occurred while loading the file. Please try again.') }); + }, switchViewer(newViewer) { this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER; }, @@ -121,36 +156,42 @@ export default { <template> <div> - <gl-loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" size="sm" /> <div v-if="blobInfo && !isLoading" class="file-holder"> <blob-header :blob="blobInfo" - :hide-viewer-switcher="!hasRichViewer" + :hide-viewer-switcher="!hasRichViewer || isBinary" :active-viewer-type="viewer.type" :has-render-error="hasRenderError" @viewer-changed="switchViewer" > <template #actions> - <blob-header-edit + <blob-edit + v-if="!isBinary" :edit-path="blobInfo.editBlobPath" :web-ide-path="blobInfo.ideEditPath" /> - <blob-replace + <blob-button-group v-if="isLoggedIn" :path="path" :name="blobInfo.name" :replace-path="blobInfo.replacePath" - :can-push-code="blobInfo.canModifyBlob" + :delete-path="blobInfo.webPath" + :can-push-code="project.userPermissions.pushCode" + :empty-repo="project.repository.empty" /> </template> </blob-header> <blob-content + v-if="!blobViewer" + :rich-viewer="legacyRichViewer" :blob="blobInfo" :content="blobInfo.rawTextBlob" :is-raw-content="true" :active-viewer="viewer" :loading="false" /> + <component :is="blobViewer" v-else v-bind="viewerProps" class="blob-viewer" /> </div> </div> </template> diff --git a/app/assets/javascripts/repository/components/blob_header_edit.vue b/app/assets/javascripts/repository/components/blob_edit.vue index 3d97ebe89e4..3d97ebe89e4 100644 --- a/app/assets/javascripts/repository/components/blob_header_edit.vue +++ b/app/assets/javascripts/repository/components/blob_edit.vue diff --git a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue new file mode 100644 index 00000000000..48fa33eb558 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue @@ -0,0 +1,51 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { sprintf, __ } from '~/locale'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + fileName: { + type: String, + required: true, + }, + filePath: { + type: String, + required: true, + }, + fileSize: { + type: Number, + required: false, + default: 0, + }, + }, + computed: { + downloadFileSize() { + return numberToHumanSize(this.fileSize); + }, + downloadText() { + if (this.fileSize > 0) { + return sprintf(__('Download (%{fileSizeReadable})'), { + fileSizeReadable: this.downloadFileSize, + }); + } + return __('Download'); + }, + }, +}; +</script> + +<template> + <div class="gl-text-center gl-py-13 gl-bg-gray-50"> + <gl-link :href="filePath" rel="nofollow" :download="fileName" target="_blank"> + <div> + <gl-icon :size="16" name="download" class="gl-text-gray-900" /> + </div> + <h4>{{ downloadText }}</h4> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/empty_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/empty_viewer.vue new file mode 100644 index 00000000000..53210cbcc93 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/empty_viewer.vue @@ -0,0 +1,3 @@ +<template> + <div class="nothing-here-block">{{ __('Empty file') }}</div> +</template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js new file mode 100644 index 00000000000..4e16b16041f --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -0,0 +1,27 @@ +export const loadViewer = (type) => { + switch (type) { + case 'empty': + return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue'); + case 'text': + return () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue'); + case 'download': + return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue'); + default: + return null; + } +}; + +export const viewerProps = (type, blob) => { + return { + text: { + content: blob.rawTextBlob, + fileName: blob.name, + readOnly: true, + }, + download: { + fileName: blob.name, + filePath: blob.rawPath, + fileSize: blob.rawSize, + }, + }[type]; +}; diff --git a/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue new file mode 100644 index 00000000000..57fc979a56e --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue @@ -0,0 +1,25 @@ +<script> +export default { + components: { + SourceEditor: () => + import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'), + }, + props: { + content: { + type: String, + required: true, + }, + fileName: { + type: String, + required: true, + }, + readOnly: { + type: Boolean, + required: true, + }, + }, +}; +</script> +<template> + <source-editor :value="content" :file-name="fileName" :editor-options="{ readOnly }" /> +</template> diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue new file mode 100644 index 00000000000..6599d99d7bd --- /dev/null +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -0,0 +1,151 @@ +<script> +import { GlModal, GlFormGroup, GlFormInput, GlFormTextarea, GlToggle } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __ } from '~/locale'; +import { + SECONDARY_OPTIONS_TEXT, + COMMIT_LABEL, + TARGET_BRANCH_LABEL, + TOGGLE_CREATE_MR_LABEL, +} from '../constants'; + +export default { + csrf, + components: { + GlModal, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlToggle, + }, + i18n: { + PRIMARY_OPTIONS_TEXT: __('Delete file'), + SECONDARY_OPTIONS_TEXT, + COMMIT_LABEL, + TARGET_BRANCH_LABEL, + TOGGLE_CREATE_MR_LABEL, + }, + props: { + modalId: { + type: String, + required: true, + }, + modalTitle: { + type: String, + required: true, + }, + deletePath: { + type: String, + required: true, + }, + commitMessage: { + type: String, + required: true, + }, + targetBranch: { + type: String, + required: true, + }, + originalBranch: { + type: String, + required: true, + }, + canPushCode: { + type: Boolean, + required: true, + }, + emptyRepo: { + type: Boolean, + required: true, + }, + }, + data() { + return { + loading: false, + commit: this.commitMessage, + target: this.targetBranch, + createNewMr: true, + error: '', + }; + }, + computed: { + primaryOptions() { + return { + text: this.$options.i18n.PRIMARY_OPTIONS_TEXT, + attributes: [ + { + variant: 'danger', + loading: this.loading, + disabled: !this.formCompleted || this.loading, + }, + ], + }; + }, + cancelOptions() { + return { + text: this.$options.i18n.SECONDARY_OPTIONS_TEXT, + attributes: [ + { + disabled: this.loading, + }, + ], + }; + }, + showCreateNewMrToggle() { + return this.canPushCode && this.target !== this.originalBranch; + }, + formCompleted() { + return this.commit && this.target; + }, + }, + methods: { + submitForm(e) { + e.preventDefault(); // Prevent modal from closing + this.loading = true; + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + :modal-id="modalId" + :title="modalTitle" + :action-primary="primaryOptions" + :action-cancel="cancelOptions" + @primary="submitForm" + > + <form ref="form" :action="deletePath" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <template v-if="emptyRepo"> + <!-- Once "empty_repo_upload_experiment" is made available, will need to add class 'js-branch-name' + Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335721 --> + <input type="hidden" name="branch_name" :value="originalBranch" /> + </template> + <template v-else> + <input type="hidden" name="original_branch" :value="originalBranch" /> + <!-- Once "push to branch" permission is made available, will need to add to conditional + Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335462 --> + <input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" /> + <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message"> + <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" /> + </gl-form-group> + <gl-form-group + v-if="canPushCode" + :label="$options.i18n.TARGET_BRANCH_LABEL" + label-for="branch_name" + > + <gl-form-input v-model="target" :disabled="loading" name="branch_name" /> + </gl-form-group> + <gl-toggle + v-if="showCreateNewMrToggle" + v-model="createNewMr" + :disabled="loading" + :label="$options.i18n.TOGGLE_CREATE_MR_LABEL" + /> + </template> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index ca5711de49c..69eefc807d7 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -70,7 +70,7 @@ export default { ); }, showParentRow() { - return !this.isLoading && ['', '/'].indexOf(this.path) === -1; + return ['', '/'].indexOf(this.path) === -1; }, }, methods: { diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 62f863db871..82c18d13a6a 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -186,6 +186,8 @@ export default { :is="linkComponent" ref="link" v-gl-hover-load="handlePreload" + v-gl-tooltip:tooltip-container + :title="fullPath" :to="routerLinkTo" :href="url" :class="{ diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 794a8a85cc5..c861fb8dd06 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -1,8 +1,9 @@ <script> import filesQuery from 'shared_queries/repository/files.query.graphql'; import createFlash from '~/flash'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __ } from '../../locale'; -import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT } from '../constants'; +import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT } from '../constants'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import { readmeFile } from '../utils/readme'; @@ -14,7 +15,7 @@ export default { FileTable, FilePreview, }, - mixins: [getRefMixin], + mixins: [getRefMixin, glFeatureFlagMixin()], apollo: { projectPath: { query: projectPathQuery, @@ -36,6 +37,7 @@ export default { return { projectPath: '', nextPageCursor: '', + pagesLoaded: 1, entries: { trees: [], submodules: [], @@ -44,16 +46,28 @@ export default { isLoadingFiles: false, isOverLimit: false, clickedShowMore: false, - pageSize: TREE_PAGE_SIZE, fetchCounter: 0, }; }, computed: { + pageSize() { + // we want to exponentially increase the page size to reduce the load on the frontend + const exponentialSize = (TREE_PAGE_SIZE / TREE_INITIAL_FETCH_COUNT) * (this.fetchCounter + 1); + return exponentialSize < TREE_PAGE_SIZE && this.glFeatures.increasePageSizeExponentially + ? exponentialSize + : TREE_PAGE_SIZE; + }, + totalEntries() { + return Object.values(this.entries).flat().length; + }, readme() { return readmeFile(this.entries.blobs); }, + pageLimitReached() { + return this.totalEntries / this.pagesLoaded >= TREE_PAGE_LIMIT; + }, hasShowMore() { - return !this.clickedShowMore && this.fetchCounter === TREE_INITIAL_FETCH_COUNT; + return !this.clickedShowMore && this.pageLimitReached; }, }, @@ -104,7 +118,7 @@ export default { if (pageInfo?.hasNextPage) { this.nextPageCursor = pageInfo.endCursor; this.fetchCounter += 1; - if (this.fetchCounter < TREE_INITIAL_FETCH_COUNT || this.clickedShowMore) { + if (!this.pageLimitReached || this.clickedShowMore) { this.fetchFiles(); this.clickedShowMore = false; } @@ -127,6 +141,7 @@ export default { }, handleShowMore() { this.clickedShowMore = true; + this.pagesLoaded += 1; this.fetchFiles(); }, }, diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index 7f065dbdf6d..df5a5ea6163 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -17,13 +17,15 @@ import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking'; import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; +import { + SECONDARY_OPTIONS_TEXT, + COMMIT_LABEL, + TARGET_BRANCH_LABEL, + TOGGLE_CREATE_MR_LABEL, +} from '../constants'; const PRIMARY_OPTIONS_TEXT = __('Upload file'); -const SECONDARY_OPTIONS_TEXT = __('Cancel'); const MODAL_TITLE = __('Upload New File'); -const COMMIT_LABEL = __('Commit message'); -const TARGET_BRANCH_LABEL = __('Target branch'); -const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); const REMOVE_FILE_TEXT = __('Remove file'); const NEW_BRANCH_IN_FORK = __( 'A new branch will be created in your fork and a new merge request will be started.', @@ -170,7 +172,7 @@ export default { }) .catch(() => { this.loading = false; - createFlash(ERROR_MESSAGE); + createFlash({ message: ERROR_MESSAGE }); }); }, formData() { diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 62d5d3db445..2d2faa8d9f3 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -1,4 +1,10 @@ -const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page +import { __ } from '~/locale'; +export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make + +export const SECONDARY_OPTIONS_TEXT = __('Cancel'); +export const COMMIT_LABEL = __('Commit message'); +export const TARGET_BRANCH_LABEL = __('Target branch'); +export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index bfd9447d260..a8f263941e2 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -1,6 +1,10 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) { project(fullPath: $projectPath) { + userPermissions { + pushCode + } repository { + empty blobs(paths: [$filePath]) { nodes { webPath @@ -15,7 +19,6 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) { storedExternally rawPath replacePath - canModifyBlob simpleViewer { fileType tooLarge diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql index be6897b9a16..b046fc1f730 100644 --- a/app/assets/javascripts/repository/queries/commit.fragment.graphql +++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql @@ -5,5 +5,6 @@ fragment TreeEntryCommit on LogTreeCommit { committedDate commitPath fileName + filePath type } diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 6cdd89ad431..36f5e6f4ce1 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -2,8 +2,8 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; -import { fixTitle, hide } from '~/tooltips'; -import { deprecatedCreateFlash as flash } from './flash'; +import { hide } from '~/tooltips'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { sprintf, s__, __ } from './locale'; @@ -98,45 +98,15 @@ Sidebar.prototype.toggleTodo = function (e) { this.todoUpdateDone(data); }) .catch(() => - flash( - sprintf(__('There was an error %{message} todo.'), { + createFlash({ + message: sprintf(__('There was an error %{message} todo.'), { message: ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'), }), - ), + }), ); }; -Sidebar.prototype.todoUpdateDone = function (data) { - const deletePath = data.delete_path ? data.delete_path : null; - const attrPrefix = deletePath ? 'mark' : 'todo'; - const $todoBtns = $('.js-issuable-todo'); - - $(document).trigger('todo:toggle', data.count); - - $todoBtns.each((i, el) => { - const $el = $(el); - const $elText = $el.find('.js-issuable-todo-inner'); - - $el - .removeClass('is-loading') - .enable() - .attr('aria-label', $el.data(`${attrPrefix}Text`)) - .attr('title', $el.data(`${attrPrefix}Text`)) - .data('deletePath', deletePath); - - if ($el.hasClass('has-tooltip')) { - fixTitle(el); - } - - if (typeof $el.data('isCollapsed') !== 'undefined') { - $elText.html($el.data(`${attrPrefix}Icon`)); - } else { - $elText.text($el.data(`${attrPrefix}Text`)); - } - }); -}; - Sidebar.prototype.sidebarCollapseClicked = function (e) { if ($(e.currentTarget).hasClass('dont-change-state')) { return; 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 7f9f796bdee..863f0ab995f 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -1,9 +1,11 @@ <script> import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; +import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; -import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql'; +import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; +import { captureException } from '~/runner/sentry_utils'; const i18n = { I18N_EDIT: __('Edit'), @@ -14,6 +16,7 @@ const i18n = { }; export default { + name: 'RunnerActionsCell', components: { GlButton, GlButtonGroup, @@ -86,7 +89,7 @@ export default { }); if (errors && errors.length) { - this.onError(new Error(errors[0])); + throw new Error(errors.join(' ')); } } catch (e) { this.onError(e); @@ -109,7 +112,7 @@ export default { runnerDelete: { errors }, }, } = await this.$apollo.mutate({ - mutation: deleteRunnerMutation, + mutation: runnerDeleteMutation, variables: { input: { id: this.runner.id, @@ -119,7 +122,7 @@ export default { refetchQueries: ['getRunners'], }); if (errors && errors.length) { - this.onError(new Error(errors[0])); + throw new Error(errors.join(' ')); } } catch (e) { this.onError(e); @@ -129,9 +132,13 @@ export default { }, onError(error) { - // TODO Render errors when "delete" action is done - // `active` toggle would not fail due to user input. - throw error; + const { message } = error; + createFlash({ message }); + + this.reportToSentry(error); + }, + reportToSentry(error) { + captureException({ error, component: this.$options.name }); }, }, i18n, diff --git a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue index b3ebdfd82e3..f186a8daf72 100644 --- a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue @@ -32,11 +32,11 @@ export default { <runner-type-badge :type="runnerType" size="sm" /> <gl-badge v-if="locked" variant="warning" size="sm"> - {{ __('locked') }} + {{ s__('Runners|locked') }} </gl-badge> <gl-badge v-if="paused" variant="danger" size="sm"> - {{ __('paused') }} + {{ s__('Runners|paused') }} </gl-badge> </div> </template> diff --git a/app/assets/javascripts/runner/components/helpers/masked_value.vue b/app/assets/javascripts/runner/components/helpers/masked_value.vue new file mode 100644 index 00000000000..feccb37de81 --- /dev/null +++ b/app/assets/javascripts/runner/components/helpers/masked_value.vue @@ -0,0 +1,60 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlButton, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isMasked: true, + }; + }, + computed: { + label() { + if (this.isMasked) { + return __('Click to reveal'); + } + return __('Click to hide'); + }, + icon() { + if (this.isMasked) { + return 'eye'; + } + return 'eye-slash'; + }, + displayedValue() { + if (this.isMasked && this.value?.length) { + return '*'.repeat(this.value.length); + } + return this.value; + }, + }, + methods: { + toggleMasked() { + this.isMasked = !this.isMasked; + }, + }, +}; +</script> +<template> + <span + >{{ displayedValue }} + <gl-button + :aria-label="label" + :icon="icon" + class="gl-text-body!" + data-testid="toggle-masked" + variant="link" + @click="toggleMasked" + /> + </span> +</template> diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue index bec33ce2f44..e14b3b17fa8 100644 --- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -1,9 +1,9 @@ <script> -import { GlFilteredSearchToken } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; -import { __, s__ } from '~/locale'; +import { formatNumber, sprintf, __, s__ } from '~/locale'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { STATUS_ACTIVE, STATUS_PAUSED, @@ -19,50 +19,9 @@ import { CONTACTED_ASC, PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, + PARAM_KEY_TAG, } from '../constants'; - -const searchTokens = [ - { - icon: 'status', - title: __('Status'), - type: PARAM_KEY_STATUS, - token: GlFilteredSearchToken, - // TODO Get more than one value when GraphQL API supports OR for "status" - unique: true, - 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') }, - - // Added extra quotes in this title to avoid splitting this value: - // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 - { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` }, - ], - // TODO In principle we could support more complex search rules, - // this can be added to a separate issue. - operators: OPERATOR_IS_ONLY, - }, - - { - icon: 'file-tree', - title: __('Type'), - type: PARAM_KEY_RUNNER_TYPE, - token: GlFilteredSearchToken, - // TODO Get more than one value when GraphQL API supports OR for "status" - unique: true, - options: [ - { value: INSTANCE_TYPE, title: s__('Runners|shared') }, - { value: GROUP_TYPE, title: s__('Runners|group') }, - { value: PROJECT_TYPE, title: s__('Runners|specific') }, - ], - // TODO We should support more complex search rules, - // search for multiple states (OR) or have NOT operators - operators: OPERATOR_IS_ONLY, - }, - - // TODO Support tags -]; +import TagToken from './search_tokens/tag_token.vue'; const sortOptions = [ { @@ -95,6 +54,14 @@ export default { return Array.isArray(val?.filters) && typeof val?.sort === 'string'; }, }, + namespace: { + type: String, + required: true, + }, + activeRunnersCount: { + type: Number, + required: true, + }, }, data() { // filtered_search_bar_root.vue may mutate the inital @@ -106,6 +73,62 @@ export default { initialSortBy: sort, }; }, + computed: { + searchTokens() { + return [ + { + icon: 'status', + title: __('Status'), + type: PARAM_KEY_STATUS, + token: BaseToken, + unique: true, + 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') }, + + // Added extra quotes in this title to avoid splitting this value: + // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 + { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` }, + ], + // TODO In principle we could support more complex search rules, + // this can be added to a separate issue. + operators: OPERATOR_IS_ONLY, + }, + + { + icon: 'file-tree', + title: __('Type'), + type: PARAM_KEY_RUNNER_TYPE, + token: BaseToken, + unique: true, + options: [ + { value: INSTANCE_TYPE, title: s__('Runners|instance') }, + { value: GROUP_TYPE, title: s__('Runners|group') }, + { value: PROJECT_TYPE, title: s__('Runners|project') }, + ], + // TODO We should support more complex search rules, + // search for multiple states (OR) or have NOT operators + operators: OPERATOR_IS_ONLY, + }, + + { + icon: 'tag', + title: s__('Runners|Tags'), + type: PARAM_KEY_TAG, + token: TagToken, + recentTokenValuesStorageKey: `${this.namespace}-recent-tags`, + operators: OPERATOR_IS_ONLY, + }, + ]; + }, + activeRunnersMessage() { + return sprintf(__('Runners currently online: %{active_runners_count}'), { + active_runners_count: formatNumber(this.activeRunnersCount), + }); + }, + }, methods: { onFilter(filters) { const { sort } = this.value; @@ -127,19 +150,23 @@ export default { }, }, sortOptions, - searchTokens, }; </script> <template> - <filtered-search - v-bind="$attrs" - recent-searches-storage-key="runners-search" - :sort-options="$options.sortOptions" - :initial-filter-value="initialFilterValue" - :initial-sort-by="initialSortBy" - :tokens="$options.searchTokens" - :search-input-placeholder="__('Search or filter results...')" - @onFilter="onFilter" - @onSort="onSort" - /> + <div> + <filtered-search + v-bind="$attrs" + :namespace="namespace" + recent-searches-storage-key="runners-search" + :sort-options="$options.sortOptions" + :initial-filter-value="initialFilterValue" + :initial-sort-by="initialSortBy" + :tokens="searchTokens" + :search-input-placeholder="__('Search or filter results...')" + data-testid="runners-filtered-search" + @onFilter="onFilter" + @onSort="onSort" + /> + <div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div> + </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index 41adbbb55f6..69a1f106ca8 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -1,8 +1,9 @@ <script> import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { formatNumber, sprintf, __, s__ } from '~/locale'; +import { formatNumber, __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { RUNNER_JOB_COUNT_LIMIT } from '../constants'; import RunnerActionsCell from './cells/runner_actions_cell.vue'; import RunnerNameCell from './cells/runner_name_cell.vue'; import RunnerTypeCell from './cells/runner_type_cell.vue'; @@ -51,19 +52,20 @@ export default { type: Array, required: true, }, - activeRunnersCount: { - type: Number, - required: true, - }, - }, - computed: { - activeRunnersMessage() { - return sprintf(__('Runners currently online: %{active_runners_count}'), { - active_runners_count: formatNumber(this.activeRunnersCount), - }); - }, }, methods: { + formatProjectCount(projectCount) { + if (projectCount === null) { + return __('n/a'); + } + return formatNumber(projectCount); + }, + formatJobCount(jobCount) { + if (jobCount > RUNNER_JOB_COUNT_LIMIT) { + return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`; + } + return formatNumber(jobCount); + }, runnerTrAttr(runner) { if (runner) { return { @@ -88,12 +90,12 @@ export default { </script> <template> <div> - <div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div> <gl-table :busy="loading" :items="runners" :fields="$options.fields" :tbody-tr-attr="runnerTrAttr" + data-testid="runner-list" stacked="md" fixed > @@ -117,12 +119,12 @@ export default { {{ ipAddress }} </template> - <template #cell(projectCount)> - <!-- TODO add projects count --> + <template #cell(projectCount)="{ item: { projectCount } }"> + {{ formatProjectCount(projectCount) }} </template> - <template #cell(jobCount)> - <!-- TODO add jobs count --> + <template #cell(jobCount)="{ item: { jobCount } }"> + {{ formatJobCount(jobCount) }} </template> <template #cell(tagList)="{ item: { tagList } }"> diff --git a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue index 426d377c92b..475d362bb52 100644 --- a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue +++ b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue @@ -1,6 +1,7 @@ <script> import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; +import MaskedValue from '~/runner/components/helpers/masked_value.vue'; import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; @@ -11,6 +12,7 @@ export default { GlLink, GlSprintf, ClipboardButton, + MaskedValue, RunnerInstructions, RunnerRegistrationTokenReset, }, @@ -92,7 +94,9 @@ export default { {{ __('And this registration token:') }} <br /> - <code data-testid="registration-token">{{ currentRegistrationToken }}</code> + <code data-testid="registration-token" + ><masked-value :value="currentRegistrationToken" + /></code> <clipboard-button :title="__('Copy token')" :text="currentRegistrationToken" /> </li> </ol> diff --git a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue b/app/assets/javascripts/runner/components/runner_registration_token_reset.vue index b03574264d9..2335faa4f85 100644 --- a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue +++ b/app/assets/javascripts/runner/components/runner_registration_token_reset.vue @@ -3,9 +3,11 @@ import { GlButton } from '@gitlab/ui'; import createFlash, { FLASH_TYPES } from '~/flash'; import { __, s__ } from '~/locale'; import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; +import { captureException } from '~/runner/sentry_utils'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; export default { + name: 'RunnerRegistrationTokenReset', components: { GlButton, }, @@ -52,8 +54,7 @@ export default { }, }); if (errors && errors.length) { - this.onError(new Error(errors[0])); - return; + throw new Error(errors.join(' ')); } this.onSuccess(token); } catch (e) { @@ -65,6 +66,8 @@ export default { onError(error) { const { message } = error; createFlash({ message }); + + this.reportToSentry(error); }, onSuccess(token) { createFlash({ @@ -73,6 +76,9 @@ export default { }); this.$emit('tokenReset', token); }, + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, }, }; </script> diff --git a/app/assets/javascripts/runner/components/runner_tag.vue b/app/assets/javascripts/runner/components/runner_tag.vue new file mode 100644 index 00000000000..06562e618a8 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_tag.vue @@ -0,0 +1,27 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { RUNNER_TAG_BADGE_VARIANT } from '../constants'; + +export default { + components: { + GlBadge, + }, + props: { + tag: { + type: String, + required: true, + }, + size: { + type: String, + required: false, + default: 'md', + }, + }, + RUNNER_TAG_BADGE_VARIANT, +}; +</script> +<template> + <gl-badge :size="size" :variant="$options.RUNNER_TAG_BADGE_VARIANT"> + {{ tag }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue index 4ba07e00c96..aec0d8e2c66 100644 --- a/app/assets/javascripts/runner/components/runner_tags.vue +++ b/app/assets/javascripts/runner/components/runner_tags.vue @@ -1,9 +1,9 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import RunnerTag from './runner_tag.vue'; export default { components: { - GlBadge, + RunnerTag, }, props: { tagList: { @@ -16,18 +16,11 @@ export default { required: false, default: 'md', }, - variant: { - type: String, - required: false, - default: 'info', - }, }, }; </script> <template> <div> - <gl-badge v-for="tag in tagList" :key="tag" :size="size" :variant="variant"> - {{ tag }} - </gl-badge> + <runner-tag v-for="tag in tagList" :key="tag" :tag="tag" :size="size" /> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_type_help.vue b/app/assets/javascripts/runner/components/runner_type_help.vue index 927deb290a4..70456b3ab65 100644 --- a/app/assets/javascripts/runner/components/runner_type_help.vue +++ b/app/assets/javascripts/runner/components/runner_type_help.vue @@ -44,13 +44,13 @@ export default { </li> <li> <gl-badge variant="warning" size="sm"> - {{ __('locked') }} + {{ s__('Runners|locked') }} </gl-badge> - {{ __('Cannot be assigned to other projects.') }} </li> <li> <gl-badge variant="danger" size="sm"> - {{ __('paused') }} + {{ s__('Runners|paused') }} </gl-badge> - {{ __('Not available to run jobs.') }} </li> diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue index 0c1b83b6830..85d14547efd 100644 --- a/app/assets/javascripts/runner/components/runner_update_form.vue +++ b/app/assets/javascripts/runner/components/runner_update_form.vue @@ -7,42 +7,26 @@ import { GlFormInputGroup, GlTooltipDirective, } from '@gitlab/ui'; +import { + modelToUpdateMutationVariables, + runnerToModel, +} from 'ee_else_ce/runner/runner_details/runner_update_form_utils'; import createFlash, { FLASH_TYPES } from '~/flash'; import { __ } from '~/locale'; +import { captureException } from '~/runner/sentry_utils'; import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql'; -const runnerToModel = (runner) => { - const { - id, - description, - maximumTimeout, - accessLevel, - active, - locked, - runUntagged, - tagList = [], - } = runner || {}; - - return { - id, - description, - maximumTimeout, - accessLevel, - active, - locked, - runUntagged, - tagList: tagList.join(', '), - }; -}; - export default { + name: 'RunnerUpdateForm', components: { GlButton, GlForm, GlFormCheckbox, GlFormGroup, GlFormInputGroup, + RunnerUpdateCostFactorFields: () => + import('ee_component/runner/components/runner_update_cost_factor_fields.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -67,18 +51,6 @@ export default { readonlyIpAddress() { return this.runner?.ipAddress; }, - updateMutationInput() { - const { maximumTimeout, tagList } = this.model; - - return { - ...this.model, - maximumTimeout: maximumTimeout !== '' ? maximumTimeout : null, - tagList: tagList - .split(',') - .map((tag) => tag.trim()) - .filter((tag) => Boolean(tag)), - }; - }, }, watch: { runner(newVal, oldVal) { @@ -98,31 +70,32 @@ export default { }, } = await this.$apollo.mutate({ mutation: runnerUpdateMutation, - variables: { - input: this.updateMutationInput, - }, + variables: modelToUpdateMutationVariables(this.model), }); if (errors?.length) { - this.onError(new Error(errors[0])); + // Validation errors need not be thrown + createFlash({ message: errors[0] }); return; } this.onSuccess(); - } catch (e) { - this.onError(e); + } catch (error) { + const { message } = error; + createFlash({ message }); + + this.reportToSentry(error); } finally { this.saving = false; } }, - onError(error) { - const { message } = error; - createFlash({ message }); - }, onSuccess() { createFlash({ message: __('Changes saved.'), type: FLASH_TYPES.SUCCESS }); this.model = runnerToModel(this.runner); }, + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, }, ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, @@ -213,6 +186,8 @@ export default { <gl-form-input-group v-model="model.tagList" /> </gl-form-group> + <runner-update-cost-factor-fields v-model="model" /> + <div class="form-actions"> <gl-button type="submit" diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue new file mode 100644 index 00000000000..0c69072f06a --- /dev/null +++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue @@ -0,0 +1,91 @@ +<script> +import { GlFilteredSearchSuggestion, GlToken } from '@gitlab/ui'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; + +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { RUNNER_TAG_BG_CLASS } from '../../constants'; + +export const TAG_SUGGESTIONS_PATH = '/admin/runners/tag_list.json'; + +export default { + components: { + BaseToken, + GlFilteredSearchSuggestion, + GlToken, + }, + props: { + config: { + type: Object, + required: true, + }, + }, + data() { + return { + tags: [], + loading: false, + }; + }, + methods: { + fnCurrentTokenValue(data) { + // By default, values are transformed with `toLowerCase` + // however, runner tags are case sensitive. + return data; + }, + getTagsOptions(search) { + // TODO This should be implemented via a GraphQL API + // The API should + // 1) scope to the rights of the user + // 2) stay up to date to the removal of old tags + // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796 + return axios + .get(TAG_SUGGESTIONS_PATH, { + params: { + search, + }, + }) + .then(({ data }) => { + return data.map(({ id, name }) => ({ id, value: name, text: name })); + }); + }, + async fetchTags(searchTerm) { + this.loading = true; + try { + this.tags = await this.getTagsOptions(searchTerm); + } catch { + createFlash({ + message: s__('Runners|Something went wrong while fetching the tags suggestions'), + }); + } finally { + this.loading = false; + } + }, + }, + RUNNER_TAG_BG_CLASS, +}; +</script> + +<template> + <base-token + v-bind="$attrs" + :config="config" + :suggestions-loading="loading" + :suggestions="tags" + :fn-current-token-value="fnCurrentTokenValue" + :recent-suggestions-storage-key="config.recentTokenValuesStorageKey" + @fetch-suggestions="fetchTags" + v-on="$listeners" + > + <template #view-token="{ viewTokenProps: { listeners, inputValue, activeTokenValue } }"> + <gl-token variant="search-value" :class="$options.RUNNER_TAG_BG_CLASS" v-on="listeners"> + {{ activeTokenValue ? activeTokenValue.text : inputValue }} + </gl-token> + </template> + <template #suggestions-list="{ suggestions }"> + <gl-filtered-search-suggestion v-for="tag in suggestions" :key="tag.id" :value="tag.value"> + {{ tag.text }} + </gl-filtered-search-suggestion> + </template> + </base-token> +</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index a57d18ba745..2822882e0cc 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -1,18 +1,23 @@ import { s__ } from '~/locale'; export const RUNNER_PAGE_SIZE = 20; +export const RUNNER_JOB_COUNT_LIMIT = 1000; +export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); -export const RUNNER_ENTITY_TYPE = 'Ci::Runner'; +export const RUNNER_TAG_BADGE_VARIANT = 'info'; +export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100'; // Filtered search parameter names // - Used for URL params names // - GlFilteredSearch tokens type -export const PARAM_KEY_SEARCH = 'search'; export const PARAM_KEY_STATUS = 'status'; export const PARAM_KEY_RUNNER_TYPE = 'runner_type'; +export const PARAM_KEY_TAG = 'tag'; +export const PARAM_KEY_SEARCH = 'search'; + export const PARAM_KEY_SORT = 'sort'; export const PARAM_KEY_PAGE = 'page'; export const PARAM_KEY_AFTER = 'after'; diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql index 84e0d6cc95c..c294cb9bf22 100644 --- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql @@ -1,4 +1,4 @@ -#import "~/runner/graphql/runner_details.fragment.graphql" +#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql" query getRunner($id: CiRunnerID!) { runner(id: $id) { diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql index 45df9c625a6..9f837197558 100644 --- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql @@ -6,9 +6,10 @@ query getRunners( $after: String $first: Int $last: Int - $search: String $status: CiRunnerStatus $type: CiRunnerType + $tagList: [String!] + $search: String $sort: CiRunnerSort ) { runners( @@ -16,9 +17,10 @@ query getRunners( after: $after first: $first last: $last - search: $search status: $status type: $type + tagList: $tagList + search: $search sort: $sort ) { nodes { diff --git a/app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql index d580ea2785e..d580ea2785e 100644 --- a/app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql +++ b/app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql diff --git a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql index 6d7dc1e2798..2449ee0fc0f 100644 --- a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql @@ -1,12 +1,5 @@ +#import "./runner_details_shared.fragment.graphql" + fragment RunnerDetails on CiRunner { - id - runnerType - active - accessLevel - runUntagged - locked - ipAddress - description - maximumTimeout - tagList + ...RunnerDetailsShared } diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql new file mode 100644 index 00000000000..8c50cba7de3 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql @@ -0,0 +1,12 @@ +fragment RunnerDetailsShared on CiRunner { + id + runnerType + active + accessLevel + runUntagged + locked + ipAddress + description + maximumTimeout + tagList +} diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql index 0835e3c7c09..68d6f02f799 100644 --- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql @@ -10,4 +10,6 @@ fragment RunnerNode on CiRunner { locked tagList contactedAt + jobCount + projectCount } diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql index d50c1880d77..dcc7fdf24f1 100644 --- a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql +++ b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/runner/graphql/runner_details.fragment.graphql" +#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql" mutation runnerUpdate($input: RunnerUpdateInput!) { runnerUpdate(input: $input) { diff --git a/app/assets/javascripts/runner/runner_details/runner_details_app.vue b/app/assets/javascripts/runner/runner_details/runner_details_app.vue index 5d5fa81b851..6557a7834e7 100644 --- a/app/assets/javascripts/runner/runner_details/runner_details_app.vue +++ b/app/assets/javascripts/runner/runner_details/runner_details_app.vue @@ -1,20 +1,22 @@ <script> +import createFlash from '~/flash'; +import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { sprintf } from '~/locale'; import RunnerTypeAlert from '../components/runner_type_alert.vue'; import RunnerTypeBadge from '../components/runner_type_badge.vue'; import RunnerUpdateForm from '../components/runner_update_form.vue'; -import { I18N_DETAILS_TITLE, RUNNER_ENTITY_TYPE } from '../constants'; +import { I18N_DETAILS_TITLE, I18N_FETCH_ERROR } from '../constants'; import getRunnerQuery from '../graphql/get_runner.query.graphql'; +import { captureException } from '../sentry_utils'; export default { + name: 'RunnerDetailsApp', components: { RunnerTypeAlert, RunnerTypeBadge, RunnerUpdateForm, }, - i18n: { - I18N_DETAILS_TITLE, - }, props: { runnerId: { type: String, @@ -31,9 +33,27 @@ export default { query: getRunnerQuery, variables() { return { - id: convertToGraphQLId(RUNNER_ENTITY_TYPE, this.runnerId), + id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), }; }, + error(error) { + createFlash({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); + }, + }, + }, + computed: { + pageTitle() { + return sprintf(I18N_DETAILS_TITLE, { runner_id: this.runnerId }); + }, + }, + errorCaptured(error) { + this.reportToSentry(error); + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); }, }, }; @@ -41,9 +61,7 @@ export default { <template> <div> <h2 class="page-title"> - {{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }} - - <runner-type-badge v-if="runner" :type="runner.runnerType" /> + {{ pageTitle }} <runner-type-badge v-if="runner" :type="runner.runnerType" /> </h2> <runner-type-alert v-if="runner" :type="runner.runnerType" /> diff --git a/app/assets/javascripts/runner/runner_details/runner_update_form_utils.js b/app/assets/javascripts/runner/runner_details/runner_update_form_utils.js new file mode 100644 index 00000000000..3b519fa7d71 --- /dev/null +++ b/app/assets/javascripts/runner/runner_details/runner_update_form_utils.js @@ -0,0 +1,38 @@ +export const runnerToModel = (runner) => { + const { + id, + description, + maximumTimeout, + accessLevel, + active, + locked, + runUntagged, + tagList = [], + } = runner || {}; + + return { + id, + description, + maximumTimeout, + accessLevel, + active, + locked, + runUntagged, + tagList: tagList.join(', '), + }; +}; + +export const modelToUpdateMutationVariables = (model) => { + const { maximumTimeout, tagList } = model; + + return { + input: { + ...model, + maximumTimeout: maximumTimeout !== '' ? maximumTimeout : null, + tagList: tagList + ?.split(',') + .map((tag) => tag.trim()) + .filter((tag) => Boolean(tag)), + }, + }; +}; diff --git a/app/assets/javascripts/runner/runner_list/index.js b/app/assets/javascripts/runner/runner_list/index.js index 5eba14a7948..16616f00d1e 100644 --- a/app/assets/javascripts/runner/runner_list/index.js +++ b/app/assets/javascripts/runner/runner_list/index.js @@ -12,7 +12,8 @@ export const initRunnerList = (selector = '#js-runner-list') => { return null; } - // TODO `activeRunnersCount` should be implemented using a GraphQL API. + // TODO `activeRunnersCount` should be implemented using a GraphQL API + // https://gitlab.com/gitlab-org/gitlab/-/issues/333806 const { activeRunnersCount, registrationToken, runnerInstallHelpPage } = el.dataset; const apolloProvider = new VueApollo({ diff --git a/app/assets/javascripts/runner/runner_list/runner_list_app.vue b/app/assets/javascripts/runner/runner_list/runner_list_app.vue index 7f3a980ccca..8d39243d609 100644 --- a/app/assets/javascripts/runner/runner_list/runner_list_app.vue +++ b/app/assets/javascripts/runner/runner_list/runner_list_app.vue @@ -1,5 +1,5 @@ <script> -import * as Sentry from '@sentry/browser'; +import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; @@ -7,8 +7,9 @@ import RunnerList from '../components/runner_list.vue'; import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeHelp from '../components/runner_type_help.vue'; -import { INSTANCE_TYPE } from '../constants'; +import { INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants'; import getRunnersQuery from '../graphql/get_runners.query.graphql'; +import { captureException } from '../sentry_utils'; import { fromUrlQueryToSearch, fromSearchToUrl, @@ -16,6 +17,7 @@ import { } from './runner_search_utils'; export default { + name: 'RunnerListApp', components: { RunnerFilteredSearchBar, RunnerList, @@ -59,8 +61,10 @@ export default { pageInfo: runners?.pageInfo || {}, }; }, - error(err) { - this.captureException(err); + error(error) { + createFlash({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); }, }, }, @@ -87,15 +91,12 @@ export default { }, }, }, - errorCaptured(err) { - this.captureException(err); + errorCaptured(error) { + this.reportToSentry(error); }, methods: { - captureException(err) { - Sentry.withScope((scope) => { - scope.setTag('component', 'runner_list_app'); - Sentry.captureException(err); - }); + reportToSentry(error) { + captureException({ error, component: this.$options.name }); }, }, INSTANCE_TYPE, @@ -115,17 +116,17 @@ export default { </div> </div> - <runner-filtered-search-bar v-model="search" namespace="admin_runners" /> + <runner-filtered-search-bar + v-model="search" + namespace="admin_runners" + :active-runners-count="activeRunnersCount" + /> <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> {{ __('No runners found') }} </div> <template v-else> - <runner-list - :runners="runners.items" - :loading="runnersLoading" - :active-runners-count="activeRunnersCount" - /> + <runner-list :runners="runners.items" :loading="runnersLoading" /> <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" /> </template> </div> diff --git a/app/assets/javascripts/runner/runner_list/runner_search_utils.js b/app/assets/javascripts/runner/runner_list/runner_search_utils.js index e45972b81db..9a0dc9c3a32 100644 --- a/app/assets/javascripts/runner/runner_list/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_list/runner_search_utils.js @@ -6,9 +6,10 @@ import { prepareTokens, } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { - PARAM_KEY_SEARCH, PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, + PARAM_KEY_TAG, + PARAM_KEY_SEARCH, PARAM_KEY_SORT, PARAM_KEY_PAGE, PARAM_KEY_AFTER, @@ -40,7 +41,7 @@ export const fromUrlQueryToSearch = (query = window.location.search) => { return { filters: prepareTokens( urlQueryToFilter(query, { - filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE], + filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG], filteredSearchTermKey: PARAM_KEY_SEARCH, legacySpacesDecode: false, }), @@ -56,15 +57,19 @@ export const fromSearchToUrl = ( ) => { const filterParams = { // Defaults - [PARAM_KEY_SEARCH]: null, [PARAM_KEY_STATUS]: [], [PARAM_KEY_RUNNER_TYPE]: [], + [PARAM_KEY_TAG]: [], // Current filters ...filterToQueryObject(processFilters(filters), { filteredSearchTermKey: PARAM_KEY_SEARCH, }), }; + if (!filterParams[PARAM_KEY_SEARCH]) { + filterParams[PARAM_KEY_SEARCH] = null; + } + const isDefaultSort = sort !== DEFAULT_SORT; const isFirstPage = pagination?.page === 1; const otherParams = { @@ -87,12 +92,12 @@ export const fromSearchToVariables = ({ filters = [], sort = null, pagination = variables.search = queryObj[PARAM_KEY_SEARCH]; - // TODO Get more than one value when GraphQL API supports OR for "status" + // TODO Get more than one value when GraphQL API supports OR for "status" or "runner_type" [variables.status] = queryObj[PARAM_KEY_STATUS] || []; - - // TODO Get more than one value when GraphQL API supports OR for "runner type" [variables.type] = queryObj[PARAM_KEY_RUNNER_TYPE] || []; + variables.tagList = queryObj[PARAM_KEY_TAG]; + if (sort) { variables.sort = sort; } diff --git a/app/assets/javascripts/runner/sentry_utils.js b/app/assets/javascripts/runner/sentry_utils.js new file mode 100644 index 00000000000..29de1f9adae --- /dev/null +++ b/app/assets/javascripts/runner/sentry_utils.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +const COMPONENT_TAG = 'vue_component'; + +/** + * Captures an error in a Vue component and sends it + * to Sentry + * + * @param {Object} options + * @param {Error} options.error - Exception or error + * @param {String} options.component - Component name in CamelCase format + */ +export const captureException = ({ error, component }) => { + Sentry.withScope((scope) => { + if (component) { + scope.setTag(COMPONENT_TAG, component); + } + Sentry.captureException(error); + }); +}; diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 0c3f273fec7..b53557c0ec5 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -2,11 +2,13 @@ import Api from '~/api'; import createFlash from '~/flash'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; import * as types from './mutation_types'; +import { loadDataFromLS, setFrequentItemToLS, mergeById } from './utils'; export const fetchGroups = ({ commit }, search) => { commit(types.REQUEST_GROUPS); - Api.groups(search) + Api.groups(search, { order_by: 'similarity' }) .then((data) => { commit(types.RECEIVE_GROUPS_SUCCESS, data); }) @@ -30,7 +32,12 @@ export const fetchProjects = ({ commit, state }, search) => { 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, {}, callback); + Api.groupProjects( + groupId, + search, + { order_by: 'similarity', with_shared: false, include_subgroups: true }, + callback, + ); } else { // The .catch() is due to the API method not handling a rejection properly Api.projects(search, { order_by: 'id' }, callback).catch(() => { @@ -39,6 +46,40 @@ export const fetchProjects = ({ commit, state }, search) => { } }; +export const loadFrequentGroups = async ({ commit }) => { + const data = loadDataFromLS(GROUPS_LOCAL_STORAGE_KEY); + commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data }); + + const promises = data.map((d) => Api.group(d.id)); + try { + const inflatedData = mergeById(await Promise.all(promises), data); + commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data: inflatedData }); + } catch { + createFlash({ message: __('There was a problem fetching recent groups.') }); + } +}; + +export const loadFrequentProjects = async ({ commit }) => { + const data = loadDataFromLS(PROJECTS_LOCAL_STORAGE_KEY); + commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data }); + + const promises = data.map((d) => Api.project(d.id).then((res) => res.data)); + try { + const inflatedData = mergeById(await Promise.all(promises), data); + commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: inflatedData }); + } catch { + createFlash({ message: __('There was a problem fetching recent projects.') }); + } +}; + +export const setFrequentGroup = ({ state }, item) => { + setFrequentItemToLS(GROUPS_LOCAL_STORAGE_KEY, state.frequentItems, item); +}; + +export const setFrequentProject = ({ state }, item) => { + setFrequentItemToLS(PROJECTS_LOCAL_STORAGE_KEY, state.frequentItems, item); +}; + export const setQuery = ({ commit }, { key, value }) => { commit(types.SET_QUERY, { key, value }); }; diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js new file mode 100644 index 00000000000..3abf7cac6ba --- /dev/null +++ b/app/assets/javascripts/search/store/constants.js @@ -0,0 +1,7 @@ +export const MAX_FREQUENT_ITEMS = 5; + +export const MAX_FREQUENCY = 5; + +export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups'; + +export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects'; diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js new file mode 100644 index 00000000000..650af5fa55a --- /dev/null +++ b/app/assets/javascripts/search/store/getters.js @@ -0,0 +1,9 @@ +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; + +export const frequentGroups = (state) => { + return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY]; +}; + +export const frequentProjects = (state) => { + return state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY]; +}; diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js index 1923c8b96ab..4fa88822722 100644 --- a/app/assets/javascripts/search/store/index.js +++ b/app/assets/javascripts/search/store/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; import mutations from './mutations'; import createState from './state'; @@ -8,6 +9,7 @@ Vue.use(Vuex); export const getStoreConfig = ({ query }) => ({ actions, + getters, mutations, state: createState({ query }), }); diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js index a6430b53c4f..5c1c29dc738 100644 --- a/app/assets/javascripts/search/store/mutation_types.js +++ b/app/assets/javascripts/search/store/mutation_types.js @@ -7,3 +7,5 @@ export const RECEIVE_PROJECTS_SUCCESS = 'RECEIVE_PROJECTS_SUCCESS'; export const RECEIVE_PROJECTS_ERROR = 'RECEIVE_PROJECTS_ERROR'; export const SET_QUERY = 'SET_QUERY'; + +export const LOAD_FREQUENT_ITEMS = 'LOAD_FREQUENT_ITEMS'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index 91d7cf66c8f..63156a89738 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -26,4 +26,7 @@ export default { [types.SET_QUERY](state, { key, value }) { state.query[key] = value; }, + [types.LOAD_FREQUENT_ITEMS](state, { key, data }) { + state.frequentItems[key] = data; + }, }; diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index 9a0d61d0b93..5b1429ccc97 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -1,8 +1,14 @@ +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; + const createState = ({ query }) => ({ query, groups: [], fetchingGroups: false, projects: [], fetchingProjects: false, + frequentItems: { + [GROUPS_LOCAL_STORAGE_KEY]: [], + [PROJECTS_LOCAL_STORAGE_KEY]: [], + }, }); export default createState; diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js new file mode 100644 index 00000000000..60c09221ca9 --- /dev/null +++ b/app/assets/javascripts/search/store/utils.js @@ -0,0 +1,80 @@ +import AccessorUtilities from '../../lib/utils/accessor'; +import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY } from './constants'; + +function extractKeys(object, keyList) { + return Object.fromEntries(keyList.map((key) => [key, object[key]])); +} + +export const loadDataFromLS = (key) => { + if (!AccessorUtilities.isLocalStorageAccessSafe()) { + return []; + } + + try { + return JSON.parse(localStorage.getItem(key)) || []; + } catch { + // The LS got in a bad state, let's wipe it + localStorage.removeItem(key); + return []; + } +}; + +export const setFrequentItemToLS = (key, data, itemData) => { + if (!AccessorUtilities.isLocalStorageAccessSafe()) { + return; + } + + const keyList = [ + 'id', + 'avatar_url', + 'name', + 'full_name', + 'name_with_namespace', + 'frequency', + 'lastUsed', + ]; + + try { + const frequentItems = data[key].map((obj) => extractKeys(obj, keyList)); + const item = extractKeys(itemData, keyList); + const existingItemIndex = frequentItems.findIndex((i) => i.id === item.id); + + if (existingItemIndex >= 0) { + // Up the frequency (Max 5) + const currentFrequency = frequentItems[existingItemIndex].frequency; + frequentItems[existingItemIndex].frequency = Math.min(currentFrequency + 1, MAX_FREQUENCY); + frequentItems[existingItemIndex].lastUsed = new Date().getTime(); + } else { + // Only store a max of 5 items + if (frequentItems.length >= MAX_FREQUENT_ITEMS) { + frequentItems.pop(); + } + + frequentItems.push({ ...item, frequency: 1, lastUsed: new Date().getTime() }); + } + + // Sort by frequency and lastUsed + frequentItems.sort((a, b) => { + if (a.frequency > b.frequency) { + return -1; + } else if (a.frequency < b.frequency) { + return 1; + } + return b.lastUsed - a.lastUsed; + }); + + // Note we do not need to commit a mutation here as immediately after this we refresh the page to + // update the search results. + localStorage.setItem(key, JSON.stringify(frequentItems)); + } catch { + // The LS got in a bad state, let's wipe it + localStorage.removeItem(key); + } +}; + +export const mergeById = (inflatedData, storedData) => { + return inflatedData.map((data) => { + const stored = storedData?.find((d) => d.id === data.id) || {}; + return { ...stored, ...data }; + }); +}; diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue index da9252eeacd..45a6ae73fac 100644 --- a/app/assets/javascripts/search/topbar/components/group_filter.vue +++ b/app/assets/javascripts/search/topbar/components/group_filter.vue @@ -1,6 +1,6 @@ <script> import { isEmpty } from 'lodash'; -import { mapState, mapActions } from 'vuex'; +import { mapState, mapActions, mapGetters } from 'vuex'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; import SearchableDropdown from './searchable_dropdown.vue'; @@ -19,13 +19,19 @@ export default { }, computed: { ...mapState(['groups', 'fetchingGroups']), + ...mapGetters(['frequentGroups']), selectedGroup() { return isEmpty(this.initialData) ? ANY_OPTION : this.initialData; }, }, methods: { - ...mapActions(['fetchGroups']), + ...mapActions(['fetchGroups', 'setFrequentGroup', 'loadFrequentGroups']), handleGroupChange(group) { + // If group.id is null we are clearing the filter and don't need to store that in LS. + if (group.id) { + this.setFrequentGroup(group); + } + visitUrl( setUrlParams({ [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null }), ); @@ -44,6 +50,8 @@ export default { :loading="fetchingGroups" :selected-item="selectedGroup" :items="groups" + :frequent-items="frequentGroups" + @first-open="loadFrequentGroups" @search="fetchGroups" @change="handleGroupChange" /> diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue index dbe8ba54216..1ca31db61e5 100644 --- a/app/assets/javascripts/search/topbar/components/project_filter.vue +++ b/app/assets/javascripts/search/topbar/components/project_filter.vue @@ -1,5 +1,5 @@ <script> -import { mapState, mapActions } from 'vuex'; +import { mapState, mapActions, mapGetters } from 'vuex'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; import SearchableDropdown from './searchable_dropdown.vue'; @@ -18,13 +18,19 @@ export default { }, computed: { ...mapState(['projects', 'fetchingProjects']), + ...mapGetters(['frequentProjects']), selectedProject() { return this.initialData ? this.initialData : ANY_OPTION; }, }, methods: { - ...mapActions(['fetchProjects']), + ...mapActions(['fetchProjects', 'setFrequentProject', 'loadFrequentProjects']), handleProjectChange(project) { + // If project.id is null we are clearing the filter and don't need to store that in LS. + if (project.id) { + this.setFrequentProject(project); + } + // This determines if we need to update the group filter or not const queryParams = { ...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }), @@ -47,6 +53,8 @@ export default { :loading="fetchingProjects" :selected-item="selectedProject" :items="projects" + :frequent-items="frequentProjects" + @first-open="loadFrequentProjects" @search="fetchProjects" @change="handleProjectChange" /> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue index 2e2aa052dd8..5653cddda60 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue @@ -2,6 +2,7 @@ import { GlDropdown, GlDropdownItem, + GlDropdownSectionHeader, GlSearchBoxByType, GlLoadingIcon, GlIcon, @@ -16,11 +17,13 @@ import SearchableDropdownItem from './searchable_dropdown_item.vue'; export default { i18n: { clearLabel: __('Clear'), + frequentlySearched: __('Frequently searched'), }, name: 'SearchableDropdown', components: { GlDropdown, GlDropdownItem, + GlDropdownSectionHeader, GlSearchBoxByType, GlLoadingIcon, GlIcon, @@ -61,17 +64,33 @@ export default { required: false, default: () => [], }, + frequentItems: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { searchText: '', + hasBeenOpened: false, }; }, + computed: { + showFrequentItems() { + return !this.searchText && this.frequentItems.length > 0; + }, + }, methods: { isSelected(selected) { return selected.id === this.selectedItem.id; }, openDropdown() { + if (!this.hasBeenOpened) { + this.hasBeenOpened = true; + this.$emit('first-open'); + } + this.$emit('search', this.searchText); }, resetDropdown() { @@ -99,7 +118,7 @@ export default { <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate"> {{ selectedItem[name] }} </span> - <gl-loading-icon v-if="loading" inline class="gl-mr-3" /> + <gl-loading-icon v-if="loading" size="sm" inline class="gl-mr-3" /> <gl-button v-if="!isSelected($options.ANY_OPTION)" v-gl-tooltip @@ -133,6 +152,25 @@ export default { <span data-testid="item-title">{{ $options.ANY_OPTION.name }}</span> </gl-dropdown-item> </div> + <div + v-if="showFrequentItems" + class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2 gl-mb-2" + > + <gl-dropdown-section-header>{{ + $options.i18n.frequentlySearched + }}</gl-dropdown-section-header> + <searchable-dropdown-item + v-for="item in frequentItems" + :key="item.id" + :item="item" + :selected-item="selectedItem" + :search-text="searchText" + :name="name" + :full-name="fullName" + data-testid="frequent-items" + @change="updateDropdown" + /> + </div> <div v-if="!loading"> <searchable-dropdown-item v-for="item in items" @@ -142,6 +180,7 @@ export default { :search-text="searchText" :name="name" :full-name="fullName" + data-testid="searchable-items" @change="updateDropdown" /> </div> 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 498d4af59b4..42d6444e690 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem, GlAvatar } from '@gitlab/ui'; +import { GlDropdownItem, GlAvatar, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; @@ -9,6 +9,9 @@ export default { GlDropdownItem, GlAvatar, }, + directives: { + SafeHtml, + }, props: { item: { type: Object, @@ -62,8 +65,7 @@ export default { :size="32" /> <div class="gl-display-flex gl-flex-direction-column"> - <!-- eslint-disable-next-line vue/no-v-html --> - <span data-testid="item-title" v-html="highlightedItemName">{{ item[name] }}</span> + <span v-safe-html="highlightedItemName" data-testid="item-title"></span> <span class="gl-font-sm gl-text-gray-700" data-testid="item-namespace">{{ truncatedNamespace }}</span> diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 9c133a79607..4f278677c5f 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -4,16 +4,17 @@ import $ from 'jquery'; import { escape, throttle } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { s__, __, sprintf } from '~/locale'; import Tracking from '~/tracking'; import axios from './lib/utils/axios_utils'; +import { spriteIcon } from './lib/utils/common_utils'; import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug, - spriteIcon, -} from './lib/utils/common_utils'; +} from './search_autocomplete_utils'; /** * Search input in top navigation bar. @@ -343,7 +344,10 @@ export class SearchAutocomplete { this.searchInput.on('focus', this.onSearchInputFocus); this.searchInput.on('blur', this.onSearchInputBlur); this.clearInput.on('click', this.onClearInputClick); - this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250)); + this.dropdownContent.on( + 'scroll', + throttle(this.setScrollFade, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + ); this.searchInput.on('click', (e) => { e.stopPropagation(); diff --git a/app/assets/javascripts/search_autocomplete_utils.js b/app/assets/javascripts/search_autocomplete_utils.js new file mode 100644 index 00000000000..a9a0f941e93 --- /dev/null +++ b/app/assets/javascripts/search_autocomplete_utils.js @@ -0,0 +1,19 @@ +import { getPagePath } from './lib/utils/common_utils'; + +export const isInGroupsPage = () => getPagePath() === 'groups'; + +export const isInProjectPage = () => getPagePath() === 'projects'; + +export const getProjectSlug = () => { + if (isInProjectPage()) { + return document?.body?.dataset?.project; + } + return null; +}; + +export const getGroupSlug = () => { + if (isInProjectPage() || isInGroupsPage()) { + return document?.body?.dataset?.group; + } + return null; +}; diff --git a/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue b/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue new file mode 100644 index 00000000000..ce6a1b4888b --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue @@ -0,0 +1,41 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlSprintf, + GlAlert, + GlLink, + }, + inject: ['autoDevopsHelpPagePath', 'autoDevopsPath'], + i18n: { + primaryButtonText: s__('SecurityConfiguration|Enable Auto DevOps'), + body: s__( + 'SecurityConfiguration|Quickly enable all continuous testing and compliance tools by enabling %{linkStart}Auto DevOps%{linkEnd}', + ), + }, + methods: { + dismissMethod() { + this.$emit('dismiss'); + }, + }, +}; +</script> + +<template> + <gl-alert + variant="info" + :primary-button-link="autoDevopsPath" + :primary-button-text="$options.i18n.primaryButtonText" + @dismiss="dismissMethod" + > + <gl-sprintf :message="$options.i18n.body"> + <template #link="{ content }"> + <gl-link :href="autoDevopsHelpPagePath"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue index 2110af1522b..7f250bf1365 100644 --- a/app/assets/javascripts/security_configuration/components/configuration_table.vue +++ b/app/assets/javascripts/security_configuration/components/configuration_table.vue @@ -8,6 +8,7 @@ import { REPORT_TYPE_DAST_PROFILES, REPORT_TYPE_DEPENDENCY_SCANNING, REPORT_TYPE_CONTAINER_SCANNING, + REPORT_TYPE_CLUSTER_IMAGE_SCANNING, REPORT_TYPE_COVERAGE_FUZZING, REPORT_TYPE_API_FUZZING, REPORT_TYPE_LICENSE_COMPLIANCE, @@ -46,6 +47,7 @@ export default { [REPORT_TYPE_DAST_PROFILES]: Upgrade, [REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade, [REPORT_TYPE_CONTAINER_SCANNING]: Upgrade, + [REPORT_TYPE_CLUSTER_IMAGE_SCANNING]: Upgrade, [REPORT_TYPE_COVERAGE_FUZZING]: Upgrade, [REPORT_TYPE_API_FUZZING]: Upgrade, [REPORT_TYPE_LICENSE_COMPLIANCE]: Upgrade, diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 142dade914b..5cb9277040d 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -1,7 +1,6 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { __, s__ } from '~/locale'; -import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql'; import { REPORT_TYPE_SAST, REPORT_TYPE_DAST, @@ -9,11 +8,15 @@ import { REPORT_TYPE_SECRET_DETECTION, REPORT_TYPE_DEPENDENCY_SCANNING, REPORT_TYPE_CONTAINER_SCANNING, + REPORT_TYPE_CLUSTER_IMAGE_SCANNING, REPORT_TYPE_COVERAGE_FUZZING, REPORT_TYPE_API_FUZZING, REPORT_TYPE_LICENSE_COMPLIANCE, } from '~/vue_shared/security_reports/constants'; +import configureSastMutation from '../graphql/configure_sast.mutation.graphql'; +import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql'; + /** * Translations & helpPagePaths for Static Security Configuration Page */ @@ -34,8 +37,8 @@ export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/das }); export const DAST_PROFILES_NAME = __('DAST Scans'); -export const DAST_PROFILES_DESCRIPTION = __( - 'Saved scan settings and target site settings which are reusable.', +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'); @@ -76,6 +79,18 @@ export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath( { anchor: 'configuration' }, ); +export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning'); +export const CLUSTER_IMAGE_SCANNING_DESCRIPTION = __( + 'Check your Kubernetes cluster images for known vulnerabilities.', +); +export const CLUSTER_IMAGE_SCANNING_HELP_PATH = helpPagePath( + 'user/application_security/cluster_image_scanning/index', +); +export const CLUSTER_IMAGE_SCANNING_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/cluster_image_scanning/index', + { anchor: 'configuration' }, +); + export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing'); export const COVERAGE_FUZZING_DESCRIPTION = __( 'Find bugs in your code with coverage-guided fuzzing.', @@ -132,6 +147,12 @@ export const scanners = [ type: REPORT_TYPE_CONTAINER_SCANNING, }, { + name: CLUSTER_IMAGE_SCANNING_NAME, + description: CLUSTER_IMAGE_SCANNING_DESCRIPTION, + helpPath: CLUSTER_IMAGE_SCANNING_HELP_PATH, + type: REPORT_TYPE_CLUSTER_IMAGE_SCANNING, + }, + { name: SECRET_DETECTION_NAME, description: SECRET_DETECTION_DESCRIPTION, helpPath: SECRET_DETECTION_HELP_PATH, @@ -195,6 +216,10 @@ export const securityFeatures = [ helpPath: DEPENDENCY_SCANNING_HELP_PATH, configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, type: REPORT_TYPE_DEPENDENCY_SCANNING, + + // This field will eventually come from the backend, the progress is + // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 + canEnableByMergeRequest: window.gon.features?.secDependencyScanningUiEnable, }, { name: CONTAINER_SCANNING_NAME, @@ -204,12 +229,28 @@ export const securityFeatures = [ type: REPORT_TYPE_CONTAINER_SCANNING, }, { + name: CLUSTER_IMAGE_SCANNING_NAME, + description: CLUSTER_IMAGE_SCANNING_DESCRIPTION, + helpPath: CLUSTER_IMAGE_SCANNING_HELP_PATH, + configurationHelpPath: CLUSTER_IMAGE_SCANNING_CONFIG_HELP_PATH, + type: REPORT_TYPE_CLUSTER_IMAGE_SCANNING, + }, + { name: SECRET_DETECTION_NAME, description: SECRET_DETECTION_DESCRIPTION, helpPath: SECRET_DETECTION_HELP_PATH, configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH, type: REPORT_TYPE_SECRET_DETECTION, + + // This field is currently hardcoded because Secret Detection is always + // available. It will eventually come from the Backend, the progress is + // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/333113 available: true, + + // This field is currently hardcoded because SAST can always be enabled via MR + // It will eventually come from the Backend, the progress is tracked in + // https://gitlab.com/gitlab-org/gitlab/-/issues/331621 + canEnableByMergeRequest: true, }, { name: API_FUZZING_NAME, @@ -247,4 +288,15 @@ export const featureToMutationMap = { }, }), }, + [REPORT_TYPE_SECRET_DETECTION]: { + mutationId: 'configureSecretDetection', + getMutationPayload: (projectPath) => ({ + mutation: configureSecretDetectionMutation, + variables: { + input: { + projectPath, + }, + }, + }), + }, }; diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 518a6ede3de..23cffde1f83 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -46,8 +46,7 @@ export default { return button; }, showManageViaMr() { - const { available, configured, canEnableByMergeRequest } = this.feature; - return canEnableByMergeRequest && available && !configured; + return ManageViaMr.canRender(this.feature); }, cardClasses() { return { 'gl-bg-gray-10': !this.available }; diff --git a/app/assets/javascripts/security_configuration/components/redesigned_app.vue b/app/assets/javascripts/security_configuration/components/redesigned_app.vue index d8a12f4a792..915da378a4f 100644 --- a/app/assets/javascripts/security_configuration/components/redesigned_app.vue +++ b/app/assets/javascripts/security_configuration/components/redesigned_app.vue @@ -2,18 +2,22 @@ import { GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; import FeatureCard from './feature_card.vue'; import SectionLayout from './section_layout.vue'; import UpgradeBanner from './upgrade_banner.vue'; export const i18n = { compliance: s__('SecurityConfiguration|Compliance'), + configurationHistory: s__('SecurityConfiguration|Configuration history'), securityTesting: s__('SecurityConfiguration|Security testing'), - securityTestingDescription: s__( + latestPipelineDescription: s__( `SecurityConfiguration|The status of the tools only applies to the - default branch and is based on the %{linkStart}latest pipeline%{linkEnd}. - Once you've enabled a scan for the default branch, any subsequent feature - branch you create will include the scan.`, + default branch and is based on the %{linkStart}latest pipeline%{linkEnd}.`, + ), + description: s__( + `SecurityConfiguration|Once you've enabled a scan for the default branch, + any subsequent feature branch you create will include the scan.`, ), securityConfiguration: __('Security Configuration'), }; @@ -28,6 +32,7 @@ export default { FeatureCard, SectionLayout, UpgradeBanner, + AutoDevOpsAlert, UserCalloutDismisser, }, props: { @@ -44,6 +49,16 @@ export default { required: false, default: false, }, + autoDevopsEnabled: { + type: Boolean, + required: false, + default: false, + }, + canEnableAutoDevops: { + type: Boolean, + required: false, + default: false, + }, gitlabCiHistoryPath: { type: String, required: false, @@ -64,16 +79,26 @@ export default { canViewCiHistory() { return Boolean(this.gitlabCiPresent && this.gitlabCiHistoryPath); }, + shouldShowDevopsAlert() { + return !this.autoDevopsEnabled && !this.gitlabCiPresent && this.canEnableAutoDevops; + }, }, }; </script> <template> <article> + <user-callout-dismisser + v-if="shouldShowDevopsAlert" + feature-name="security_configuration_devops_alert" + > + <template #default="{ dismiss, shouldShowCallout }"> + <auto-dev-ops-alert v-if="shouldShowCallout" class="gl-mt-3" @dismiss="dismiss" /> + </template> + </user-callout-dismisser> <header> <h1 class="gl-font-size-h1">{{ $options.i18n.securityConfiguration }}</h1> </header> - <user-callout-dismisser v-if="canUpgrade" feature-name="security_configuration_upgrade_banner"> <template #default="{ dismiss, shouldShowCallout }"> <upgrade-banner v-if="shouldShowCallout" @close="dismiss" /> @@ -84,16 +109,19 @@ export default { <gl-tab data-testid="security-testing-tab" :title="$options.i18n.securityTesting"> <section-layout :heading="$options.i18n.securityTesting"> <template #description> - <p - v-if="latestPipelinePath" - data-testid="latest-pipeline-info-security" - class="gl-line-height-20" - > - <gl-sprintf :message="$options.i18n.securityTestingDescription"> - <template #link="{ content }"> - <gl-link :href="latestPipelinePath">{{ content }}</gl-link> - </template> - </gl-sprintf> + <p> + <span data-testid="latest-pipeline-info-security"> + <gl-sprintf + v-if="latestPipelinePath" + :message="$options.i18n.latestPipelineDescription" + > + <template #link="{ content }"> + <gl-link :href="latestPipelinePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + + {{ $options.i18n.description }} </p> <p v-if="canViewCiHistory"> <gl-link data-testid="security-view-history-link" :href="gitlabCiHistoryPath">{{ @@ -106,6 +134,7 @@ export default { <feature-card v-for="feature in augmentedSecurityFeatures" :key="feature.type" + data-testid="security-testing-card" :feature="feature" class="gl-mb-6" /> @@ -115,16 +144,19 @@ export default { <gl-tab data-testid="compliance-testing-tab" :title="$options.i18n.compliance"> <section-layout :heading="$options.i18n.compliance"> <template #description> - <p - v-if="latestPipelinePath" - class="gl-line-height-20" - data-testid="latest-pipeline-info-compliance" - > - <gl-sprintf :message="$options.i18n.securityTestingDescription"> - <template #link="{ content }"> - <gl-link :href="latestPipelinePath">{{ content }}</gl-link> - </template> - </gl-sprintf> + <p> + <span data-testid="latest-pipeline-info-compliance"> + <gl-sprintf + v-if="latestPipelinePath" + :message="$options.i18n.latestPipelineDescription" + > + <template #link="{ content }"> + <gl-link :href="latestPipelinePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + + {{ $options.i18n.description }} </p> <p v-if="canViewCiHistory"> <gl-link data-testid="compliance-view-history-link" :href="gitlabCiHistoryPath">{{ diff --git a/app/assets/javascripts/security_configuration/components/section_layout.vue b/app/assets/javascripts/security_configuration/components/section_layout.vue index 1e1f83a6d99..e351f9b9d8d 100644 --- a/app/assets/javascripts/security_configuration/components/section_layout.vue +++ b/app/assets/javascripts/security_configuration/components/section_layout.vue @@ -11,12 +11,12 @@ export default { </script> <template> - <div class="row"> - <div class="col-lg-5"> + <div class="row gl-line-height-20"> + <div class="col-lg-4"> <h2 class="gl-font-size-h2 gl-mt-0">{{ heading }}</h2> <slot name="description"></slot> </div> - <div class="col-lg-7"> + <div class="col-lg-8"> <slot name="features"></slot> </div> </div> diff --git a/app/assets/javascripts/security_configuration/graphql/configure_secret_detection.mutation.graphql b/app/assets/javascripts/security_configuration/graphql/configure_secret_detection.mutation.graphql new file mode 100644 index 00000000000..e42a8de64f3 --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/configure_secret_detection.mutation.graphql @@ -0,0 +1,6 @@ +mutation configureSecretDetection($input: ConfigureSecretDetectionInput!) { + configureSecretDetection(input: $input) { + successPath + errors + } +} diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index e1dc6f24737..f05bd79258e 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -7,11 +7,7 @@ import { securityFeatures, complianceFeatures } from './components/constants'; import RedesignedSecurityConfigurationApp from './components/redesigned_app.vue'; import { augmentFeatures } from './utils'; -export const initStaticSecurityConfiguration = (el) => { - if (!el) { - return null; - } - +export const initRedesignedSecurityConfiguration = (el) => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ @@ -24,35 +20,60 @@ export const initStaticSecurityConfiguration = (el) => { features, latestPipelinePath, gitlabCiHistoryPath, + autoDevopsHelpPagePath, + autoDevopsPath, } = el.dataset; - if (gon.features.securityConfigurationRedesign) { - const { augmentedSecurityFeatures, augmentedComplianceFeatures } = augmentFeatures( - securityFeatures, - complianceFeatures, - features ? JSON.parse(features) : [], - ); + const { augmentedSecurityFeatures, augmentedComplianceFeatures } = augmentFeatures( + securityFeatures, + complianceFeatures, + features ? JSON.parse(features) : [], + ); + + return new Vue({ + el, + apolloProvider, + provide: { + projectPath, + upgradePath, + autoDevopsHelpPagePath, + autoDevopsPath, + }, + render(createElement) { + return createElement(RedesignedSecurityConfigurationApp, { + props: { + augmentedComplianceFeatures, + augmentedSecurityFeatures, + latestPipelinePath, + gitlabCiHistoryPath, + ...parseBooleanDataAttributes(el, [ + 'gitlabCiPresent', + 'autoDevopsEnabled', + 'canEnableAutoDevops', + ]), + }, + }); + }, + }); +}; + +export const initCESecurityConfiguration = (el) => { + if (!el) { + return null; + } - return new Vue({ - el, - apolloProvider, - provide: { - projectPath, - upgradePath, - }, - render(createElement) { - return createElement(RedesignedSecurityConfigurationApp, { - props: { - augmentedComplianceFeatures, - augmentedSecurityFeatures, - latestPipelinePath, - gitlabCiHistoryPath, - ...parseBooleanDataAttributes(el, ['gitlabCiPresent']), - }, - }); - }, - }); + if (gon.features?.securityConfigurationRedesign) { + return initRedesignedSecurityConfiguration(el); } + + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + const { projectPath, upgradePath } = el.dataset; + return new Vue({ el, apolloProvider, diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js index 071ebff4f21..ec6b93c6193 100644 --- a/app/assets/javascripts/security_configuration/utils.js +++ b/app/assets/javascripts/security_configuration/utils.js @@ -1,6 +1,8 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => { const featuresByType = features.reduce((acc, feature) => { - acc[feature.type] = feature; + acc[feature.type] = convertObjectPropsToCamelCase(feature, { deep: true }); return acc; }, {}); diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue index c608c71714b..4c1f0d892af 100644 --- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -82,7 +82,7 @@ export default { text: this.alertContent.actionText, onClick: (_, toastObject) => { this[this.alertContent.actionName](); - toastObject.goAway(0); + toastObject.hide(); }, }, }; diff --git a/app/assets/javascripts/sentry/index.js b/app/assets/javascripts/sentry/index.js index a875ef84088..176745b4177 100644 --- a/app/assets/javascripts/sentry/index.js +++ b/app/assets/javascripts/sentry/index.js @@ -14,6 +14,7 @@ const index = function index() { release: gon.revision, tags: { revision: gon.revision, + feature_category: gon.feature_category, }, }); diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js index bc3b2f16a6a..a3a2c794a67 100644 --- a/app/assets/javascripts/sentry/sentry_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -59,16 +59,18 @@ const SentryConfig = { configure() { const { dsn, release, tags, whitelistUrls, environment } = this.options; + Sentry.init({ dsn, release, - tags, whitelistUrls, environment, ignoreErrors: this.IGNORE_ERRORS, // TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144 blacklistUrls: this.BLACKLIST_URLS, sampleRate: SAMPLE_RATE, }); + + Sentry.setTags(tags); }, setUser() { diff --git a/app/assets/javascripts/usage_ping_consent.js b/app/assets/javascripts/service_ping_consent.js index 3876aa62b75..f145a1b30db 100644 --- a/app/assets/javascripts/usage_ping_consent.js +++ b/app/assets/javascripts/service_ping_consent.js @@ -1,23 +1,24 @@ import $ from 'jquery'; -import { deprecatedCreateFlash as Flash, hideFlash } from './flash'; +import createFlash, { hideFlash } from './flash'; import axios from './lib/utils/axios_utils'; import { parseBoolean } from './lib/utils/common_utils'; import { __ } from './locale'; export default () => { - $('body').on('click', '.js-usage-consent-action', (e) => { + $('body').on('click', '.js-service-ping-consent-action', (e) => { e.preventDefault(); e.stopImmediatePropagation(); // overwrite rails listener - const { url, checkEnabled, pingEnabled } = e.target.dataset; + const { url, checkEnabled, servicePingEnabled } = e.target.dataset; const data = { application_setting: { version_check_enabled: parseBoolean(checkEnabled), - usage_ping_enabled: parseBoolean(pingEnabled), + service_ping_enabled: parseBoolean(servicePingEnabled), }, }; - const hideConsentMessage = () => hideFlash(document.querySelector('.ping-consent-message')); + const hideConsentMessage = () => + hideFlash(document.querySelector('.service-ping-consent-message')); axios .put(url, data) @@ -26,7 +27,9 @@ export default () => { }) .catch(() => { hideConsentMessage(); - Flash(__('Something went wrong. Try again later.')); + createFlash({ + message: __('Something went wrong. Try again later.'), + }); }); }); }; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index adb573db652..4b3b22f6db3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -47,7 +47,7 @@ export default { <template> <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> {{ assigneeTitle }} - <gl-loading-icon v-if="loading" inline class="align-bottom" /> + <gl-loading-icon v-if="loading" size="sm" inline class="align-bottom" /> <a v-if="editable" class="js-sidebar-dropdown-toggle edit-link float-right" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 9840aa4ed66..c6877226b7d 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,6 +1,6 @@ <script> import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; @@ -113,7 +113,9 @@ export default { }) .catch(() => { this.loading = false; - return new Flash(__('Error occurred when saving assignees')); + return createFlash({ + message: __('Error occurred when saving assignees'), + }); }); }, exposeAvailabilityStatus(users) { 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 d9a974202a3..1dd05d3886e 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -3,6 +3,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import Vue from 'vue'; import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issue_show/constants'; import { __, n__ } from '~/locale'; import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; @@ -80,6 +81,8 @@ export default { selected: [], isSettingAssignees: false, isDirty: false, + oldIid: null, + oldSelected: null, }; }, apollo: { @@ -142,6 +145,14 @@ export default { return this.currentUser.username !== undefined; }, }, + watch: { + iid(_, oldIid) { + if (this.isDirty) { + this.oldIid = oldIid; + this.oldSelected = this.selected; + } + }, + }, created() { assigneesWidget.updateAssignees = this.updateAssignees; }, @@ -157,10 +168,14 @@ export default { variables: { ...this.queryVariables, assigneeUsernames, + iid: this.oldIid || this.iid, }, }) .then(({ data }) => { - this.$emit('assignees-updated', data.issuableSetAssignees.issuable.assignees.nodes); + this.$emit('assignees-updated', { + id: getIdFromGraphQLId(data.issuableSetAssignees.issuable.id), + assignees: data.issuableSetAssignees.issuable.assignees.nodes, + }); return data; }) .catch(() => { @@ -176,7 +191,10 @@ export default { saveAssignees() { if (this.isDirty) { this.isDirty = false; - this.updateAssignees(this.selected.map(({ username }) => username)); + const usernames = this.oldSelected || this.selected; + this.updateAssignees(usernames.map(({ username }) => username)); + this.oldIid = null; + this.oldSelected = null; } this.$el.dispatchEvent(hideDropdownEvent); }, diff --git a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue index 41b3b6c9a45..bed84dc5706 100644 --- a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue +++ b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue @@ -22,8 +22,16 @@ export default { required: false, default: '', }, + pronouns: { + type: String, + required: false, + default: '', + }, }, computed: { + hasPronouns() { + return this.pronouns !== null && this.pronouns.trim() !== ''; + }, isBusy() { return isUserBusy(this.availability); }, @@ -32,9 +40,18 @@ export default { </script> <template> <span :class="containerClasses"> - <gl-sprintf v-if="isBusy" :message="s__('UserAvailability|%{author} (Busy)')"> - <template #author>{{ name }}</template> + <gl-sprintf :message="s__('UserAvailability|%{author} %{spanStart}(Busy)%{spanEnd}')"> + <template #author + >{{ name }} + <span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal" + >({{ pronouns }})</span + ></template + > + <template #span="{ content }" + ><span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal">{{ + content + }}</span> + </template> </gl-sprintf> - <template v-else>{{ name }}</template> </span> </template> diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue index 372368707af..dc0f2b54a7b 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -4,7 +4,7 @@ import Vue from 'vue'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { confidentialityQueries } from '~/sidebar/constants'; +import { confidentialityQueries, Tracking } from '~/sidebar/constants'; import SidebarConfidentialityContent from './sidebar_confidentiality_content.vue'; import SidebarConfidentialityForm from './sidebar_confidentiality_form.vue'; @@ -18,8 +18,8 @@ const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { export default { tracking: { - event: 'click_edit_button', - label: 'right_sidebar', + event: Tracking.editEvent, + label: Tracking.rightSidebarLabel, property: 'confidentiality', }, components: { diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index c3dfa5f8b14..1ff24dec884 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -5,7 +5,13 @@ import { IssuableType } from '~/issue_show/constants'; import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { dateFields, dateTypes, dueDateQueries, startDateQueries } from '~/sidebar/constants'; +import { + dateFields, + dateTypes, + dueDateQueries, + startDateQueries, + Tracking, +} from '~/sidebar/constants'; import SidebarFormattedDate from './sidebar_formatted_date.vue'; import SidebarInheritDate from './sidebar_inherit_date.vue'; @@ -15,8 +21,8 @@ const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { export default { tracking: { - event: 'click_edit_button', - label: 'right_sidebar', + event: Tracking.editEvent, + label: Tracking.rightSidebarLabel, }, directives: { GlTooltip: GlTooltipDirective, @@ -149,6 +155,9 @@ export default { }, }, methods: { + epicDatePopoverEl() { + return this.$refs?.epicDatePopover?.$el; + }, closeForm() { this.$refs.editable.collapse(); this.$el.dispatchEvent(hideDropdownEvent); @@ -249,12 +258,7 @@ export default { :aria-label="$options.i18n.help" data-testid="inherit-date-popover" /> - <gl-popover - :target="() => $refs.epicDatePopover.$el" - triggers="focus" - placement="left" - boundary="viewport" - > + <gl-popover :target="epicDatePopoverEl" triggers="focus" placement="left" boundary="viewport"> <p>{{ $options.i18n.dateHelpValidMessage }}</p> <gl-link :href="$options.dateHelpUrl" target="_blank">{{ $options.i18n.learnMore 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 c3f31a3d220..42d2e456a07 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui'; import $ from 'jquery'; import { mapActions } from 'vuex'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { __, sprintf } from '../../../locale'; import eventHub from '../../event_hub'; @@ -52,7 +52,9 @@ export default { const flashMessage = __( 'Something went wrong trying to change the locked state of this %{issuableDisplayName}', ); - Flash(sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName })); + createFlash({ + message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }), + }); }) .finally(() => { this.closeForm(); diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index e85e416881c..650aa603f18 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -92,11 +92,11 @@ export default { @click="onClickCollapsedIcon" > <gl-icon name="users" /> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <span v-else data-testid="collapsed-count"> {{ participantCount }} </span> </div> <div v-if="showParticipantLabel" class="title hide-collapsed gl-mb-2"> - <gl-loading-icon v-if="loading" :inline="true" /> + <gl-loading-icon v-if="loading" size="sm" :inline="true" /> {{ participantLabel }} </div> <div class="participants-list hide-collapsed"> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue index 88c0b18ccc7..295027186cc 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -35,7 +35,7 @@ export default { <template> <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> {{ reviewerTitle }} - <gl-loading-icon v-if="loading" inline class="align-bottom" /> + <gl-loading-icon v-if="loading" size="sm" inline class="align-bottom" /> <a v-if="editable" class="js-sidebar-dropdown-toggle edit-link float-right" diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index c0bd54c60da..e414aaf719b 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -2,7 +2,7 @@ // NOTE! For the first iteration, we are simply copying the implementation of Assignees // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; @@ -80,7 +80,9 @@ export default { }) .catch(() => { this.loading = false; - return new Flash(__('Error occurred when saving reviewers')); + return createFlash({ + message: __('Error occurred when saving reviewers'), + }); }); }, requestReview(data) { diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index 592cfea5e32..fdf63c23552 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -181,7 +181,7 @@ export default { </gl-dropdown-item> </gl-dropdown> - <gl-loading-icon v-if="isUpdating" :inline="true" /> + <gl-loading-icon v-if="isUpdating" size="sm" :inline="true" /> <severity-token v-else-if="!isDropdownShowing" :severity="selectedItem" /> </div> diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index c80ccc928b3..2e00a23de7c 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -16,11 +16,13 @@ import { IssuableType } from '~/issue_show/constants'; import { __, s__, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { + Tracking, IssuableAttributeState, IssuableAttributeType, issuableAttributesQueries, noAttributeId, -} from '../constants'; + defaultEpicSort, +} from '~/sidebar/constants'; export default { noAttributeId, @@ -28,6 +30,7 @@ export default { issuableAttributesQueries, i18n: { [IssuableAttributeType.Milestone]: __('Milestone'), + expired: __('(expired)'), none: __('None'), }, directives: { @@ -73,9 +76,14 @@ export default { type: String, required: true, validator(value) { - return value === IssuableType.Issue; + return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); }, }, + icon: { + type: String, + required: false, + default: undefined, + }, }, apollo: { currentAttribute: { @@ -117,7 +125,9 @@ export default { return { fullPath: this.attrWorkspacePath, title: this.searchTerm, + in: this.searchTerm && this.issuableAttribute === IssuableType.Epic ? 'TITLE' : undefined, state: this.$options.IssuableAttributeState[this.issuableAttribute], + sort: this.issuableAttribute === IssuableType.Epic ? defaultEpicSort : null, }; }, update(data) { @@ -140,8 +150,8 @@ export default { currentAttribute: null, attributesList: [], tracking: { - label: 'right_sidebar', - event: 'click_edit_button', + event: Tracking.editEvent, + label: Tracking.rightSidebarLabel, property: this.issuableAttribute, }, }; @@ -170,6 +180,9 @@ export default { attributeTypeTitle() { return this.$options.i18n[this.issuableAttribute]; }, + attributeTypeIcon() { + return this.icon || this.issuableAttribute; + }, i18n() { return { noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), { @@ -222,7 +235,8 @@ export default { variables: { fullPath: this.workspacePath, attributeId: - this.issuableAttribute === IssuableAttributeType.Milestone + this.issuableAttribute === IssuableAttributeType.Milestone && + this.issuableType === IssuableType.Issue ? getIdFromGraphQLId(attributeId) : attributeId, iid: this.iid, @@ -253,6 +267,11 @@ export default { attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId) ); }, + isAttributeOverdue(attribute) { + return this.issuableAttribute === IssuableAttributeType.Milestone + ? attribute?.expired + : false; + }, showDropdown() { this.$refs.newDropdown.show(); }, @@ -282,8 +301,10 @@ export default { > <template #collapsed> <div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon"> - <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="issuableAttribute" /> - <span class="collapse-truncated-title">{{ attributeTitle }}</span> + <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="attributeTypeIcon" /> + <span class="collapse-truncated-title"> + {{ attributeTitle }} + </span> </div> <div :data-testid="`select-${issuableAttribute}`" @@ -300,8 +321,13 @@ export default { :attributeUrl="attributeUrl" :currentAttribute="currentAttribute" > - <gl-link class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl"> + <gl-link + class="gl-text-gray-900! gl-font-weight-bold" + :href="attributeUrl" + :data-qa-selector="`${issuableAttribute}_link`" + > {{ attributeTitle }} + <span v-if="isAttributeOverdue(currentAttribute)">{{ $options.i18n.expired }}</span> </gl-link> </slot> </div> @@ -328,6 +354,7 @@ export default { <gl-dropdown-divider /> <gl-loading-icon v-if="$apollo.queries.attributesList.loading" + size="sm" class="gl-py-4" data-testid="loading-icon-dropdown" /> @@ -351,6 +378,7 @@ export default { @click="updateAttribute(attrItem.id)" > {{ attrItem.title }} + <span v-if="isAttributeOverdue(attrItem)">{{ $options.i18n.expired }}</span> </gl-dropdown-item> </slot> </template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 825d7ff5841..7c496cc422a 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -117,9 +117,15 @@ export default { {{ title }} </span> <slot name="title-extra"></slot> - <gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" /> + <gl-loading-icon + v-if="loading || initialLoading" + size="sm" + inline + class="gl-ml-2 hide-collapsed" + /> <gl-loading-icon v-if="loading && isClassicSidebar" + size="sm" inline class="gl-mx-auto gl-my-0 hide-expanded" /> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index e97742a1339..bc7e377a966 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -2,17 +2,18 @@ import { GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; import createFlash from '~/flash'; import { IssuableType } from '~/issue_show/constants'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { subscribedQueries } from '~/sidebar/constants'; +import { subscribedQueries, Tracking } from '~/sidebar/constants'; const ICON_ON = 'notifications'; const ICON_OFF = 'notifications-off'; export default { tracking: { - event: 'click_edit_button', - label: 'right_sidebar', + event: Tracking.editEvent, + label: Tracking.rightSidebarLabel, property: 'subscriptions', }, directives: { @@ -102,7 +103,7 @@ export default { }); }, isLoggedIn() { - return Boolean(gon.current_user_id); + return isLoggedIn(); }, canSubscribe() { return this.emailsDisabled || !this.isLoggedIn; @@ -195,7 +196,7 @@ export default { class="sidebar-collapsed-icon" @click="toggleSubscribed" > - <gl-loading-icon v-if="isLoading" class="sidebar-item-icon is-active" /> + <gl-loading-icon v-if="isLoading" size="sm" class="sidebar-item-icon is-active" /> <gl-icon v-else :name="notificationIcon" :size="16" class="sidebar-item-icon is-active" /> </span> <div v-show="emailsDisabled" class="gl-mt-3 hide-collapsed gl-text-gray-500"> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index f91a78b7f1d..8a14998910b 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import createFlash from '~/flash'; +import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; @@ -52,8 +53,7 @@ export default { return this.issuableType === 'issue'; }, getGraphQLEntityType() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return this.isIssue() ? 'Issue' : 'MergeRequest'; + return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST; }, extractTimelogs(data) { const timelogs = data?.issuable?.timelogs?.nodes || []; 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 87ddbbf256a..9a9d03353dc 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -200,7 +200,7 @@ export default { /> <div class="hide-collapsed gl-line-height-20 gl-text-gray-900"> {{ __('Time tracking') }} - <gl-loading-icon v-if="isTimeTrackingInfoLoading" inline /> + <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline /> <div v-if="!showHelpState" data-testid="helpButton" diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue new file mode 100644 index 00000000000..a9c4203af22 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue @@ -0,0 +1,195 @@ +<script> +import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { produce } from 'immer'; +import createFlash from '~/flash'; +import { __, sprintf } from '~/locale'; +import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants'; +import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils'; +import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; + +export default { + components: { + GlButton, + GlIcon, + TodoButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + isClassicSidebar: { + default: false, + }, + }, + props: { + issuableId: { + type: String, + required: true, + }, + issuableIid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + loading: false, + }; + }, + apollo: { + todoId: { + query() { + return todoQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.issuableIid), + }; + }, + update(data) { + return data.workspace?.issuable?.currentUserTodos.nodes[0]?.id; + }, + result({ data }) { + const currentUserTodos = data.workspace?.issuable?.currentUserTodos?.nodes ?? []; + this.todoId = currentUserTodos[0]?.id; + this.$emit('todoUpdated', currentUserTodos.length > 0); + }, + error() { + createFlash({ + message: sprintf(__('Something went wrong while setting %{issuableType} to-do item.'), { + issuableType: this.issuableType, + }), + }); + }, + }, + }, + computed: { + todoIdQuery() { + return todoQueries[this.issuableType].query; + }, + todoIdQueryVariables() { + return { + fullPath: this.fullPath, + iid: String(this.issuableIid), + }; + }, + isLoading() { + return this.$apollo.queries?.todoId?.loading || this.loading; + }, + hasTodo() { + return Boolean(this.todoId); + }, + todoMutationType() { + if (this.hasTodo) { + return TodoMutationTypes.MarkDone; + } + return TodoMutationTypes.Create; + }, + collapsedButtonIcon() { + return this.hasTodo ? 'todo-done' : 'todo-add'; + }, + tootltipTitle() { + return todoLabel(this.hasTodo); + }, + }, + methods: { + toggleTodo() { + this.loading = true; + this.$apollo + .mutate({ + mutation: todoMutations[this.todoMutationType], + variables: { + input: { + targetId: !this.hasTodo ? this.issuableId : undefined, + id: this.hasTodo ? this.todoId : undefined, + }, + }, + update: ( + store, + { + data: { + todoMutation: { todo }, + }, + }, + ) => { + const queryProps = { + query: this.todoIdQuery, + variables: this.todoIdQueryVariables, + }; + + const sourceData = store.readQuery(queryProps); + const data = produce(sourceData, (draftState) => { + draftState.workspace.issuable.currentUserTodos.nodes = this.hasTodo ? [] : [todo]; + }); + store.writeQuery({ + data, + ...queryProps, + }); + }, + }) + .then( + ({ + data: { + todoMutation: { errors }, + }, + }) => { + if (errors.length) { + createFlash({ + message: errors[0], + }); + } + }, + ) + .catch(() => { + createFlash({ + message: sprintf(__('Something went wrong while setting %{issuableType} to-do item.'), { + issuableType: this.issuableType, + }), + }); + }) + .finally(() => { + this.loading = false; + }); + }, + }, +}; +</script> + +<template> + <div data-testid="sidebar-todo"> + <todo-button + :issuable-type="issuableType" + :issuable-id="issuableId" + :is-todo="hasTodo" + :loading="isLoading" + size="small" + class="hide-collapsed" + @click.stop.prevent="toggleTodo" + /> + <gl-button + v-if="isClassicSidebar" + category="tertiary" + type="reset" + class="sidebar-collapsed-icon sidebar-collapsed-container gl-rounded-0! gl-shadow-none!" + @click.stop.prevent="toggleTodo" + > + <gl-icon + v-gl-tooltip.left.viewport + :title="tootltipTitle" + :size="16" + :class="{ 'todo-undone': hasTodo }" + :name="collapsedButtonIcon" + :aria-label="collapsedButtonIcon" + /> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index f589e7555b3..f7e76cc2b7f 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -85,6 +85,6 @@ export default { :name="collapsedButtonIcon" /> <span v-show="!collapsed" class="issuable-todo-inner">{{ buttonLabel }}</span> - <gl-loading-icon v-show="isActionActive" :inline="true" /> + <gl-loading-icon v-show="isActionActive" size="sm" :inline="true" /> </button> </template> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index e8e69c19d9f..08ee4379c0c 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,18 +1,26 @@ import { IssuableType } from '~/issue_show/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql'; +import epicReferenceQuery from '~/sidebar/queries/epic_reference.query.graphql'; import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql'; import epicSubscribedQuery from '~/sidebar/queries/epic_subscribed.query.graphql'; +import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql'; import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql'; import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.query.graphql'; +import issueTodoQuery from '~/sidebar/queries/issue_todo.query.graphql'; +import mergeRequestMilestone from '~/sidebar/queries/merge_request_milestone.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql'; import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.query.graphql'; +import mergeRequestTodoQuery from '~/sidebar/queries/merge_request_todo.query.graphql'; +import todoCreateMutation from '~/sidebar/queries/todo_create.mutation.graphql'; +import todoMarkDoneMutation from '~/sidebar/queries/todo_mark_done.mutation.graphql'; import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql'; import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql'; @@ -20,6 +28,7 @@ import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscr import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql'; import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql'; import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql'; +import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql'; import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql'; @@ -35,7 +44,9 @@ import projectIssueMilestoneMutation from './queries/project_issue_milestone.mut import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; import projectMilestonesQuery from './queries/project_milestones.query.graphql'; -export const ASSIGNEES_DEBOUNCE_DELAY = 250; +export const ASSIGNEES_DEBOUNCE_DELAY = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; + +export const defaultEpicSort = 'TITLE_ASC'; export const assigneesQueries = { [IssuableType.Issue]: { @@ -87,6 +98,9 @@ export const referenceQueries = { [IssuableType.MergeRequest]: { query: mergeRequestReferenceQuery, }, + [IssuableType.Epic]: { + query: epicReferenceQuery, + }, }; export const dateTypes = { @@ -122,6 +136,11 @@ export const subscribedQueries = { }, }; +export const Tracking = { + editEvent: 'click_edit_button', + rightSidebarLabel: 'right_sidebar', +}; + export const timeTrackingQueries = { [IssuableType.Issue]: { query: issueTimeTrackingQuery, @@ -165,12 +184,19 @@ export const issuableMilestoneQueries = { query: projectIssueMilestoneQuery, mutation: projectIssueMilestoneMutation, }, + [IssuableType.MergeRequest]: { + query: mergeRequestMilestone, + mutation: mergeRequestMilestoneMutation, + }, }; export const milestonesQueries = { [IssuableType.Issue]: { query: projectMilestonesQuery, }, + [IssuableType.MergeRequest]: { + query: projectMilestonesQuery, + }, }; export const IssuableAttributeType = { @@ -187,3 +213,25 @@ export const issuableAttributesQueries = { list: milestonesQueries, }, }; + +export const todoQueries = { + [IssuableType.Epic]: { + query: epicTodoQuery, + }, + [IssuableType.Issue]: { + query: issueTodoQuery, + }, + [IssuableType.MergeRequest]: { + query: mergeRequestTodoQuery, + }, +}; + +export const TodoMutationTypes = { + Create: 'create', + MarkDone: 'mark-done', +}; + +export const todoMutations = { + [TodoMutationTypes.Create]: todoCreateMutation, + [TodoMutationTypes.MarkDone]: todoMarkDoneMutation, +}; diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js index 21cd24b0842..5a3122e83d0 100644 --- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js +++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import { escape } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import createFlash from '~/flash'; import { __ } from '~/locale'; function isValidProjectId(id) { @@ -42,8 +43,10 @@ class SidebarMoveIssue { this.mediator .fetchAutocompleteProjects(searchTerm) .then(callback) - .catch( - () => new window.Flash(__('An error occurred while fetching projects autocomplete.')), + .catch(() => + createFlash({ + message: __('An error occurred while fetching projects autocomplete.'), + }), ); }, renderRow: (project) => ` @@ -76,7 +79,7 @@ class SidebarMoveIssue { this.$confirmButton.disable().addClass('is-loading'); this.mediator.moveIssue().catch(() => { - window.Flash(__('An error occurred while moving the issue.')); + createFlash({ message: __('An error occurred while moving the issue.') }); this.$confirmButton.enable().removeClass('is-loading'); }); } diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 67c72b17f1f..dd1b439c482 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -2,6 +2,8 @@ import $ from 'jquery'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createFlash from '~/flash'; +import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { IssuableType } from '~/issue_show/constants'; @@ -18,6 +20,8 @@ import SidebarConfidentialityWidget from '~/sidebar/components/confidential/side import SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; +import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue'; +import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import { apolloProvider } from '~/sidebar/graphql'; import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; import Translate from '../vue_shared/translate'; @@ -29,6 +33,7 @@ import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; +import { IssuableAttributeType } from './constants'; import SidebarMoveIssue from './lib/sidebar_move_issue'; Vue.use(Translate); @@ -38,6 +43,40 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op return JSON.parse(sidebarOptEl.innerHTML); } +function mountSidebarToDoWidget() { + const el = document.querySelector('.js-issuable-todo'); + + if (!el) { + return false; + } + + const { projectPath, iid, id } = el.dataset; + + return new Vue({ + el, + apolloProvider, + components: { + SidebarTodoWidget, + }, + provide: { + isClassicSidebar: true, + }, + render: (createElement) => + createElement('sidebar-todo-widget', { + props: { + fullPath: projectPath, + issuableId: + isInIssuePage() || isInDesignPage() + ? convertToGraphQLId(TYPE_ISSUE, id) + : convertToGraphQLId(TYPE_MERGE_REQUEST, id), + issuableIid: iid, + issuableType: + isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + }, + }), + }); +} + function getSidebarAssigneeAvailabilityData() { const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input'); return Array.from(sidebarAssigneeEl) @@ -154,7 +193,8 @@ function mountReviewersComponent(mediator) { issuableIid: String(iid), projectPath: fullPath, field: el.dataset.field, - issuableType: isInIssuePage() || isInDesignPage() ? 'issue' : 'merge_request', + issuableType: + isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, }, }), }); @@ -166,6 +206,40 @@ function mountReviewersComponent(mediator) { } } +function mountMilestoneSelect() { + const el = document.querySelector('.js-milestone-select'); + + if (!el) { + return false; + } + + const { canEdit, projectPath, issueIid } = el.dataset; + + return new Vue({ + el, + apolloProvider, + components: { + SidebarDropdownWidget, + }, + provide: { + canUpdate: parseBoolean(canEdit), + isClassicSidebar: true, + }, + render: (createElement) => + createElement('sidebar-dropdown-widget', { + props: { + attrWorkspacePath: projectPath, + workspacePath: projectPath, + iid: issueIid, + issuableType: + isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + issuableAttribute: IssuableAttributeType.Milestone, + icon: 'clock', + }, + }), + }); +} + export function mountSidebarLabels() { const el = document.querySelector('.js-sidebar-labels'); @@ -460,12 +534,14 @@ export function mountSidebar(mediator) { initInviteMembersModal(); initInviteMembersTrigger(); + mountSidebarToDoWidget(); if (isAssigneesWidgetShown) { mountAssigneesComponent(); } else { mountAssigneesComponentDeprecated(mediator); } mountReviewersComponent(mediator); + mountMilestoneSelect(); mountConfidentialComponent(mediator); mountDueDateComponent(mediator); mountReferenceComponent(mediator); diff --git a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql new file mode 100644 index 00000000000..bd10f09aed8 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql @@ -0,0 +1,10 @@ +query epicReference($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + issuable: epic(iid: $iid) { + __typename + id + reference(full: true) + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql new file mode 100644 index 00000000000..1e6f9bad5b2 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql @@ -0,0 +1,14 @@ +query epicTodos($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + issuable: epic(iid: $iid) { + __typename + id + currentUserTodos(state: pending) { + nodes { + id + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql new file mode 100644 index 00000000000..783d36352fe --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql @@ -0,0 +1,14 @@ +query issueTodos($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: issue(iid: $iid) { + __typename + id + currentUserTodos(state: pending) { + nodes { + id + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql new file mode 100644 index 00000000000..5c0edf5acee --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql @@ -0,0 +1,14 @@ +#import "./milestone.fragment.graphql" + +query mergeRequestMilestone($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: mergeRequest(iid: $iid) { + __typename + id + attribute: milestone { + ...MilestoneFragment + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql new file mode 100644 index 00000000000..93a1c9ea925 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql @@ -0,0 +1,14 @@ +query mergeRequestTodos($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: mergeRequest(iid: $iid) { + __typename + id + currentUserTodos(state: pending) { + nodes { + id + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql index 8db5359dac0..2ffd58a2da1 100644 --- a/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql +++ b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql @@ -2,4 +2,5 @@ fragment MilestoneFragment on Milestone { id title webUrl: webPath + expired } diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql index d88ad8b1087..721a71bef63 100644 --- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql @@ -11,6 +11,7 @@ mutation projectIssueMilestoneMutation($fullPath: ID!, $iid: String!, $attribute title id state + expired } } } diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql index 1237640c468..a3ab1ebc872 100644 --- a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql +++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql @@ -3,7 +3,13 @@ query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) { workspace: project(fullPath: $fullPath) { __typename - attributes: milestones(searchTitle: $title, state: $state) { + attributes: milestones( + searchTitle: $title + state: $state + sort: EXPIRED_LAST_DUE_DATE_ASC + first: 20 + includeAncestors: true + ) { nodes { ...MilestoneFragment state diff --git a/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql b/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql new file mode 100644 index 00000000000..4675db9153e --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql @@ -0,0 +1,9 @@ +mutation issuableTodoCreate($input: TodoCreateInput!) { + todoMutation: todoCreate(input: $input) { + __typename + todo { + id + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql b/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql new file mode 100644 index 00000000000..8253e5e82bc --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql @@ -0,0 +1,9 @@ +mutation issuableTodoMarkDone($input: TodoMarkDoneInput!) { + todoMutation: todoMarkDone(input: $input) { + __typename + todo { + id + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql b/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql index b45b6b46c8f..28a47735143 100644 --- a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql @@ -1,6 +1,7 @@ mutation($projectPath: ID!, $iid: String!, $healthStatus: HealthStatus) { updateIssue(input: { projectPath: $projectPath, iid: $iid, healthStatus: $healthStatus }) { - issue { + issuable: issue { + id healthStatus } errors diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql new file mode 100644 index 00000000000..368f06fac7f --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql @@ -0,0 +1,17 @@ +mutation mergeRequestSetMilestone($fullPath: ID!, $iid: String!, $attributeId: ID) { + issuableSetAttribute: mergeRequestSetMilestone( + input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId } + ) { + __typename + errors + issuable: mergeRequest { + __typename + id + attribute: milestone { + title + id + state + } + } + } +} diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 88501f2c305..ace2a163adc 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -1,4 +1,5 @@ import sidebarDetailsIssueQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.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'; @@ -88,7 +89,7 @@ export default class SidebarService { return gqClient.mutate({ mutation: reviewerRereviewMutation, variables: { - userId: convertToGraphQLId('User', `${userId}`), // eslint-disable-line @gitlab/require-i18n-strings + userId: convertToGraphQLId(TYPE_USER, `${userId}`), projectPath: this.fullPath, iid: this.iid.toString(), }, diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 3595354da80..0a5e44a9b95 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,7 +1,7 @@ import Store from 'ee_else_ce/sidebar/stores/sidebar_store'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; -import { deprecatedCreateFlash as Flash } from '../flash'; import { visitUrl } from '../lib/utils/url_utility'; import Service from './services/sidebar_service'; @@ -74,7 +74,11 @@ export default class SidebarMediator { .then(([restResponse, graphQlResponse]) => { this.processFetchedData(restResponse.data, graphQlResponse.data); }) - .catch(() => new Flash(__('Error occurred when fetching sidebar data'))); + .catch(() => + createFlash({ + message: __('Error occurred when fetching sidebar data'), + }), + ); } processFetchedData(data) { diff --git a/app/assets/javascripts/smart_interval.js b/app/assets/javascripts/smart_interval.js index 15d04dadb15..6d77952f24e 100644 --- a/app/assets/javascripts/smart_interval.js +++ b/app/assets/javascripts/smart_interval.js @@ -3,6 +3,35 @@ import $ from 'jquery'; /** * Instances of SmartInterval extend the functionality of `setInterval`, make it configurable * and controllable by a public API. + * + * This component has two intervals: + * + * - current interval - when the page is visible - defined by `startingInterval`, `maxInterval`, and `incrementByFactorOf` + * - Example: + * - `startingInterval: 10000`, `maxInterval: 240000`, `incrementByFactorOf: 2` + * - results in `10s, 20s, 40s, 80s, ..., 240s`, it stops increasing at `240s` and keeps this interval indefinitely. + * - hidden interval - when the page is not visible + * + * Visibility transitions: + * + * - `visible -> not visible` + * - `document.addEventListener('visibilitychange', () => ...)` + * + * > This event fires with a visibilityState of hidden when a user navigates to a new page, switches tabs, closes the tab, minimizes or closes the browser, or, on mobile, switches from the browser to a different app. + * + * Source [Document: visibilitychange event - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event) + * + * - `window.addEventListener('blur', () => ...)` - every time user clicks somewhere else then in the browser page + * - `not visible -> visible` + * - `document.addEventListener('visibilitychange', () => ...)` same as the transition `visible -> not visible` + * - `window.addEventListener('focus', () => ...)` + * + * The combination of these two listeners can result in an unexpected resumption of polling: + * + * - switch to a different window (causes `blur`) + * - switch to a different desktop (causes `visibilitychange` (not visible)) + * - switch back to the original desktop (causes `visibilitychange` (visible)) + * - *now the polling happens even in window that user doesn't work in* */ export default class SmartInterval { diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index c53d0575752..f07fb9d926a 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -2,7 +2,7 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import eventHub from '~/blob/components/eventhub'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { redirectTo, joinPaths } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { @@ -135,7 +135,9 @@ export default { const defaultErrorMsg = this.newSnippet ? SNIPPET_CREATE_MUTATION_ERROR : SNIPPET_UPDATE_MUTATION_ERROR; - Flash(sprintf(defaultErrorMsg, { err })); + createFlash({ + message: sprintf(defaultErrorMsg, { err }), + }); this.isUpdating = false; }, getAttachedFiles() { diff --git a/app/assets/javascripts/snippets/components/embed_dropdown.vue b/app/assets/javascripts/snippets/components/embed_dropdown.vue index ad1b08a5a07..0fdbc89a038 100644 --- a/app/assets/javascripts/snippets/components/embed_dropdown.vue +++ b/app/assets/javascripts/snippets/components/embed_dropdown.vue @@ -60,7 +60,7 @@ export default { class="gl-dropdown-text-py-0 gl-dropdown-text-block" data-testid="input" > - <gl-form-input-group :value="value" readonly select-on-click :aria-label="name"> + <gl-form-input-group :value="value" readonly select-on-click :label="name"> <template #append> <gl-button v-gl-tooltip.hover diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 612b4c7d2e3..fe169775f96 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -6,13 +6,13 @@ import axios from '~/lib/utils/axios_utils'; import { getBaseURL, joinPaths } from '~/lib/utils/url_utility'; import { sprintf } from '~/locale'; import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants'; -import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; export default { components: { BlobHeaderEdit, GlLoadingIcon, - EditorLite, + SourceEditor, }, inheritAttrs: false, props: { @@ -85,7 +85,7 @@ export default { size="lg" class="loading-animation prepend-top-20 gl-mb-6" /> - <editor-lite + <source-editor v-else :value="blob.content" :file-global-id="blob.id" diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index bf19b63650e..a8f95748e7e 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -274,7 +274,7 @@ export default { data-qa-selector="delete_snippet_button" @click="deleteSnippet" > - <gl-loading-icon v-if="isDeleting" inline /> + <gl-loading-icon v-if="isDeleting" size="sm" inline /> {{ __('Delete snippet') }} </gl-button> </template> diff --git a/app/assets/javascripts/sortable/sortable_config.js b/app/assets/javascripts/sortable/sortable_config.js index 43ef5d66422..a4c4cb7f101 100644 --- a/app/assets/javascripts/sortable/sortable_config.js +++ b/app/assets/javascripts/sortable/sortable_config.js @@ -4,4 +4,5 @@ export default { fallbackClass: 'is-dragging', fallbackOnBody: true, ghostClass: 'is-ghost', + fallbackTolerance: 1, }; diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js index eb3eaa66df5..7cba445d9b1 100644 --- a/app/assets/javascripts/star.js +++ b/app/assets/javascripts/star.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { deprecatedCreateFlash as Flash } from './flash'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { spriteIcon } from './lib/utils/common_utils'; import { __, s__ } from './locale'; @@ -28,7 +28,11 @@ export default class Star { $this.prepend(spriteIcon('star', iconClasses)); } }) - .catch(() => Flash(__('Star toggle failed. Try again later.'))); + .catch(() => + createFlash({ + message: __('Star toggle failed. Try again later.'), + }), + ); }); } } diff --git a/app/assets/javascripts/static_site_editor/components/edit_drawer.vue b/app/assets/javascripts/static_site_editor/components/edit_drawer.vue index 0685dfdb1d1..781e23cd6c8 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_drawer.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_drawer.vue @@ -21,7 +21,7 @@ export default { </script> <template> <gl-drawer class="gl-pt-8" :open="isOpen" @close="$emit('close')"> - <template #header>{{ __('Page settings') }}</template> + <template #title>{{ __('Page settings') }}</template> <front-matter-controls :settings="settings" @updateSettings="$emit('updateSettings', $event)" /> </gl-drawer> </template> diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index b08bf26e1dc..ab7fd0542bf 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -28,7 +28,8 @@ export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit'; export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request'; export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor'; -export const USAGE_PING_TRACKING_ACTION_CREATE_COMMIT = 'static_site_editor_commits'; -export const USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST = 'static_site_editor_merge_requests'; +export const SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT = 'static_site_editor_commits'; +export const SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST = + 'static_site_editor_merge_requests'; export const MR_META_LOCAL_STORAGE_KEY = 'sse-merge-request-meta-storage-key'; diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js index 57f32ab4847..4ad2e2618ac 100644 --- a/app/assets/javascripts/static_site_editor/image_repository.js +++ b/app/assets/javascripts/static_site_editor/image_repository.js @@ -1,10 +1,13 @@ -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import { getBinary } from './services/image_service'; const imageRepository = () => { const images = new Map(); - const flash = (message) => new Flash(message); + const flash = (message) => + createFlash({ + message, + }); const add = (file, url) => { getBinary(file) diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue index 99bb2080610..5ce2c17f8de 100644 --- a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue @@ -81,11 +81,13 @@ export default { :invalid-feedback="urlError" > <gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" /> - <gl-sprintf slot="description" :message="description" class="text-gl-muted"> - <template #id> - <strong>{{ __('0t1DgySidms') }}</strong> - </template> - </gl-sprintf> + <template #description> + <gl-sprintf :message="description" class="text-gl-muted"> + <template #id> + <strong>{{ __('0t1DgySidms') }}</strong> + </template> + </gl-sprintf> + </template> </gl-form-group> </gl-modal> </template> diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js index ecb7f60a421..99534413d92 100644 --- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js @@ -9,8 +9,8 @@ import { SUBMIT_CHANGES_MERGE_REQUEST_ERROR, TRACKING_ACTION_CREATE_COMMIT, TRACKING_ACTION_CREATE_MERGE_REQUEST, - USAGE_PING_TRACKING_ACTION_CREATE_COMMIT, - USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, + SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT, + SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE, DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION, } from '../constants'; @@ -58,7 +58,7 @@ const createUpdateSourceFileAction = (sourcePath, content) => [ const commit = (projectId, message, branch, actions) => { Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT); - Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_COMMIT); + Api.trackRedisCounterEvent(SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT); return Api.commitMultiple( projectId, @@ -74,7 +74,7 @@ const commit = (projectId, message, branch, actions) => { const createMergeRequest = (projectId, title, description, sourceBranch, targetBranch) => { Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_MERGE_REQUEST); - Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST); + Api.trackRedisCounterEvent(SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST); return Api.createProjectMergeRequest( projectId, diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index 3b2210b9ef2..93353b400e5 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import 'deckar01-task_list'; import { __ } from '~/locale'; -import { deprecatedCreateFlash as Flash } from './flash'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; export default class TaskList { @@ -22,7 +22,9 @@ export default class TaskList { errorMessages = e.response.data.errors.join(' '); } - return new Flash(errorMessages || __('Update failed'), 'alert'); + return createFlash({ + message: errorMessages || __('Update failed'), + }); }; this.init(); diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue index 2577664a5e8..d066834540f 100644 --- a/app/assets/javascripts/terraform/components/states_table.vue +++ b/app/assets/javascripts/terraform/components/states_table.vue @@ -137,7 +137,7 @@ export default { <div v-if="item.loadingLock" class="gl-mx-3"> <p class="gl-display-flex gl-justify-content-start gl-align-items-baseline gl-m-0"> - <gl-loading-icon class="gl-pr-1" /> + <gl-loading-icon size="sm" class="gl-pr-1" /> {{ loadingLockText(item) }} </p> </div> @@ -146,7 +146,7 @@ export default { <p class="gl-display-flex gl-justify-content-start gl-align-items-baseline gl-m-0 gl-text-red-500" > - <gl-loading-icon class="gl-pr-1" /> + <gl-loading-icon size="sm" class="gl-pr-1" /> {{ $options.i18n.removing }} </p> </div> diff --git a/app/assets/javascripts/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue index a18f33ebb1f..7eb79120fb8 100644 --- a/app/assets/javascripts/terraform/components/terraform_list.vue +++ b/app/assets/javascripts/terraform/components/terraform_list.vue @@ -98,7 +98,7 @@ export default { <section> <gl-tabs> <gl-tab> - <template slot="title"> + <template #title> <p class="gl-m-0"> {{ s__('Terraform|States') }} <gl-badge v-if="statesCount">{{ statesCount }}</gl-badge> diff --git a/app/assets/javascripts/toggle_buttons.js b/app/assets/javascripts/toggle_buttons.js index 03c975d5fe8..5b85107991a 100644 --- a/app/assets/javascripts/toggle_buttons.js +++ b/app/assets/javascripts/toggle_buttons.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { deprecatedCreateFlash as Flash } from './flash'; +import createFlash from './flash'; import { parseBoolean } from './lib/utils/common_utils'; import { __ } from './locale'; @@ -42,7 +42,9 @@ function onToggleClicked(toggle, input, clickCallback) { $(input).trigger('trigger-change'); }) .catch(() => { - Flash(__('Something went wrong when toggling the button')); + createFlash({ + message: __('Something went wrong when toggling the button'), + }); }); } diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue new file mode 100644 index 00000000000..24565c441d8 --- /dev/null +++ b/app/assets/javascripts/token_access/components/token_access.vue @@ -0,0 +1,206 @@ +<script> +import { GlButton, GlCard, GlFormInput, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { __, s__ } from '~/locale'; +import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql'; +import removeProjectCIJobTokenScopeMutation from '../graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql'; +import updateCIJobTokenScopeMutation from '../graphql/mutations/update_ci_job_token_scope.mutation.graphql'; +import getCIJobTokenScopeQuery from '../graphql/queries/get_ci_job_token_scope.query.graphql'; +import getProjectsWithCIJobTokenScopeQuery from '../graphql/queries/get_projects_with_ci_job_token_scope.query.graphql'; +import TokenProjectsTable from './token_projects_table.vue'; + +export default { + i18n: { + toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'), + toggleHelpText: s__( + `CICD|Select projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable.`, + ), + cardHeaderTitle: s__('CICD|Add an existing project to the scope'), + addProject: __('Add project'), + cancel: __('Cancel'), + addProjectPlaceholder: __('Paste project path (i.e. gitlab-org/gitlab)'), + projectsFetchError: __('There was a problem fetching the projects'), + scopeFetchError: __('There was a problem fetching the job token scope value'), + }, + components: { + GlButton, + GlCard, + GlFormInput, + GlLoadingIcon, + GlToggle, + TokenProjectsTable, + }, + inject: { + fullPath: { + default: '', + }, + }, + apollo: { + jobTokenScopeEnabled: { + query: getCIJobTokenScopeQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update(data) { + return data.project.ciCdSettings.jobTokenScopeEnabled; + }, + error() { + createFlash({ message: this.$options.i18n.scopeFetchError }); + }, + }, + projects: { + query: getProjectsWithCIJobTokenScopeQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update(data) { + return data.project?.ciJobTokenScope?.projects?.nodes ?? []; + }, + error() { + createFlash({ message: this.$options.i18n.projectsFetchError }); + }, + }, + }, + data() { + return { + jobTokenScopeEnabled: null, + targetProjectPath: '', + projects: [], + }; + }, + computed: { + isProjectPathEmpty() { + return this.targetProjectPath === ''; + }, + }, + methods: { + async updateCIJobTokenScope() { + try { + const { + data: { + ciCdSettingsUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateCIJobTokenScopeMutation, + variables: { + input: { + fullPath: this.fullPath, + jobTokenScopeEnabled: this.jobTokenScopeEnabled, + }, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + createFlash({ message: error }); + } finally { + if (this.jobTokenScopeEnabled) { + this.getProjects(); + } + } + }, + async addProject() { + try { + const { + data: { + ciJobTokenScopeAddProject: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: addProjectCIJobTokenScopeMutation, + variables: { + input: { + projectPath: this.fullPath, + targetProjectPath: this.targetProjectPath, + }, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + createFlash({ message: error }); + } finally { + this.clearTargetProjectPath(); + this.getProjects(); + } + }, + async removeProject(removeTargetPath) { + try { + const { + data: { + ciJobTokenScopeRemoveProject: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: removeProjectCIJobTokenScopeMutation, + variables: { + input: { + projectPath: this.fullPath, + targetProjectPath: removeTargetPath, + }, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + createFlash({ message: error }); + } finally { + this.getProjects(); + } + }, + clearTargetProjectPath() { + this.targetProjectPath = ''; + }, + getProjects() { + this.$apollo.queries.projects.refetch(); + }, + }, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> + <template v-else> + <gl-toggle + v-model="jobTokenScopeEnabled" + :label="$options.i18n.toggleLabelTitle" + :help="$options.i18n.toggleHelpText" + @change="updateCIJobTokenScope" + /> + <div v-if="jobTokenScopeEnabled" data-testid="token-section"> + <gl-card class="gl-mt-5"> + <template #header> + <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5> + </template> + <template #default> + <gl-form-input + v-model="targetProjectPath" + :placeholder="$options.i18n.addProjectPlaceholder" + /> + </template> + <template #footer> + <gl-button + variant="confirm" + :disabled="isProjectPathEmpty" + data-testid="add-project-button" + @click="addProject" + > + {{ $options.i18n.addProject }} + </gl-button> + <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button> + </template> + </gl-card> + + <token-projects-table :projects="projects" @removeProject="removeProject" /> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue new file mode 100644 index 00000000000..777eda1c4d7 --- /dev/null +++ b/app/assets/javascripts/token_access/components/token_projects_table.vue @@ -0,0 +1,81 @@ +<script> +import { GlButton, GlTable } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +const defaultTableClasses = { + thClass: 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!', +}; + +export default { + i18n: { + emptyText: s__('CI/CD|No projects have been added to the scope'), + }, + fields: [ + { + key: 'project', + label: __('Projects that can be accessed'), + tdClass: 'gl-p-5!', + ...defaultTableClasses, + columnClass: 'gl-w-85p', + }, + { + key: 'actions', + label: '', + tdClass: 'gl-p-5! gl-text-right', + ...defaultTableClasses, + columnClass: 'gl-w-15p', + }, + ], + components: { + GlButton, + GlTable, + }, + inject: { + fullPath: { + default: '', + }, + }, + props: { + projects: { + type: Array, + required: true, + }, + }, + methods: { + removeProject(project) { + this.$emit('removeProject', project); + }, + }, +}; +</script> +<template> + <gl-table + :items="projects" + :fields="$options.fields" + :tbody-tr-attr="{ 'data-testid': 'projects-token-table-row' }" + :empty-text="$options.i18n.emptyText" + show-empty + stacked="sm" + fixed + > + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + + <template #cell(project)="{ item }"> + {{ item.name }} + </template> + + <template #cell(actions)="{ item }"> + <gl-button + v-if="item.fullPath !== fullPath" + category="primary" + variant="danger" + icon="remove" + :aria-label="__('Remove access')" + data-testid="remove-project-button" + @click="removeProject(item.fullPath)" + /> + </template> + </gl-table> +</template> diff --git a/app/assets/javascripts/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql new file mode 100644 index 00000000000..0a7c76dd580 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql @@ -0,0 +1,5 @@ +mutation addProjectCIJobTokenScope($input: CiJobTokenScopeAddProjectInput!) { + ciJobTokenScopeAddProject(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql new file mode 100644 index 00000000000..5107ea30cd1 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql @@ -0,0 +1,5 @@ +mutation removeProjectCIJobTokenScope($input: CiJobTokenScopeRemoveProjectInput!) { + ciJobTokenScopeRemoveProject(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql new file mode 100644 index 00000000000..d99f2e3597d --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql @@ -0,0 +1,8 @@ +mutation updateCIJobTokenScope($input: CiCdSettingsUpdateInput!) { + ciCdSettingsUpdate(input: $input) { + ciCdSettings { + jobTokenScopeEnabled + } + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql new file mode 100644 index 00000000000..d4f559c3701 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql @@ -0,0 +1,7 @@ +query getCIJobTokenScope($fullPath: ID!) { + project(fullPath: $fullPath) { + ciCdSettings { + jobTokenScopeEnabled + } + } +} diff --git a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql new file mode 100644 index 00000000000..bec0710a1dd --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql @@ -0,0 +1,12 @@ +query getProjectsWithCIJobTokenScope($fullPath: ID!) { + project(fullPath: $fullPath) { + ciJobTokenScope { + projects { + nodes { + name + fullPath + } + } + } + } +} diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js new file mode 100644 index 00000000000..6a29883290a --- /dev/null +++ b/app/assets/javascripts/token_access/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import TokenAccess from './components/token_access.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const initTokenAccess = (containerId = 'js-ci-token-access-app') => { + const containerEl = document.getElementById(containerId); + + if (!containerEl) { + return false; + } + + const { fullPath } = containerEl.dataset; + + return new Vue({ + el: containerEl, + apolloProvider, + provide: { + fullPath, + }, + render(createElement) { + return createElement(TokenAccess); + }, + }); +}; diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js index e0ba7dba97f..3714cac3fba 100644 --- a/app/assets/javascripts/tracking/index.js +++ b/app/assets/javascripts/tracking/index.js @@ -34,6 +34,12 @@ const addExperimentContext = (opts) => { return options; }; +const renameKey = (o, oldKey, newKey) => { + const ret = {}; + delete Object.assign(ret, o, { [newKey]: o[oldKey] })[oldKey]; + return ret; +}; + const createEventPayload = (el, { suffix = '' } = {}) => { const { trackAction, @@ -186,15 +192,18 @@ export default class Tracking { (context) => context.schema !== standardContext.schema, ); - const mappedConfig = { - forms: { whitelist: config.forms?.allow || [] }, - fields: { whitelist: config.fields?.allow || [] }, - }; + const mappedConfig = {}; + if (config.forms) mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist'); + if (config.fields) mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist'); const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts); - if (document.readyState !== 'loading') enabler(); - else document.addEventListener('DOMContentLoaded', enabler); + if (document.readyState === 'complete') enabler(); + else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'complete') enabler(); + }); + } } static mixin(opts = {}) { diff --git a/app/assets/javascripts/user_lists/components/user_lists.vue b/app/assets/javascripts/user_lists/components/user_lists.vue index 80be894c689..0e3c6b396db 100644 --- a/app/assets/javascripts/user_lists/components/user_lists.vue +++ b/app/assets/javascripts/user_lists/components/user_lists.vue @@ -3,12 +3,8 @@ import { GlBadge, GlButton } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapState, mapActions } from 'vuex'; import EmptyState from '~/feature_flags/components/empty_state.vue'; -import { - buildUrlWithCurrentLocation, - getParameterByName, - historyPushState, -} from '~/lib/utils/common_utils'; -import { objectToQuery } from '~/lib/utils/url_utility'; +import { buildUrlWithCurrentLocation, historyPushState } from '~/lib/utils/common_utils'; +import { objectToQuery, getParameterByName } from '~/lib/utils/url_utility'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import UserListsTable from './user_lists_table.vue'; diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 21368edb6af..0e25f71fe05 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -44,6 +44,7 @@ const populateUserInfo = (user) => { bioHtml: sanitize(userData.bio_html), workInformation: userData.work_information, websiteUrl: userData.website_url, + pronouns: userData.pronouns, loaded: true, }); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue index dc766176617..68f4609f14d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue @@ -14,27 +14,25 @@ export default { }; </script> <template> - <table class="table m-0"> - <thead class="thead-white text-nowrap"> - <tr class="d-none d-sm-table-row"> - <th class="w-0"></th> - <th>{{ __('Artifact') }}</th> - <th class="w-50"></th> - <th>{{ __('Job') }}</th> - </tr> - </thead> + <div class="gl-pl-7"> + <table class="table m-0"> + <thead class="thead-white text-nowrap"> + <tr class="d-none d-sm-table-row"> + <th>{{ __('Artifact') }}</th> + <th>{{ __('Job') }}</th> + </tr> + </thead> - <tbody> - <tr v-for="item in artifacts" :key="item.text"> - <td class="w-0"></td> - <td> - <gl-link :href="item.url" target="_blank">{{ item.text }}</gl-link> - </td> - <td class="w-0"></td> - <td> - <gl-link :href="item.job_path">{{ item.job_name }}</gl-link> - </td> - </tr> - </tbody> - </table> + <tbody> + <tr v-for="item in artifacts" :key="item.text"> + <td> + <gl-link :href="item.url" target="_blank">{{ item.text }}</gl-link> + </td> + <td> + <gl-link :href="item.job_path">{{ item.job_name }}</gl-link> + </td> + </tr> + </tbody> + </table> + </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue index 410d2740e1d..bb1837399ed 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue @@ -136,7 +136,7 @@ export default { <template> <div class="mr-info-list clearfix mr-memory-usage js-mr-memory-usage"> <p v-if="shouldShowLoading" class="usage-info js-usage-info usage-info-loading"> - <gl-loading-icon class="usage-info-load-spinner" />{{ + <gl-loading-icon size="sm" class="usage-info-load-spinner" />{{ s__('mrWidget|Loading deployment statistics') }} </p> 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 33809b953ee..0ac98f6c982 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 @@ -122,7 +122,7 @@ export default { </div> <div v-if="!isCollapsed" class="mr-widget-grouped-section"> <div v-if="isLoadingExpanded" class="report-block-container"> - <gl-loading-icon inline /> {{ __('Loading...') }} + <gl-loading-icon size="sm" inline /> {{ __('Loading...') }} </div> <smart-virtual-list v-else-if="fullData" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index a619ae9c351..b75f2dce54e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -58,13 +58,13 @@ export default { <template v-else> <button - class="btn-blank btn s32 square gl-mr-3" + class="btn-blank btn s32 square" type="button" :aria-label="ariaLabel" :disabled="isLoading" @click="toggleCollapsed" > - <gl-loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" size="sm" /> <gl-icon v-else :name="arrowIconName" class="js-icon" /> </button> <template v-if="isCollapsed"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index f1230e2fdeb..5e401fc17e9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -1,16 +1,17 @@ <script> -/* eslint-disable vue/no-v-html */ import { GlButton, GlDropdown, GlDropdownSectionHeader, GlDropdownItem, + GlLink, GlTooltipDirective, GlModalDirective, + GlSafeHtmlDirective as SafeHtml, + GlSprintf, } from '@gitlab/ui'; -import { escape } from 'lodash'; import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility'; -import { n__, s__, sprintf } from '~/locale'; +import { s__ } from '~/locale'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import MrWidgetHowToMergeModal from './mr_widget_how_to_merge_modal.vue'; @@ -27,10 +28,13 @@ export default { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, + GlLink, + GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, GlModalDirective, + SafeHtml, }, props: { mr: { @@ -42,19 +46,6 @@ export default { shouldShowCommitsBehindText() { return this.mr.divergedCommitsCount > 0; }, - commitsBehindText() { - return sprintf( - s__( - 'mrWidget|The source branch is %{commitsBehindLinkStart}%{commitsBehind}%{commitsBehindLinkEnd} the target branch', - ), - { - commitsBehindLinkStart: `<a href="${escape(this.mr.targetBranchPath)}">`, - commitsBehind: n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount), - commitsBehindLinkEnd: '</a>', - }, - false, - ); - }, branchNameClipboardData() { // This supports code in app/assets/javascripts/copy_to_clipboard.js that // works around ClipboardJS limitations to allow the context-specific @@ -100,10 +91,10 @@ export default { <strong> {{ s__('mrWidget|Request to merge') }} <tooltip-on-truncate + v-safe-html="mr.sourceBranchLink" :title="mr.sourceBranch" truncate-target="child" class="label-branch label-truncate js-source-branch" - v-html="mr.sourceBranchLink" /><clipboard-button data-testid="mr-widget-copy-clipboard" :text="branchNameClipboardData" @@ -119,11 +110,15 @@ export default { <a :href="mr.targetBranchTreePath" class="js-target-branch"> {{ mr.targetBranch }} </a> </tooltip-on-truncate> </strong> - <div - v-if="shouldShowCommitsBehindText" - class="diverged-commits-count" - v-html="commitsBehindText" - ></div> + <div v-if="shouldShowCommitsBehindText" class="diverged-commits-count"> + <gl-sprintf :message="s__('mrWidget|The source branch is %{link} the target branch')"> + <template #link> + <gl-link :href="mr.targetBranchPath">{{ + n__('%d commit behind', '%d commits behind', mr.divergedCommitsCount) + }}</gl-link> + </template> + </gl-sprintf> + </div> </div> <div class="branch-actions d-flex"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 6c162a06161..9bb955c534f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -171,7 +171,7 @@ export default { <template v-else-if="!hasPipeline"> <gl-loading-icon size="md" /> <p - class="gl-flex-grow-1 gl-display-flex gl-ml-5 gl-mb-0" + class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0" data-testid="monitoring-pipeline-message" > {{ $options.monitoringPipelineText }} @@ -190,7 +190,7 @@ export default { </p> </template> <template v-else-if="hasPipeline"> - <a :href="status.details_path" class="align-self-start gl-mr-3"> + <a :href="status.details_path" class="gl-align-self-center gl-mr-3"> <ci-icon :status="status" :size="24" /> </a> <div class="ci-widget-container d-flex"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 0cd280c42d2..f99b825ff30 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -2,10 +2,10 @@ import { GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge'; import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql'; +import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { deprecatedCreateFlash as Flash } from '../../../flash'; import { AUTO_MERGE_STRATEGIES } from '../../constants'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; @@ -109,7 +109,9 @@ export default { }) .catch(() => { this.isCancellingAutoMerge = false; - Flash(__('Something went wrong. Please try again.')); + createFlash({ + message: __('Something went wrong. Please try again.'), + }); }); }, removeSourceBranch() { @@ -135,7 +137,9 @@ export default { }) .catch(() => { this.isRemovingSourceBranch = false; - Flash(__('Something went wrong. Please try again.')); + createFlash({ + message: __('Something went wrong. Please try again.'), + }); }); }, }, @@ -173,7 +177,7 @@ export default { data-testid="cancelAutomaticMergeButton" @click.prevent="cancelAutomaticMerge" > - <gl-loading-icon v-if="isCancellingAutoMerge" inline class="gl-mr-1" /> + <gl-loading-icon v-if="isCancellingAutoMerge" size="sm" inline class="gl-mr-1" /> {{ cancelButtonText }} </a> </h4> @@ -196,7 +200,7 @@ export default { data-testid="removeSourceBranchButton" @click.prevent="removeSourceBranch" > - <gl-loading-icon v-if="isRemovingSourceBranch" inline class="gl-mr-1" /> + <gl-loading-icon v-if="isRemovingSourceBranch" size="sm" inline class="gl-mr-1" /> {{ s__('mrWidget|Delete source branch') }} </a> </p> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 5f8630bf7b3..1a764d3d091 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -63,7 +63,7 @@ export default { size="small" @click="refreshWidget" > - <gl-loading-icon v-if="isRefreshing" :inline="true" /> + <gl-loading-icon v-if="isRefreshing" size="sm" :inline="true" /> {{ s__('mrWidget|Refresh') }} </gl-button> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index ee90d734ecb..5a93021978c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -112,7 +112,7 @@ export default { <div v-else class="media-body space-children gl-display-flex gl-align-items-center"> <span v-if="shouldBeRebased" class="bold"> {{ - s__(`mrWidget|Fast-forward merge is not possible. + s__(`mrWidget|Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.`) }} </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 9da3bea9362..5177eab790b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -1,14 +1,13 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import { GlLoadingIcon, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import createFlash from '~/flash'; import { s__, __ } from '~/locale'; import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants'; import modalEventHub from '~/projects/commit/event_hub'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import eventHub from '../../event_hub'; import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; -import statusIcon from '../mr_widget_status_icon.vue'; export default { name: 'MRWidgetMerged', @@ -17,7 +16,7 @@ export default { }, components: { MrWidgetAuthorTime, - statusIcon, + GlIcon, ClipboardButton, GlLoadingIcon, GlButton, @@ -100,7 +99,9 @@ export default { }) .catch(() => { this.isMakingRequest = false; - Flash(__('Something went wrong. Please try again.')); + createFlash({ + message: __('Something went wrong. Please try again.'), + }); }); }, openRevertModal() { @@ -114,7 +115,7 @@ export default { </script> <template> <div class="mr-widget-body media"> - <status-icon status="success" /> + <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" /> <div class="media-body"> <div class="space-children"> <mr-widget-author-time @@ -129,7 +130,6 @@ export default { :title="revertTitle" size="small" category="secondary" - variant="warning" data-qa-selector="revert_button" @click="openRevertModal" > @@ -142,7 +142,6 @@ export default { :title="revertTitle" size="small" category="secondary" - variant="warning" data-method="post" > {{ revertLabel }} @@ -167,6 +166,15 @@ export default { > {{ cherryPickLabel }} </gl-button> + <gl-button + v-if="shouldShowRemoveSourceBranch" + :disabled="isMakingRequest" + size="small" + class="js-remove-branch-button" + @click="removeSourceBranch" + > + {{ s__('mrWidget|Delete source branch') }} + </gl-button> </div> <section class="mr-info-list" data-qa-selector="merged_status_content"> <p> @@ -194,19 +202,8 @@ export default { <p v-if="mr.sourceBranchRemoved"> {{ s__('mrWidget|The source branch has been deleted') }} </p> - <p v-if="shouldShowRemoveSourceBranch" class="space-children"> - <span>{{ s__('mrWidget|You can delete the source branch now') }}</span> - <gl-button - :disabled="isMakingRequest" - size="small" - class="js-remove-branch-button" - @click="removeSourceBranch" - > - {{ s__('mrWidget|Delete source branch') }} - </gl-button> - </p> <p v-if="shouldShowSourceBranchRemoving"> - <gl-loading-icon :inline="true" /> + <gl-loading-icon size="sm" :inline="true" /> <span> {{ s__('mrWidget|The source branch is being deleted') }} </span> </p> </section> 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 a82a8a22873..22f41b43095 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 @@ -2,9 +2,9 @@ /* eslint-disable vue/no-v-html */ import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; import { escape } from 'lodash'; +import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { deprecatedCreateFlash as Flash } from '../../../flash'; import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; @@ -87,9 +87,7 @@ export default { }, fastForwardMergeText() { return sprintf( - __( - 'Fast-forward merge is not possible. Rebase the source branch onto %{targetBranch} to allow this merge request to be merged.', - ), + __('Merge blocked: the source branch must be rebased onto the target branch.'), { targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`, }, @@ -113,7 +111,9 @@ export default { if (error.response && error.response.data && error.response.data.merge_error) { this.rebasingError = error.response.data.merge_error; } else { - Flash(__('Something went wrong. Please try again.')); + createFlash({ + message: __('Something went wrong. Please try again.'), + }); } }); }, @@ -129,7 +129,9 @@ export default { if (res.merge_error && res.merge_error.length) { this.rebasingError = res.merge_error; - Flash(__('Something went wrong. Please try again.')); + createFlash({ + message: __('Something went wrong. Please try again.'), + }); } eventHub.$emit('MRWidgetRebaseSuccess'); @@ -138,7 +140,9 @@ export default { }) .catch(() => { this.isMakingRequest = false; - Flash(__('Something went wrong. Please try again.')); + createFlash({ + message: __('Something went wrong. Please try again.'), + }); stopPolling(); }); }, @@ -187,9 +191,7 @@ export default { data-testid="rebase-message" data-qa-selector="no_fast_forward_message_content" >{{ - __( - 'Fast-forward merge is not possible. Rebase the source branch onto the target branch.', - ) + __('Merge blocked: the source branch must be rebased onto the target branch.') }}</span > <span v-else class="gl-font-weight-bold danger" data-testid="rebase-message">{{ diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 07de525b1fa..2d0b7fe46a6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -412,7 +412,6 @@ export default { // If state is merged we should update the widget and stop the polling eventHub.$emit('MRWidgetUpdateRequested'); eventHub.$emit('FetchActionsContent'); - MergeRequest.setStatusBoxToMerged(); MergeRequest.hideCloseButton(); MergeRequest.decreaseCounter(); stopPolling(); @@ -629,11 +628,9 @@ export default { input-id="squash-message-edit" squash > - <commit-message-dropdown - slot="header" - v-model="squashCommitMessage" - :commits="commits" - /> + <template #header> + <commit-message-dropdown v-model="squashCommitMessage" :commits="commits" /> + </template> </commit-edit> <commit-edit v-if="shouldShowMergeEdit" @@ -641,14 +638,16 @@ export default { :label="__('Merge commit message')" input-id="merge-message-edit" > - <label slot="checkbox"> - <input - id="include-description" - type="checkbox" - @change="updateMergeCommitMessage($event.target.checked)" - /> - {{ __('Include merge request description') }} - </label> + <template #checkbox> + <label> + <input + id="include-description" + type="checkbox" + @change="updateMergeCommitMessage($event.target.checked)" + /> + {{ __('Include merge request description') }} + </label> + </template> </commit-edit> </ul> </commits-header> 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 e9dcf494099..5fe04269e33 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 @@ -5,12 +5,12 @@ import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/ap import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; +import createFlash from '~/flash'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import notify from '~/lib/utils/notify'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; -import createFlash from '../flash'; import { setFaviconOverlay } from '../lib/utils/favicon'; import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 4cc2f423d73..8e3160ce2f2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,10 +1,11 @@ -import { format } from 'timeago.js'; import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import { statusBoxState } from '~/issuable/components/status_box.vue'; -import { formatDate } from '../../lib/utils/datetime_utility'; +import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants'; import { stateKey } from './state_maps'; +const { format } = getTimeago(); + export default class MergeRequestStore { constructor(data) { this.sha = data.diff_head_sha; diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index b7544a4a5d0..c24318cb9ad 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -204,7 +204,7 @@ export default { @click="$emit('toggle-sidebar')" > <gl-icon name="user" /> - <gl-loading-icon v-if="isUpdating" /> + <gl-loading-icon v-if="isUpdating" size="sm" /> </div> <gl-tooltip :target="() => $refs.assignees" boundary="viewport" placement="left"> <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK"> @@ -270,12 +270,12 @@ export default { <p v-else-if="userListEmpty" class="gl-mx-5 gl-my-4"> {{ __('No Matching Results') }} </p> - <gl-loading-icon v-else /> + <gl-loading-icon v-else size="sm" /> </div> </gl-dropdown> </div> - <gl-loading-icon v-if="isUpdating" :inline="true" /> + <gl-loading-icon v-if="isUpdating" size="sm" :inline="true" /> <div v-else-if="!isDropdownShowing" class="hide-collapsed value gl-m-0" diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue index ce90a759cee..eaa5fc5af04 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue @@ -81,7 +81,7 @@ export default { <template v-if="sidebarCollapsed"> <div ref="status" class="gl-ml-6" data-testid="status-icon" @click="$emit('toggle-sidebar')"> <gl-icon name="status" /> - <gl-loading-icon v-if="isUpdating" /> + <gl-loading-icon v-if="isUpdating" size="sm" /> </div> <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left"> <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')"> @@ -120,7 +120,7 @@ export default { @handle-updating="handleUpdating" /> - <gl-loading-icon v-if="isUpdating" :inline="true" /> + <gl-loading-icon v-if="isUpdating" size="sm" :inline="true" /> <p v-else-if="!isDropdownShowing" class="value gl-m-0" diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue index 13472b48e84..bab13fe7c75 100644 --- a/app/assets/javascripts/vue_shared/components/actions_button.vue +++ b/app/assets/javascripts/vue_shared/components/actions_button.vue @@ -68,7 +68,7 @@ export default { split @click="handleClick(selectedAction, $event)" > - <template slot="button-content"> + <template #button-content> <span class="gl-new-dropdown-button-text" v-bind="selectedAction.attrs"> {{ selectedAction.text }} </span> diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index e6d9a38d1fb..f4c73d12923 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -93,12 +93,12 @@ export default { return { name, list, - title: this.getAwardListTitle(list), + title: this.getAwardListTitle(list, name), classes: this.getAwardClassBindings(list), html: glEmojiTag(name), }; }, - getAwardListTitle(awardsList) { + getAwardListTitle(awardsList, name) { if (!awardsList.length) { return ''; } @@ -128,7 +128,7 @@ export default { // We have 10+ awarded user, join them with comma and add `and x more`. if (remainingAwardList.length) { title = sprintf( - __(`%{listToShow}, and %{awardsListLength} more.`), + __(`%{listToShow}, and %{awardsListLength} more`), { listToShow: namesToShow.join(', '), awardsListLength: remainingAwardList.length, @@ -146,7 +146,7 @@ export default { title = namesToShow.join(__(' and ')); } - return title; + return title + sprintf(__(' reacted with :%{name}:'), { name }); }, handleAward(awardName) { if (!this.canAwardEmoji) { diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js index 9c2ed5abf04..0c1d55ae707 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js @@ -5,7 +5,13 @@ export default { props: { content: { type: String, - required: true, + required: false, + default: null, + }, + richViewer: { + type: String, + default: '', + required: false, }, type: { type: String, diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index a8a053c0d9e..dc4d1bd56e9 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -18,5 +18,5 @@ export default { }; </script> <template> - <markdown-field-view ref="content" v-safe-html="content" /> + <markdown-field-view ref="content" v-safe-html="richViewer || content" /> </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 f6ab3cac536..0589b47edbd 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 @@ -9,8 +9,8 @@ export default { name: 'SimpleViewer', components: { GlIcon, - EditorLite: () => - import(/* webpackChunkName: 'EditorLite' */ '~/vue_shared/components/editor_lite.vue'), + SourceEditor: () => + import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'), }, mixins: [ViewerMixin, glFeatureFlagsMixin()], inject: ['blobHash'], @@ -53,7 +53,7 @@ export default { </script> <template> <div> - <editor-lite + <source-editor v-if="isRawContent && refactorBlobViewerEnabled" :value="content" :file-name="fileName" diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 4b53f55b856..14e99977a85 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -82,13 +82,7 @@ export default { data-qa-selector="changed_file_icon_content" :data-qa-title="tooltipTitle" > - <gl-icon - v-if="showIcon" - :name="changedIcon" - :size="size" - :class="changedIconClass" - use-deprecated-sizes - /> + <gl-icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue index 2552236a073..fb7105bd416 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue @@ -28,18 +28,23 @@ export default { <slot></slot> </p> <resizable-chart-container> - <gl-area-chart - slot-scope="{ width }" - v-bind="$attrs" - :width="width" - :height="$options.chartContainerHeight" - :data="chartData" - :include-legend-avg-max="false" - :option="areaChartOptions" - > - <slot slot="tooltip-title" name="tooltip-title"></slot> - <slot slot="tooltip-content" name="tooltip-content"></slot> - </gl-area-chart> + <template #default="{ width }"> + <gl-area-chart + v-bind="$attrs" + :width="width" + :height="$options.chartContainerHeight" + :data="chartData" + :include-legend-avg-max="false" + :option="areaChartOptions" + > + <template #tooltip-title> + <slot name="tooltip-title"></slot> + </template> + <template #tooltip-content> + <slot name="tooltip-content"></slot> + </template> + </gl-area-chart> + </template> </resizable-chart-container> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue index f4fd57e4cdc..0575d7f6404 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue @@ -46,9 +46,12 @@ export default { :area-chart-options="chartOptions" > {{ dateRange }} - - <slot slot="tooltip-title" name="tooltip-title"></slot> - <slot slot="tooltip-content" name="tooltip-content"></slot> + <template #tooltip-title> + <slot name="tooltip-title"></slot> + </template> + <template #tooltip-content> + <slot name="tooltip-content"></slot> + </template> </ci-cd-analytics-area-chart> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index dbf459cb289..07bd6019b80 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -64,12 +64,6 @@ export default { </script> <template> <span :class="cssClass"> - <gl-icon - :name="icon" - :size="size" - :class="cssClasses" - :aria-label="status.icon" - use-deprecated-sizes - /> + <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue index 4bc70870767..733accdff44 100644 --- a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue +++ b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue @@ -3,6 +3,7 @@ import Identicon from '../identicon.vue'; import ProjectAvatarImage from './image.vue'; export default { + name: 'DeprecatedProjectAvatar', components: { Identicon, ProjectAvatarImage, diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue index 269736c799c..269736c799c 100644 --- a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue +++ b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue index b3edd05b0ee..b786f7752df 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue @@ -89,7 +89,7 @@ export default { <template> <div class="nothing-here-block"> - <gl-loading-icon v-if="is($options.STATE_LOADING)" /> + <gl-loading-icon v-if="is($options.STATE_LOADING)" size="sm" /> <template v-else> <gl-alert v-show="is($options.STATE_ERRORED)" diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue index 8494f99fd7d..52371e42ba1 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue @@ -1,11 +1,14 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlAlert } from '@gitlab/ui'; +import { GlAlert, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; export default { + name: 'DismissibleAlert', components: { GlAlert, }, + directives: { + SafeHtml, + }, props: { html: { type: String, @@ -28,6 +31,6 @@ export default { <template> <gl-alert v-if="!isDismissed" v-bind="$attrs" @dismiss="dismiss" v-on="$listeners"> - <div v-html="html"></div> + <div v-safe-html="html"></div> </gl-alert> </template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue index a1c7c4dd142..a512eb687b7 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -36,7 +36,7 @@ export default { data-toggle="dropdown" aria-expanded="false" > - <gl-loading-icon v-show="isLoading" :inline="true" /> + <gl-loading-icon v-show="isLoading" size="sm" :inline="true" /> <slot v-if="$slots.default"></slot> <span v-else class="dropdown-toggle-text"> {{ toggleText }} </span> <gl-icon diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue index 546ee56355f..0b92c947fc7 100644 --- a/app/assets/javascripts/vue_shared/components/expand_button.vue +++ b/app/assets/javascripts/vue_shared/components/expand_button.vue @@ -7,7 +7,7 @@ import { __ } from '~/locale'; * * @example * <expand-button> - * <template slot="expanded"> + * <template #expanded> * Text goes here. * </template> * </expand-button> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index fbadb202d51..b0c1c1531aa 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -103,6 +103,9 @@ export default { focusedIndex() { if (!this.mouseOver) { this.$nextTick(() => { + if (!this.$refs.virtualScrollList?.$el) { + return; + } const el = this.$refs.virtualScrollList.$el; const scrollTop = this.focusedIndex * FILE_FINDER_ROW_HEIGHT; const bottom = this.listShowCount * FILE_FINDER_ROW_HEIGHT; @@ -218,7 +221,7 @@ export default { </script> <template> - <div class="file-finder-overlay" @mousedown.self="toggle(false)"> + <div v-if="visible" class="file-finder-overlay" @mousedown.self="toggle(false)"> <div class="dropdown-menu diff-file-changes file-finder show"> <div :class="{ 'has-value': showClearInputButton }" class="dropdown-input"> <input diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index 4244cab902a..276fb35b51f 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -85,7 +85,7 @@ export default { </script> <template> <span> - <gl-loading-icon v-if="loading" :inline="true" /> + <gl-loading-icon v-if="loading" size="sm" :inline="true" /> <gl-icon v-else-if="isSymlink" name="symlink" :size="size" use-deprecated-sizes /> <svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]"> <use v-bind="{ 'xlink:href': spriteHref }" /> @@ -95,7 +95,6 @@ export default { :name="folderIconName" :size="size" class="folder-icon" - use-deprecated-sizes data-qa-selector="folder_icon_content" /> </span> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 9775a9119c6..994ce6a762a 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -10,8 +10,11 @@ export const FILTER_CURRENT = 'Current'; export const OPERATOR_IS = '='; export const OPERATOR_IS_TEXT = __('is'); export const OPERATOR_IS_NOT = '!='; +export const OPERATOR_IS_NOT_TEXT = __('is not'); export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }]; +export const OPERATOR_IS_NOT_ONLY = [{ value: OPERATOR_IS_NOT, description: OPERATOR_IS_NOT_TEXT }]; +export const OPERATOR_IS_AND_IS_NOT = [...OPERATOR_IS_ONLY, ...OPERATOR_IS_NOT_ONLY]; export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __(FILTER_NONE) }; export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) }; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js index 37436de907f..571d24b50cf 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -215,35 +215,35 @@ export function urlQueryToFilter(query = '', options = {}) { /** * Returns array of token values from localStorage - * based on provided recentTokenValuesStorageKey + * based on provided recentSuggestionsStorageKey * - * @param {String} recentTokenValuesStorageKey + * @param {String} recentSuggestionsStorageKey * @returns */ -export function getRecentlyUsedTokenValues(recentTokenValuesStorageKey) { - let recentlyUsedTokenValues = []; +export function getRecentlyUsedSuggestions(recentSuggestionsStorageKey) { + let recentlyUsedSuggestions = []; if (AccessorUtilities.isLocalStorageAccessSafe()) { - recentlyUsedTokenValues = JSON.parse(localStorage.getItem(recentTokenValuesStorageKey)) || []; + recentlyUsedSuggestions = JSON.parse(localStorage.getItem(recentSuggestionsStorageKey)) || []; } - return recentlyUsedTokenValues; + return recentlyUsedSuggestions; } /** * Sets provided token value to recently used array - * within localStorage for provided recentTokenValuesStorageKey + * within localStorage for provided recentSuggestionsStorageKey * - * @param {String} recentTokenValuesStorageKey + * @param {String} recentSuggestionsStorageKey * @param {Object} tokenValue */ -export function setTokenValueToRecentlyUsed(recentTokenValuesStorageKey, tokenValue) { - const recentlyUsedTokenValues = getRecentlyUsedTokenValues(recentTokenValuesStorageKey); +export function setTokenValueToRecentlyUsed(recentSuggestionsStorageKey, tokenValue) { + const recentlyUsedSuggestions = getRecentlyUsedSuggestions(recentSuggestionsStorageKey); - recentlyUsedTokenValues.splice(0, 0, { ...tokenValue }); + recentlyUsedSuggestions.splice(0, 0, { ...tokenValue }); if (AccessorUtilities.isLocalStorageAccessSafe()) { localStorage.setItem( - recentTokenValuesStorageKey, - JSON.stringify(uniqWith(recentlyUsedTokenValues, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)), + recentSuggestionsStorageKey, + JSON.stringify(uniqWith(recentlyUsedSuggestions, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)), ); } } 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 3b261f5ac25..a25a19a006c 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 @@ -74,13 +74,13 @@ export default { :config="config" :value="value" :active="active" - :tokens-list-loading="loading" - :token-values="authors" + :suggestions-loading="loading" + :suggestions="authors" :fn-active-token-value="getActiveAuthor" - :default-token-values="defaultAuthors" - :preloaded-token-values="preloadedAuthors" - :recent-token-values-storage-key="config.recentTokenValuesStorageKey" - @fetch-token-values="fetchAuthorBySearchTerm" + :default-suggestions="defaultAuthors" + :preloaded-suggestions="preloadedAuthors" + :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" + @fetch-suggestions="fetchAuthorBySearchTerm" v-on="$listeners" > <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> @@ -93,9 +93,9 @@ export default { /> <span>{{ activeTokenValue ? activeTokenValue.name : inputValue }}</span> </template> - <template #token-values-list="{ tokenValues }"> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="author in tokenValues" + v-for="author in suggestions" :key="author.username" :value="author.username" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index bda6b340871..a4804525a53 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -6,9 +6,10 @@ import { GlDropdownSectionHeader, GlLoadingIcon, } from '@gitlab/ui'; +import { debounce } from 'lodash'; import { DEBOUNCE_DELAY } from '../constants'; -import { getRecentlyUsedTokenValues, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; +import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; export default { components: { @@ -31,12 +32,12 @@ export default { type: Boolean, required: true, }, - tokensListLoading: { + suggestionsLoading: { type: Boolean, required: false, default: false, }, - tokenValues: { + suggestions: { type: Array, required: false, default: () => [], @@ -44,21 +45,21 @@ export default { fnActiveTokenValue: { type: Function, required: false, - default: (tokenValues, currentTokenValue) => { - return tokenValues.find(({ value }) => value === currentTokenValue); + default: (suggestions, currentTokenValue) => { + return suggestions.find(({ value }) => value === currentTokenValue); }, }, - defaultTokenValues: { + defaultSuggestions: { type: Array, required: false, default: () => [], }, - preloadedTokenValues: { + preloadedSuggestions: { type: Array, required: false, default: () => [], }, - recentTokenValuesStorageKey: { + recentSuggestionsStorageKey: { type: String, required: false, default: '', @@ -77,21 +78,21 @@ export default { data() { return { searchKey: '', - recentTokenValues: this.recentTokenValuesStorageKey - ? getRecentlyUsedTokenValues(this.recentTokenValuesStorageKey) + recentSuggestions: this.recentSuggestionsStorageKey + ? getRecentlyUsedSuggestions(this.recentSuggestionsStorageKey) : [], loading: false, }; }, computed: { - isRecentTokenValuesEnabled() { - return Boolean(this.recentTokenValuesStorageKey); + isRecentSuggestionsEnabled() { + return Boolean(this.recentSuggestionsStorageKey); }, recentTokenIds() { - return this.recentTokenValues.map((tokenValue) => tokenValue[this.valueIdentifier]); + return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); }, preloadedTokenIds() { - return this.preloadedTokenValues.map((tokenValue) => tokenValue[this.valueIdentifier]); + return this.preloadedSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); }, currentTokenValue() { if (this.fnCurrentTokenValue) { @@ -100,17 +101,17 @@ export default { return this.value.data.toLowerCase(); }, activeTokenValue() { - return this.fnActiveTokenValue(this.tokenValues, this.currentTokenValue); + return this.fnActiveTokenValue(this.suggestions, this.currentTokenValue); }, /** - * Return all the tokenValues when searchKey is present - * otherwise return only the tokenValues which aren't + * Return all the suggestions when searchKey is present + * otherwise return only the suggestions which aren't * present in "Recently used" */ - availableTokenValues() { + availableSuggestions() { return this.searchKey - ? this.tokenValues - : this.tokenValues.filter( + ? this.suggestions + : this.suggestions.filter( (tokenValue) => !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]) && !this.preloadedTokenIds.includes(tokenValue[this.valueIdentifier]), @@ -121,30 +122,30 @@ export default { active: { immediate: true, handler(newValue) { - if (!newValue && !this.tokenValues.length) { - this.$emit('fetch-token-values', this.value.data); + if (!newValue && !this.suggestions.length) { + this.$emit('fetch-suggestions', this.value.data); } }, }, }, methods: { - handleInput({ data }) { + handleInput: debounce(function debouncedSearch({ data }) { this.searchKey = data; - setTimeout(() => { - if (!this.tokensListLoading) this.$emit('fetch-token-values', data); - }, DEBOUNCE_DELAY); - }, + if (!this.suggestionsLoading) { + this.$emit('fetch-suggestions', data); + } + }, DEBOUNCE_DELAY), handleTokenValueSelected(activeTokenValue) { // Make sure that; // 1. Recently used values feature is enabled // 2. User has actually selected a value // 3. Selected value is not part of preloaded list. if ( - this.isRecentTokenValuesEnabled && + this.isRecentSuggestionsEnabled && activeTokenValue && !this.preloadedTokenIds.includes(activeTokenValue[this.valueIdentifier]) ) { - setTokenValueToRecentlyUsed(this.recentTokenValuesStorageKey, activeTokenValue); + setTokenValueToRecentlyUsed(this.recentSuggestionsStorageKey, activeTokenValue); } }, }, @@ -168,9 +169,9 @@ export default { <slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> </template> <template #suggestions> - <template v-if="defaultTokenValues.length"> + <template v-if="defaultSuggestions.length"> <gl-filtered-search-suggestion - v-for="token in defaultTokenValues" + v-for="token in defaultSuggestions" :key="token.value" :value="token.value" > @@ -178,19 +179,19 @@ export default { </gl-filtered-search-suggestion> <gl-dropdown-divider /> </template> - <template v-if="isRecentTokenValuesEnabled && recentTokenValues.length && !searchKey"> + <template v-if="isRecentSuggestionsEnabled && recentSuggestions.length && !searchKey"> <gl-dropdown-section-header>{{ __('Recently used') }}</gl-dropdown-section-header> - <slot name="token-values-list" :token-values="recentTokenValues"></slot> + <slot name="suggestions-list" :suggestions="recentSuggestions"></slot> <gl-dropdown-divider /> </template> <slot - v-if="preloadedTokenValues.length && !searchKey" - name="token-values-list" - :token-values="preloadedTokenValues" + v-if="preloadedSuggestions.length && !searchKey" + name="suggestions-list" + :suggestions="preloadedSuggestions" ></slot> - <gl-loading-icon v-if="tokensListLoading" /> + <gl-loading-icon v-if="suggestionsLoading" size="sm" /> <template v-else> - <slot name="token-values-list" :token-values="availableTokenValues"></slot> + <slot name="suggestions-list" :suggestions="availableSuggestions"></slot> </template> </template> </gl-filtered-search-token> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue index 694dcd95b5e..5859fd10688 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue @@ -97,7 +97,7 @@ export default { {{ branch.text }} </gl-filtered-search-suggestion> <gl-dropdown-divider v-if="defaultBranches.length" /> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <template v-else> <gl-filtered-search-suggestion v-for="branch in branches" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index 9ba7f3d1a1d..d186f46866c 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -101,7 +101,7 @@ export default { {{ emoji.value }} </gl-filtered-search-suggestion> <gl-dropdown-divider v-if="defaultEmojis.length" /> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <template v-else> <gl-filtered-search-suggestion v-for="emoji in emojis" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue index d21fa9a344a..aa234cf86d9 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue @@ -56,7 +56,7 @@ export default { } // Current value is a string. - const [groupPath, idProperty] = this.currentValue?.split('::&'); + const [groupPath, idProperty] = this.currentValue?.split(this.$options.separator); return this.epics.find( (epic) => epic.group_full_path === groupPath && @@ -65,6 +65,9 @@ export default { } return null; }, + displayText() { + return `${this.activeEpic?.title}${this.$options.separator}${this.activeEpic?.iid}`; + }, }, watch: { active: { @@ -103,8 +106,10 @@ export default { this.fetchEpicsBySearchTerm({ epicPath, search: data }); }, DEBOUNCE_DELAY), - getEpicDisplayText(epic) { - return `${epic.title}${this.$options.separator}${epic.iid}`; + getValue(epic) { + return this.config.useIdValue + ? String(epic[this.idProperty]) + : `${epic.group_full_path}${this.$options.separator}${epic[this.idProperty]}`; }, }, }; @@ -118,7 +123,7 @@ export default { @input="searchEpics" > <template #view="{ inputValue }"> - {{ activeEpic ? getEpicDisplayText(activeEpic) : inputValue }} + {{ activeEpic ? displayText : inputValue }} </template> <template #suggestions> <gl-filtered-search-suggestion @@ -129,13 +134,9 @@ export default { {{ epic.text }} </gl-filtered-search-suggestion> <gl-dropdown-divider v-if="defaultEpics.length" /> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <template v-else> - <gl-filtered-search-suggestion - v-for="epic in epics" - :key="epic.id" - :value="`${epic.group_full_path}::&${epic[idProperty]}`" - > + <gl-filtered-search-suggestion v-for="epic in epics" :key="epic.id" :value="getValue(epic)"> {{ epic.title }} </gl-filtered-search-suggestion> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue index 7b6a590279a..ba8b2421726 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue @@ -7,6 +7,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { DEBOUNCE_DELAY, DEFAULT_ITERATIONS } from '../constants'; @@ -30,8 +31,7 @@ export default { data() { return { iterations: this.config.initialIterations || [], - defaultIterations: this.config.defaultIterations || DEFAULT_ITERATIONS, - loading: true, + loading: false, }; }, computed: { @@ -39,7 +39,12 @@ export default { return this.value.data; }, activeIteration() { - return this.iterations.find((iteration) => iteration.title === this.currentValue); + return this.iterations.find( + (iteration) => getIdFromGraphQLId(iteration.id) === Number(this.currentValue), + ); + }, + defaultIterations() { + return this.config.defaultIterations || DEFAULT_ITERATIONS; }, }, watch: { @@ -53,6 +58,9 @@ export default { }, }, methods: { + getValue(iteration) { + return String(getIdFromGraphQLId(iteration.id)); + }, fetchIterationBySearchTerm(searchTerm) { const fetchPromise = this.config.fetchPath ? this.config.fetchIterations(this.config.fetchPath, searchTerm) @@ -95,12 +103,12 @@ export default { {{ iteration.text }} </gl-filtered-search-suggestion> <gl-dropdown-divider v-if="defaultIterations.length" /> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <template v-else> <gl-filtered-search-suggestion v-for="iteration in iterations" - :key="iteration.title" - :value="iteration.title" + :key="iteration.id" + :value="getValue(iteration)" > {{ iteration.title }} </gl-filtered-search-suggestion> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index e496d099a42..4d08f81fee9 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -96,12 +96,12 @@ export default { :config="config" :value="value" :active="active" - :tokens-list-loading="loading" - :token-values="labels" + :suggestions-loading="loading" + :suggestions="labels" :fn-active-token-value="getActiveLabel" - :default-token-values="defaultLabels" - :recent-token-values-storage-key="config.recentTokenValuesStorageKey" - @fetch-token-values="fetchLabelBySearchTerm" + :default-suggestions="defaultLabels" + :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" + @fetch-suggestions="fetchLabelBySearchTerm" v-on="$listeners" > <template @@ -115,9 +115,9 @@ export default { >~{{ activeTokenValue ? getLabelName(activeTokenValue) : inputValue }}</gl-token > </template> - <template #token-values-list="{ tokenValues }"> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="label in tokenValues" + v-for="label in suggestions" :key="label.id" :value="getLabelName(label)" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index cda6e4d6726..66ad5ef5b4e 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -9,6 +9,7 @@ import { debounce } from 'lodash'; import createFlash from '~/flash'; import { __ } from '~/locale'; +import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; import { DEFAULT_MILESTONES, DEBOUNCE_DELAY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; @@ -34,7 +35,7 @@ export default { return { milestones: this.config.initialMilestones || [], defaultMilestones: this.config.defaultMilestones || DEFAULT_MILESTONES, - loading: true, + loading: false, }; }, computed: { @@ -59,11 +60,16 @@ export default { }, methods: { fetchMilestoneBySearchTerm(searchTerm = '') { + if (this.loading) { + return; + } + this.loading = true; this.config .fetchMilestones(searchTerm) - .then(({ data }) => { - this.milestones = data; + .then((response) => { + const data = Array.isArray(response) ? response : response.data; + this.milestones = data.slice().sort(sortMilestonesByDueDate); }) .catch(() => createFlash({ message: __('There was a problem fetching milestones.') })) .finally(() => { @@ -96,7 +102,7 @@ export default { {{ milestone.text }} </gl-filtered-search-suggestion> <gl-dropdown-divider v-if="defaultMilestones.length" /> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <template v-else> <gl-filtered-search-suggestion v-for="milestone in milestones" diff --git a/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue b/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue index 74f988476e3..26c50345c19 100644 --- a/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue +++ b/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable-next-line vue/no-deprecated-functional-template --> <template functional> <footer class="form-actions d-flex justify-content-between"> <div><slot name="prepend"></slot></div> diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue index 96d99faa952..dd0c0358ef6 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue @@ -74,6 +74,8 @@ export default { @hidden="syncHide" > <slot></slot> - <slot slot="modal-footer" name="modal-footer" :ok="ok" :cancel="cancel"></slot> + <template #modal-footer> + <slot name="modal-footer" :ok="ok" :cancel="cancel"></slot> + </template> </gl-modal> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 80b7a9b7d05..9ea48050079 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -4,7 +4,7 @@ import { GlIcon } from '@gitlab/ui'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; import { unescape } from 'lodash'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import GLForm from '~/gl_form'; import axios from '~/lib/utils/axios_utils'; import { stripHtml } from '~/lib/utils/text_utility'; @@ -222,7 +222,11 @@ export default { axios .post(this.markdownPreviewPath, { text: this.textareaValue }) .then((response) => this.renderMarkdown(response.data)) - .catch(() => new Flash(__('Error loading markdown preview'))); + .catch(() => + createFlash({ + message: __('Error loading markdown preview'), + }), + ); } else { this.renderMarkdown(); } @@ -245,7 +249,11 @@ export default { this.$nextTick() .then(() => $(this.$refs['markdown-preview']).renderGFM()) - .catch(() => new Flash(__('Error rendering markdown preview'))); + .catch(() => + createFlash({ + message: __('Error rendering markdown preview'), + }), + ); }, }, }; 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 83b8a6ae562..065d9b1b5dd 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 @@ -1,5 +1,6 @@ <script> import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import ApplySuggestion from './apply_suggestion.vue'; @@ -73,7 +74,7 @@ export default { return __('Applying suggestions...'); }, isLoggedIn() { - return Boolean(gon.current_user_id); + return isLoggedIn(); }, }, methods: { @@ -110,7 +111,7 @@ export default { </div> <div v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</div> <div v-else-if="isApplying" class="d-flex align-items-center text-secondary"> - <gl-loading-icon class="d-flex-center mr-2" /> + <gl-loading-icon size="sm" class="d-flex-center mr-2" /> <span>{{ applyingSuggestionsMessage }}</span> </div> <div v-else-if="canApply && isBatched" class="d-flex align-items-center"> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue index 9059f0d2a8b..a04f8616acb 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -1,7 +1,11 @@ <script> -/* eslint-disable vue/no-v-html */ +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; + export default { name: 'SuggestionDiffRow', + directives: { + SafeHtml, + }, props: { line: { type: Object, @@ -32,7 +36,7 @@ export default { :class="[{ 'd-table-cell': displayAsCell }, lineType]" data-testid="suggestion-diff-content" > - <span v-if="line.rich_text" class="line" v-html="line.rich_text"></span> + <span v-if="line.rich_text" v-safe-html="line.rich_text" class="line"></span> <span v-else-if="line.text" class="line">{{ line.text }}</span> <span v-else class="line"></span> </td> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 53d1cca7af3..63774c6c498 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -1,7 +1,7 @@ <script> import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import Vue from 'vue'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import SuggestionDiff from './suggestion_diff.vue'; @@ -79,7 +79,10 @@ export default { const suggestionElements = container.querySelectorAll('.js-render-suggestion'); if (this.lineType === 'old') { - Flash(__('Unable to apply suggestions to a deleted line.'), 'alert', this.$el); + createFlash({ + message: __('Unable to apply suggestions to a deleted line.'), + parent: this.$el, + }); } suggestionElements.forEach((suggestionEl, i) => { diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 7393a8791b7..7112295fa57 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -82,7 +82,7 @@ export default { <span class="attaching-file-message"></span> <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> <span class="uploading-progress">0%</span> - <gl-loading-icon inline /> + <gl-loading-icon size="sm" inline /> </span> <span class="uploading-error-container hide"> <span class="uploading-error-icon"> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 69afd711797..d6501a37a35 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -16,12 +16,15 @@ * :note="{body: 'This is a note'}" * /> */ +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import { renderMarkdown } from '~/notes/utils'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import userAvatarLink from '../user_avatar/user_avatar_link.vue'; export default { name: 'PlaceholderNote', + directives: { SafeHtml }, components: { userAvatarLink, TimelineEntryItem, @@ -34,6 +37,9 @@ export default { }, computed: { ...mapGetters(['getUserData']), + renderedNote() { + return renderMarkdown(this.note.body); + }, }, }; </script> @@ -57,9 +63,7 @@ export default { </div> </div> <div class="note-body"> - <div class="note-text md"> - <p>{{ note.body }}</p> - </div> + <div v-safe-html="renderedNote" class="note-text md"></div> </div> </div> </timeline-entry-item> 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 149909d263e..c3d861d74bc 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -111,7 +111,7 @@ export default { <div class="note-header"> <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> <span v-safe-html="actionTextHtml"></span> - <template v-if="canSeeDescriptionVersion" slot="extra-controls"> + <template v-if="canSeeDescriptionVersion" #extra-controls> · <gl-button variant="link" diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue index ff2847624c5..e37a663ace3 100644 --- a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue +++ b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue @@ -27,9 +27,13 @@ export default { title() { return this.isCurrentUser ? s__('OnCallSchedules|You are currently a part of:') - : sprintf(s__('OnCallSchedules|User %{name} is currently part of:'), { - name: this.userName, - }); + : sprintf( + s__('OnCallSchedules|User %{name} is currently part of:'), + { + name: this.userName, + }, + false, + ); }, footer() { return this.isCurrentUser diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index d05e45e90b3..79a9e1fca8c 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -169,6 +169,12 @@ export default { methods: { filterItemsByStatus(tabIndex) { this.resetPagination(); + const activeStatusTab = this.statusTabs[tabIndex]; + + if (activeStatusTab == null) { + return; + } + const { filters, status } = this.statusTabs[tabIndex]; this.statusFilter = filters; this.filteredByStatus = status; diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.stories.js b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js new file mode 100644 index 00000000000..110c6c73bad --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js @@ -0,0 +1,30 @@ +import ProjectAvatar from './project_avatar.vue'; + +export default { + component: ProjectAvatar, + title: 'vue_shared/components/project_avatar', +}; + +const Template = (args, { argTypes }) => ({ + components: { ProjectAvatar }, + props: Object.keys(argTypes), + template: '<project-avatar v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + projectAvatarUrl: + 'https://gitlab.com/uploads/-/system/project/avatar/278964/logo-extra-whitespace.png?width=64', + projectName: 'GitLab', +}; + +export const FallbackAvatar = Template.bind({}); +FallbackAvatar.args = { + projectName: 'GitLab', +}; + +export const EmptyAltTag = Template.bind({}); +EmptyAltTag.args = { + ...Default.args, + alt: '', +}; diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.vue b/app/assets/javascripts/vue_shared/components/project_avatar.vue new file mode 100644 index 00000000000..f16187022a5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_avatar.vue @@ -0,0 +1,45 @@ +<script> +import { GlAvatar } from '@gitlab/ui'; + +export default { + components: { + GlAvatar, + }, + props: { + projectName: { + type: String, + required: true, + }, + projectAvatarUrl: { + type: String, + required: false, + default: '', + }, + size: { + type: Number, + default: 32, + required: false, + }, + alt: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + avatarAlt() { + return this.alt ?? this.projectName; + }, + }, +}; +</script> + +<template> + <gl-avatar + shape="rect" + :entity-name="projectName" + :src="projectAvatarUrl" + :alt="avatarAlt" + :size="size" + /> +</template> 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 ddc8bbf9b27..69f43c9e464 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 @@ -4,7 +4,7 @@ import { GlButton, GlIcon } 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/project_avatar/default.vue'; +import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue'; export default { name: 'ProjectListItem', diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue index 580e1668f41..d55c93fd146 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue @@ -194,7 +194,7 @@ export default { <template v-if="selectedPlatform"> <h5> {{ $options.i18n.architecture }} - <gl-loading-icon v-if="$apollo.loading" inline /> + <gl-loading-icon v-if="$apollo.loading" size="sm" inline /> </h5> <gl-dropdown class="gl-mb-3" :text="selectedArchitectureName"> diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue deleted file mode 100644 index bb1a8fae7b0..00000000000 --- a/app/assets/javascripts/vue_shared/components/select2_select.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -import $ from 'jquery'; -import 'select2'; -import { loadCSSFile } from '~/lib/utils/css_utils'; - -export default { - // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 - // eslint-disable-next-line @gitlab/require-i18n-strings - name: 'Select2Select', - props: { - options: { - type: Object, - required: false, - default: () => ({}), - }, - value: { - type: String, - required: false, - default: '', - }, - }, - - watch: { - value() { - $(this.$refs.dropdownInput).val(this.value).trigger('change'); - }, - }, - - mounted() { - loadCSSFile(gon.select2_css_path) - .then(() => { - $(this.$refs.dropdownInput) - .val(this.value) - .select2(this.options) - .on('change', (event) => this.$emit('input', event.target.value)); - }) - .catch(() => {}); - }, - - beforeDestroy() { - $(this.$refs.dropdownInput).select2('destroy'); - }, -}; -</script> - -<template> - <input ref="dropdownInput" type="hidden" /> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue index bbc7e6e7a6e..5c3a6852219 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { s__, __, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -10,8 +10,9 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; export default { name: 'CopyableField', components: { - GlLoadingIcon, ClipboardButton, + GlLoadingIcon, + GlSprintf, }, props: { value: { @@ -48,12 +49,6 @@ export default { loadingIconLabel() { return sprintf(this.$options.i18n.loadingIconLabel, { name: this.name }); }, - templateText() { - return sprintf(this.$options.i18n.templateText, { - name: this.name, - value: this.value, - }); - }, }, i18n: { loadingIconLabel: __('Loading %{name}'), @@ -78,10 +73,13 @@ export default { class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap" :title="value" > - {{ templateText }} + <gl-sprintf :message="$options.i18n.templateText"> + <template #name>{{ name }}</template> + <template #value>{{ value }}</template> + </gl-sprintf> </span> - <gl-loading-icon v-if="isLoading" inline :label="loadingIconLabel" /> + <gl-loading-icon v-if="isLoading" size="sm" inline :label="loadingIconLabel" /> <clipboard-button v-else size="small" v-bind="clipboardProps" /> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue index 075681de320..4531fafbf72 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue @@ -104,7 +104,7 @@ export default { <collapsed-calendar-icon :text="collapsedText" class="sidebar-collapsed-icon" /> <div class="title"> {{ label }} - <gl-loading-icon v-if="isLoading" :inline="true" /> + <gl-loading-icon v-if="isLoading" size="sm" :inline="true" /> <div class="float-right"> <button v-if="editable && !editing" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue index 320e2048f1c..12daaea8758 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue @@ -148,7 +148,7 @@ export default { @hide="handleDropdownHide" > <template #button-content - ><gl-loading-icon v-if="moveInProgress" class="gl-mr-3" />{{ + ><gl-loading-icon v-if="moveInProgress" size="sm" class="gl-mr-3" />{{ dropdownButtonTitle }}</template > diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue index f8cc981ba3d..3ec33a653b8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue @@ -108,7 +108,7 @@ export default { class="float-left d-flex align-items-center" @click="handleCreateClick" > - <gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" /> + <gl-loading-icon v-show="labelCreateInProgress" size="sm" :inline="true" class="mr-1" /> {{ __('Create') }} </gl-button> <gl-button class="float-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView"> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index 86788a84260..9914bfc6026 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -48,6 +48,12 @@ export default { } return this.labels; }, + showDropdownFooter() { + return ( + (this.isDropdownVariantSidebar || this.isDropdownVariantEmbedded) && + (this.allowLabelCreate || this.labelsManagePath) + ); + }, showNoMatchingResultsMessage() { return Boolean(this.searchKey) && this.visibleLabels.length === 0; }, @@ -192,11 +198,7 @@ export default { </li> </ul> </div> - <div - v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" - class="dropdown-footer" - data-testid="dropdown-footer" - > + <div v-if="showDropdownFooter" class="dropdown-footer" data-testid="dropdown-footer"> <ul class="list-unstyled"> <li v-if="allowLabelCreate"> <gl-link @@ -206,7 +208,7 @@ export default { {{ footerCreateLabelTitle }} </gl-link> </li> - <li> + <li v-if="labelsManagePath"> <gl-link :href="labelsManagePath" class="gl-display-flex flex-row text-break-word label-item" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue index 813de528c0b..aad754e15b0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue @@ -26,7 +26,7 @@ export default { <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> {{ __('Labels') }} <template v-if="allowLabelEdit"> - <gl-loading-icon v-show="labelsSelectInProgress" inline /> + <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline /> <gl-button variant="link" class="float-right gl-text-gray-900! gl-hover-text-blue-800! js-sidebar-dropdown-toggle" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js index 89f96ab916b..178be0f6da0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -16,7 +16,9 @@ export const receiveLabelsSuccess = ({ commit }, labels) => commit(types.RECEIVE_SET_LABELS_SUCCESS, labels); export const receiveLabelsFailure = ({ commit }) => { commit(types.RECEIVE_SET_LABELS_FAILURE); - flash(__('Error fetching labels.')); + createFlash({ + message: __('Error fetching labels.'), + }); }; export const fetchLabels = ({ state, dispatch }) => { dispatch('requestLabels'); @@ -32,7 +34,9 @@ export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LA export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS); export const receiveCreateLabelFailure = ({ commit }) => { commit(types.RECEIVE_CREATE_LABEL_FAILURE); - flash(__('Error creating label.')); + createFlash({ + message: __('Error creating label.'), + }); }; export const createLabel = ({ state, dispatch }, label) => { dispatch('requestCreateLabel'); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 55716e1105e..2e0a57f15dd 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -1,3 +1,4 @@ +import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; import { DropdownVariant } from '../constants'; import * as types from './mutation_types'; @@ -66,5 +67,16 @@ export default { candidateLabel.touched = true; candidateLabel.set = !candidateLabel.set; } + + if (isScopedLabel(candidateLabel)) { + const scopedBase = scopedLabelKey(candidateLabel); + const currentActiveScopedLabel = state.labels.find(({ title }) => { + return title.startsWith(scopedBase) && title !== '' && title !== candidateLabel.title; + }); + + if (currentActiveScopedLabel) { + currentActiveScopedLabel.set = false; + } + } }, }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue index a7f20fbe851..4651e7a1576 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -117,7 +117,7 @@ export default { data-testid="create-button" @click="createLabel" > - <gl-loading-icon v-if="labelCreateInProgress" :inline="true" class="mr-1" /> + <gl-loading-icon v-if="labelCreateInProgress" size="sm" :inline="true" class="mr-1" /> {{ __('Create') }} </gl-button> <gl-button diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue index 5d1663bc1fd..b6d14965cfa 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue @@ -26,7 +26,7 @@ export default { <div class="title hide-collapsed gl-mb-3"> {{ __('Labels') }} <template v-if="allowLabelEdit"> - <gl-loading-icon v-show="labelsSelectInProgress" inline /> + <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline /> <gl-button variant="link" class="float-right js-sidebar-dropdown-toggle" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue index 46ccb9470e5..58a940bca3b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue @@ -1,7 +1,6 @@ <script> import { GlLabel } from '@gitlab/ui'; -import { mapState } from 'vuex'; - +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isScopedLabel } from '~/lib/utils/common_utils'; export default { @@ -14,15 +13,26 @@ export default { required: false, default: false, }, - }, - computed: { - ...mapState([ - 'selectedLabels', - 'allowLabelRemove', - 'allowScopedLabels', - 'labelsFilterBasePath', - 'labelsFilterParam', - ]), + selectedLabels: { + type: Array, + required: true, + }, + allowLabelRemove: { + type: Boolean, + required: true, + }, + allowScopedLabels: { + type: Boolean, + required: true, + }, + labelsFilterBasePath: { + type: String, + required: true, + }, + labelsFilterParam: { + type: String, + required: true, + }, }, methods: { labelFilterUrl(label) { @@ -33,6 +43,9 @@ export default { scopedLabel(label) { return this.allowScopedLabels && isScopedLabel(label); }, + removeLabel(labelId) { + this.$emit('onLabelRemove', getIdFromGraphQLId(labelId)); + }, }, }; </script> @@ -43,12 +56,14 @@ export default { 'has-labels': selectedLabels.length, }" class="hide-collapsed value issuable-show-labels js-value" + data-testid="value-wrapper" > - <span v-if="!selectedLabels.length" class="text-secondary"> + <span v-if="!selectedLabels.length" class="text-secondary" data-testid="empty-placeholder"> <slot></slot> </span> - <template v-for="label in selectedLabels" v-else> + <template v-else> <gl-label + v-for="label in selectedLabels" :key="label.id" data-qa-selector="selected_label_content" :data-qa-label-name="label.title" @@ -60,7 +75,7 @@ export default { :show-close-button="allowLabelRemove" :disabled="disableLabels" tooltip-placement="top" - @close="$emit('onLabelRemove', label.id)" + @close="removeLabel(label.id)" /> </template> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql new file mode 100644 index 00000000000..1c2fd3bb7c0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql @@ -0,0 +1,15 @@ +query issueLabels($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + issuable: issue(iid: $iid) { + id + labels { + nodes { + id + title + color + description + } + } + } + } +} 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 7728c758e18..87f36a5bb72 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 @@ -11,6 +11,7 @@ import DropdownContents from './dropdown_contents.vue'; import DropdownTitle from './dropdown_title.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; +import issueLabelsQuery from './graphql/issue_labels.query.graphql'; import labelsSelectModule from './store'; Vue.use(Vuex); @@ -24,6 +25,7 @@ export default { DropdownContents, DropdownValueCollapsed, }, + inject: ['iid', 'projectPath'], props: { allowLabelRemove: { type: Boolean, @@ -119,8 +121,23 @@ export default { data() { return { contentIsOnViewport: true, + issueLabels: [], }; }, + apollo: { + issueLabels: { + query: issueLabelsQuery, + variables() { + return { + iid: this.iid, + fullPath: this.projectPath, + }; + }, + update(data) { + return data.workspace?.issuable?.labels.nodes || []; + }, + }, + }, computed: { ...mapState(['showDropdownButton', 'showDropdownContents']), ...mapGetters([ @@ -293,7 +310,7 @@ export default { <template v-if="isDropdownVariantSidebar"> <dropdown-value-collapsed ref="dropdownButtonCollapsed" - :labels="selectedLabels" + :labels="issueLabels" @onValueClick="handleCollapsedValueClick" /> <dropdown-title @@ -302,6 +319,11 @@ export default { /> <dropdown-value :disable-labels="labelsSelectInProgress" + :selected-labels="issueLabels" + :allow-label-remove="allowLabelRemove" + :allow-scoped-labels="allowScopedLabels" + :labels-filter-base-path="labelsFilterBasePath" + :labels-filter-param="labelsFilterParam" @onLabelRemove="$emit('onLabelRemove', $event)" > <slot></slot> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js index 2b96b159ca3..935f020f559 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -16,7 +16,9 @@ export const receiveLabelsSuccess = ({ commit }, labels) => commit(types.RECEIVE_SET_LABELS_SUCCESS, labels); export const receiveLabelsFailure = ({ commit }) => { commit(types.RECEIVE_SET_LABELS_FAILURE); - flash(__('Error fetching labels.')); + createFlash({ + message: __('Error fetching labels.'), + }); }; export const fetchLabels = ({ state, dispatch }) => { dispatch('requestLabels'); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js index 131c6e6fb57..1c03d95f37b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js @@ -1,3 +1,4 @@ +import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; import { DropdownVariant } from '../constants'; import * as types from './mutation_types'; @@ -55,5 +56,16 @@ export default { candidateLabel.touched = true; candidateLabel.set = !candidateLabel.set; } + + if (isScopedLabel(candidateLabel)) { + const scopedBase = scopedLabelKey(candidateLabel); + const currentActiveScopedLabel = state.labels.find( + ({ title }) => title.indexOf(scopedBase) === 0 && title !== candidateLabel.title, + ); + + if (currentActiveScopedLabel) { + currentActiveScopedLabel.set = false; + } + } }, }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js new file mode 100644 index 00000000000..d2afc02233e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js @@ -0,0 +1,23 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import TodoButton from './todo_button.vue'; + +export default { + component: TodoButton, + title: 'vue_shared/components/todo_toggle/todo_button', +}; + +const Template = (args, { argTypes }) => ({ + components: { TodoButton }, + props: Object.keys(argTypes), + template: '<todo-button v-bind="$props" v-on="$props" />', +}); + +export const Default = Template.bind({}); +Default.argTypes = { + isTodo: { + description: 'True if to-do is unresolved (i.e. not "done")', + control: { type: 'boolean' }, + }, + click: { action: 'clicked' }, +}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue new file mode 100644 index 00000000000..e6229cf0a93 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue @@ -0,0 +1,56 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { todoLabel } from './utils'; + +export default { + components: { + GlButton, + }, + props: { + isTodo: { + type: Boolean, + required: false, + default: true, + }, + }, + computed: { + buttonLabel() { + return todoLabel(this.isTodo); + }, + }, + methods: { + updateGlobalTodoCount(additionalTodoCount) { + const countContainer = document.querySelector('.js-todos-count'); + if (countContainer === null) return; + const currentCount = parseInt(countContainer.innerText, 10); + const todoToggleEvent = new CustomEvent('todo:toggle', { + detail: { + count: Math.max(currentCount + additionalTodoCount, 0), + }, + }); + + document.dispatchEvent(todoToggleEvent); + }, + incrementGlobalTodoCount() { + this.updateGlobalTodoCount(1); + }, + decrementGlobalTodoCount() { + this.updateGlobalTodoCount(-1); + }, + onToggle(event) { + if (this.isTodo) { + this.decrementGlobalTodoCount(); + } else { + this.incrementGlobalTodoCount(); + } + this.$emit('click', event); + }, + }, +}; +</script> + +<template> + <gl-button v-bind="$attrs" :aria-label="buttonLabel" @click="onToggle($event)"> + {{ buttonLabel }} + </gl-button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js new file mode 100644 index 00000000000..59e72a2ffe3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js @@ -0,0 +1,5 @@ +import { __ } from '~/locale'; + +export const todoLabel = (hasTodo) => { + return hasTodo ? __('Mark as done') : __('Add a to do'); +}; diff --git a/app/assets/javascripts/vue_shared/components/editor_lite.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue index c3bddabea21..fdf0c9baee3 100644 --- a/app/assets/javascripts/vue_shared/components/editor_lite.vue +++ b/app/assets/javascripts/vue_shared/components/source_editor.vue @@ -1,9 +1,9 @@ <script> import { debounce } from 'lodash'; import { CONTENT_UPDATE_DEBOUNCE, EDITOR_READY_EVENT } from '~/editor/constants'; -import Editor from '~/editor/editor_lite'; +import Editor from '~/editor/source_editor'; -function initEditorLite({ el, ...args }) { +function initSourceEditor({ el, ...args }) { const editor = new Editor({ scrollbar: { alwaysConsumeMouseWheel: false, @@ -64,7 +64,7 @@ export default { }, }, mounted() { - this.editor = initEditorLite({ + this.editor = initSourceEditor({ el: this.$refs.editor, blobPath: this.fileName, blobContent: this.value, @@ -93,7 +93,7 @@ export default { </script> <template> <div - :id="`editor-lite-${fileGlobalId}`" + :id="`source-editor-${fileGlobalId}`" ref="editor" data-editor-loading @[$options.readyEvent]="$emit($options.readyEvent)" diff --git a/app/assets/javascripts/vue_shared/components/todo_button.vue b/app/assets/javascripts/vue_shared/components/todo_button.vue deleted file mode 100644 index 935d222a1a9..00000000000 --- a/app/assets/javascripts/vue_shared/components/todo_button.vue +++ /dev/null @@ -1,28 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlButton, - }, - props: { - isTodo: { - type: Boolean, - required: false, - default: true, - }, - }, - computed: { - buttonLabel() { - return this.isTodo ? __('Mark as done') : __('Add a to do'); - }, - }, -}; -</script> - -<template> - <gl-button v-bind="$attrs" :aria-label="buttonLabel" @click="$emit('click', $event)"> - {{ buttonLabel }} - </gl-button> -</template> 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 deac24d2270..f387f8ca128 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 @@ -72,7 +72,11 @@ export default { <template v-else> <div class="gl-mb-3"> <h5 class="gl-m-0"> - <user-name-with-status :name="user.name" :availability="availabilityStatus" /> + <user-name-with-status + :name="user.name" + :availability="availabilityStatus" + :pronouns="user.pronouns" + /> </h5> <span class="gl-text-gray-500">@{{ user.username }}</span> </div> diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index 04e44aa2ed1..b85cae0c64f 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -96,9 +96,6 @@ export default { }, }, searchUsers: { - // TODO Remove error policy - // https://gitlab.com/gitlab-org/gitlab/-/issues/329750 - errorPolicy: 'all', query: searchUsers, variables() { return { @@ -111,28 +108,10 @@ export default { return !this.isEditing; }, update(data) { - // TODO Remove null filter (BE fix required) - // https://gitlab.com/gitlab-org/gitlab/-/issues/329750 return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || []; }, debounce: ASSIGNEES_DEBOUNCE_DELAY, - error({ graphQLErrors }) { - // TODO This error suppression is temporary (BE fix required) - // https://gitlab.com/gitlab-org/gitlab/-/issues/329750 - const isNullError = ({ message }) => { - return message === 'Cannot return null for non-nullable field GroupMember.user'; - }; - - if (graphQLErrors?.length > 0 && graphQLErrors.every(isNullError)) { - // only null-related errors exist, suppress them. - // eslint-disable-next-line no-console - console.error( - "Suppressing the error 'Cannot return null for non-nullable field GroupMember.user'. Please see https://gitlab.com/gitlab-org/gitlab/-/issues/329750", - ); - this.isSearching = false; - return; - } - + error() { this.$emit('error'); this.isSearching = false; }, 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 4bd3e352fd2..5ba7c107c12 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -93,9 +93,8 @@ export default { tooltip: '', attrs: { 'data-qa-selector': 'edit_button', - 'data-track-event': 'click_edit', - // eslint-disable-next-line @gitlab/require-i18n-strings - 'data-track-label': 'Edit', + 'data-track-action': 'click_consolidated_edit', + 'data-track-label': 'edit', }, ...handleOptions, }; @@ -127,9 +126,8 @@ export default { tooltip: '', attrs: { 'data-qa-selector': 'web_ide_button', - 'data-track-event': 'click_edit_ide', - // eslint-disable-next-line @gitlab/require-i18n-strings - 'data-track-label': 'Web IDE', + 'data-track-action': 'click_consolidated_edit_ide', + 'data-track-label': 'web_ide', }, ...handleOptions, }; diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue index e9983af5401..1b20ae57563 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -16,14 +16,9 @@ export default { type: Array, required: true, }, - experiment: { - type: String, - required: false, - default: null, - }, }, created() { - const trackingMixin = Tracking.mixin({ ...gon.tracking_data, experiment: this.experiment }); + const trackingMixin = Tracking.mixin(); const trackingInstance = new Vue({ ...trackingMixin, render() { @@ -35,7 +30,7 @@ export default { }; </script> <template> - <div class="container"> + <div class="container gl-display-flex gl-flex-direction-column"> <h2 class="gl-my-7 gl-font-size-h1 gl-text-center"> {{ title }} </h2> @@ -43,11 +38,12 @@ export default { <div v-for="panel in panels" :key="panel.name" - class="new-namespace-panel-wrapper gl-display-inline-block gl-px-3 gl-mb-5" + class="new-namespace-panel-wrapper gl-display-inline-block gl-float-left gl-px-3 gl-mb-5" > <a :href="`#${panel.name}`" - :data-qa-selector="`${panel.name}_link`" + data-qa-selector="panel_link" + :data-qa-panel-name="panel.name" class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-8 gl-hover-text-decoration-none!" @click="track('click_tab', { label: panel.name })" > diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index a2b432d11f4..c1e8376d656 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -36,11 +36,6 @@ export default { type: String, required: true, }, - experiment: { - type: String, - required: false, - default: null, - }, }, data() { @@ -103,12 +98,7 @@ export default { </script> <template> - <welcome-page - v-if="activePanelName === null" - :panels="panels" - :title="title" - :experiment="experiment" - > + <welcome-page v-if="!activePanelName" :panels="panels" :title="title"> <template #footer> <slot name="welcome-footer"> </slot> </template> diff --git a/app/assets/javascripts/vue_shared/plugins/global_toast.js b/app/assets/javascripts/vue_shared/plugins/global_toast.js index bfea2bedd40..fb52b31c2c8 100644 --- a/app/assets/javascripts/vue_shared/plugins/global_toast.js +++ b/app/assets/javascripts/vue_shared/plugins/global_toast.js @@ -2,7 +2,7 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; Vue.use(GlToast); -const instance = new Vue(); +export const instance = new Vue(); export default function showGlobalToast(...args) { return instance.$toast.show(...args); diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue index 12e5f634a08..0ff858e6afc 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -5,6 +5,10 @@ import { redirectTo } from '~/lib/utils/url_utility'; import { sprintf, s__ } from '~/locale'; import apolloProvider from '../provider'; +function mutationSettingsForFeatureType(type) { + return featureToMutationMap[type]; +} + export default { apolloProvider, components: { @@ -19,7 +23,7 @@ export default { variant: { type: String, required: false, - default: 'success', + default: 'confirm', }, category: { type: String, @@ -33,17 +37,19 @@ export default { }; }, computed: { - featureSettings() { - return featureToMutationMap[this.feature.type]; + mutationSettings() { + return mutationSettingsForFeatureType(this.feature.type); }, }, methods: { async mutate() { this.isLoading = true; try { - const mutation = this.featureSettings; - const { data } = await this.$apollo.mutate(mutation.getMutationPayload(this.projectPath)); - const { errors, successPath } = data[mutation.mutationId]; + const { mutationSettings } = this; + const { data } = await this.$apollo.mutate( + mutationSettings.getMutationPayload(this.projectPath), + ); + const { errors, successPath } = data[mutationSettings.mutationId]; if (errors.length > 0) { throw new Error(errors[0]); @@ -62,6 +68,22 @@ export default { } }, }, + /** + * Returns a boolean representing whether this component can be rendered for + * the given feature. Useful for parent components to determine whether or + * not to render this component. + * @param {Object} feature The feature to check. + * @returns {boolean} + */ + canRender(feature) { + const { available, configured, canEnableByMergeRequest, type } = feature; + return ( + canEnableByMergeRequest && + available && + !configured && + Boolean(mutationSettingsForFeatureType(type)) + ); + }, i18n: { buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'), noSuccessPathError: s__( @@ -74,6 +96,7 @@ export default { <template> <gl-button v-if="!feature.configured" + data-testid="configure-via-mr-button" :loading="isLoading" :variant="variant" :category="category" diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue index 8fdc5ca78db..f3dd26b02cb 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue @@ -76,6 +76,7 @@ export default { <template> <security-report-download-dropdown + :title="s__('SecurityReports|Download results')" :artifacts="reportArtifacts" :loading="isLoadingReportArtifacts" /> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue index 5d39d740c07..4178c5d1170 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue @@ -21,6 +21,16 @@ export default { required: false, default: false, }, + text: { + type: String, + required: false, + default: '', + }, + title: { + type: String, + required: false, + default: '', + }, }, methods: { artifactText({ name }) { @@ -35,7 +45,8 @@ export default { <template> <gl-dropdown v-gl-tooltip - :text="s__('SecurityReports|Download results')" + :text="text" + :title="title" :loading="loading" icon="download" size="small" diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index 1cdcf87097f..4a50dfbd82f 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -22,6 +22,7 @@ export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles'; export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning'; export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning'; +export const REPORT_TYPE_CLUSTER_IMAGE_SCANNING = 'cluster_image_scanning'; export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing'; export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning'; export const REPORT_TYPE_API_FUZZING = 'api_fuzzing'; diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue index d7a3d4e611e..3e0310e173e 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -200,6 +200,7 @@ export default { <template #action-buttons> <security-report-download-dropdown + :text="s__('SecurityReports|Download results')" :artifacts="reportArtifacts" :loading="isLoadingReportArtifacts" /> @@ -228,6 +229,7 @@ export default { <template #action-buttons> <security-report-download-dropdown + :text="s__('SecurityReports|Download results')" :artifacts="reportArtifacts" :loading="isLoadingReportArtifacts" /> diff --git a/app/assets/javascripts/vuex_shared/bindings.js b/app/assets/javascripts/vuex_shared/bindings.js index 741690886b7..bc3741a3880 100644 --- a/app/assets/javascripts/vuex_shared/bindings.js +++ b/app/assets/javascripts/vuex_shared/bindings.js @@ -6,7 +6,7 @@ * @param {string} list[].getter - the name of the getter, leave it empty to not use a getter * @param {string} list[].updateFn - the name of the action, leave it empty to use the default action * @param {string} defaultUpdateFn - the default function to dispatch - * @param {string} root - the key of the state where to search fo they keys described in list + * @param {string|function} root - the key of the state where to search for the keys described in list * @returns {Object} a dictionary with all the computed properties generated */ export const mapComputed = (list, defaultUpdateFn, root) => { @@ -21,6 +21,10 @@ export const mapComputed = (list, defaultUpdateFn, root) => { if (getter) { return this.$store.getters[getter]; } else if (root) { + if (typeof root === 'function') { + return root(this.$store.state)[key]; + } + return this.$store.state[root][key]; } return this.$store.state[key]; diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index 4ee586527b5..b74dba686ad 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -68,7 +68,7 @@ export default { :open="open" @close="closeDrawer" > - <template #header> + <template #title> <h4 class="page-title gl-my-2">{{ __("What's new") }}</h4> </template> <template v-if="features.length"> |