diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 11:59:07 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 11:59:07 +0000 |
commit | 8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca (patch) | |
tree | 544930fb309b30317ae9797a9683768705d664c4 /app/assets | |
parent | 4b1de649d0168371549608993deac953eb692019 (diff) | |
download | gitlab-ce-8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca.tar.gz |
Add latest changes from gitlab-org/gitlab@13-7-stable-eev13.7.0-rc42
Diffstat (limited to 'app/assets')
707 files changed, 12540 insertions, 8057 deletions
diff --git a/app/assets/images/checkmark.png b/app/assets/images/checkmark.png Binary files differnew file mode 100644 index 00000000000..6e47fda5cdc --- /dev/null +++ b/app/assets/images/checkmark.png diff --git a/app/assets/images/chevron-down.png b/app/assets/images/chevron-down.png Binary files differnew file mode 100644 index 00000000000..3f269e05d0b --- /dev/null +++ b/app/assets/images/chevron-down.png diff --git a/app/assets/images/jobs-empty-state.svg b/app/assets/images/jobs-empty-state.svg new file mode 100644 index 00000000000..e6e0681a002 --- /dev/null +++ b/app/assets/images/jobs-empty-state.svg @@ -0,0 +1,33 @@ +<svg width="234" height="162" viewBox="0 0 234 162" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M174.68 56.344H200.5C215.412 56.344 227.5 44.1787 227.5 29.172C227.5 14.1653 215.412 2 200.5 2C185.588 2 173.5 14.1653 173.5 29.172C173.5 36.2548 176.193 42.7046 180.604 47.5412" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/> +<path d="M145.5 76.4714C145.5 65.3553 154.454 56.344 165.5 56.344" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/> +<path d="M102.5 121.758H29.5C14.5883 121.758 2.5 109.593 2.5 94.586C2.5 79.5794 14.5883 67.4141 29.5 67.4141C44.4117 67.4141 56.5 79.5794 56.5 94.586C56.5 101.669 53.8072 108.119 49.3957 112.955" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/> +<path d="M67.0466 121.758H52.5C42.5589 121.758 34.5 129.868 34.5 139.873C34.5 149.877 42.5589 157.987 52.5 157.987C62.4411 157.987 70.5 149.877 70.5 139.873C70.5 137.478 70.0384 135.192 69.1998 133.1" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/> +<g clip-path="url(#clip0)"> +<path d="M55.0188 135.3C55.1617 134.764 54.8451 134.211 54.3117 134.068C53.7782 133.925 53.2298 134.243 53.0869 134.78L49.9811 146.445C49.8381 146.981 50.1547 147.534 50.6882 147.677C51.2217 147.821 51.77 147.503 51.9129 146.965L55.0188 135.3Z" fill="#FC6D26"/> +<path d="M49.2071 137.142C49.5976 137.534 49.5976 138.172 49.2071 138.565L46.9142 140.873L49.2071 143.18C49.5976 143.573 49.5976 144.211 49.2071 144.603C48.8166 144.997 48.1834 144.997 47.7929 144.603L44.7929 141.584C44.4024 141.192 44.4024 140.554 44.7929 140.161L47.7929 137.142C48.1834 136.748 48.8166 136.748 49.2071 137.142Z" fill="#FC6D26"/> +<path d="M55.7929 137.142C55.4024 137.534 55.4024 138.172 55.7929 138.565L58.0858 140.873L55.7929 143.18C55.4024 143.573 55.4024 144.211 55.7929 144.603C56.1834 144.997 56.8166 144.997 57.2071 144.603L60.2071 141.584C60.5976 141.192 60.5976 140.554 60.2071 140.161L57.2071 137.142C56.8166 136.748 56.1834 136.748 55.7929 137.142Z" fill="#FC6D26"/> +</g> +<path d="M212.102 160C222.815 160 231.5 151.214 231.5 140.376C231.5 129.537 222.815 120.752 212.102 120.752H151.5" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/> +<path d="M126.5 138.866C107.171 138.866 91.5 123.096 91.5 103.643C91.5 84.191 107.171 68.4204 126.5 68.4204C145.829 68.4204 161.5 84.191 161.5 103.643C161.5 123.096 145.829 138.866 126.5 138.866ZM126.5 131.451C141.76 131.451 154.132 119.001 154.132 103.643C154.132 88.2861 141.76 75.8358 126.5 75.8358C111.24 75.8358 98.8684 88.2861 98.8684 103.643C98.8684 119.001 111.24 131.451 126.5 131.451Z" fill="#FC6D26"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M126.126 87.1326C135.355 87.1326 142.906 94.5624 142.906 103.643C142.906 112.724 135.355 120.154 126.126 120.154C120.672 120.154 115.638 117.265 112.281 113.137L126.126 103.643V87.1326Z" fill="#6E49CB"/> +<g clip-path="url(#clip1)"> +<path d="M29.5 90.2659L24.3571 91.9534V93.1629C24.3571 94.9623 25.087 96.6872 26.3846 97.9546L29.5 100.997V90.2659Z" fill="#FC6D26"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 86.8909L29.5 83.5159L41.5 86.8909V93.1115C41.5 96.6919 40.0551 100.126 37.4832 102.657L29.5 110.516L21.5168 102.657C18.9449 100.126 17.5 96.6919 17.5 93.1115V86.8909ZM20.9286 93.1115V89.4366L29.5 87.0259L38.0714 89.4366V93.1115C38.0714 95.7968 36.9878 98.3721 35.0588 100.271L29.5 105.743L23.9412 100.271C22.0122 98.3721 20.9286 95.7968 20.9286 93.1115Z" fill="#FC6D26"/> +</g> +<g clip-path="url(#clip2)"> +<path d="M210.857 19.7297L209.51 24.8237C208.922 27.0445 207.518 28.9576 205.581 30.1752L194.728 36.999L191.862 34.1146L198.642 23.1922C199.852 21.2431 201.753 19.8298 203.96 19.2386L209.022 17.8826C209.822 17.6681 210.644 18.1474 210.857 18.953C210.925 19.2075 210.925 19.4752 210.857 19.7297ZM207.292 21.4702L204.732 22.1561C203.261 22.5503 201.993 23.4925 201.187 24.7918L196.517 32.3146L203.992 27.6148C205.283 26.803 206.219 25.5276 206.611 24.0471L207.292 21.4702ZM196.5 38.2294L204 33.7007V35.2103C204 38.5451 201.314 41.2485 198 41.2485H196.5V38.2294ZM190.5 32.1912H187.5V30.6816C187.5 27.3468 190.186 24.6434 193.5 24.6434H195L190.5 32.1912Z" fill="#FC6D26"/> +</g> +<path fill-rule="evenodd" clip-rule="evenodd" d="M209.914 132.822C209.384 132.822 208.875 133.032 208.5 133.407L204.796 137.111C204.613 137.293 204.5 137.544 204.5 137.822V144.822C204.5 145.926 205.395 146.822 206.5 146.822H216.5C217.605 146.822 218.5 145.926 218.5 144.822V137.822C218.5 137.546 218.388 137.296 218.207 137.115L214.5 133.407C214.125 133.032 213.616 132.822 213.086 132.822H209.914ZM215.086 136.822L213.086 134.822H212.5V136.822H215.086ZM210.5 134.822H209.914L207.914 136.822H210.5V134.822ZM206.5 138.822H216.5V144.822H206.5V138.822Z" fill="#FC6D26"/> +<defs> +<clipPath id="clip0"> +<rect width="16" height="13.6779" fill="white" transform="translate(44.5 134.033)"/> +</clipPath> +<clipPath id="clip1"> +<rect width="24" height="27.172" fill="white" transform="translate(17.5 83.5159)"/> +</clipPath> +<clipPath id="clip2"> +<rect width="24" height="24.1529" fill="white" transform="translate(187.5 17.0956)"/> +</clipPath> +</defs> +</svg> diff --git a/app/assets/javascripts/admin/users/components/app.vue b/app/assets/javascripts/admin/users/components/app.vue new file mode 100644 index 00000000000..a3abd904a6b --- /dev/null +++ b/app/assets/javascripts/admin/users/components/app.vue @@ -0,0 +1,26 @@ +<script> +import UsersTable from './users_table.vue'; + +export default { + components: { + UsersTable, + }, + props: { + users: { + type: Array, + required: false, + default: () => [], + }, + paths: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <users-table :users="users" :paths="paths" /> + </div> +</template> diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue new file mode 100644 index 00000000000..a2d68972519 --- /dev/null +++ b/app/assets/javascripts/admin/users/components/users_table.vue @@ -0,0 +1,63 @@ +<script> +import { GlTable } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const DEFAULT_TH_CLASSES = + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; +const thWidthClass = width => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`; + +export default { + components: { + GlTable, + }, + props: { + users: { + type: Array, + required: true, + }, + paths: { + type: Object, + required: true, + }, + }, + fields: [ + { + key: 'name', + label: __('Name'), + thClass: thWidthClass(40), + }, + { + key: 'projectsCount', + label: __('Projects'), + thClass: thWidthClass(10), + }, + { + key: 'createdAt', + label: __('Created on'), + thClass: thWidthClass(15), + }, + { + key: 'lastActivityOn', + label: __('Last activity'), + thClass: thWidthClass(15), + }, + { + key: 'settings', + label: '', + thClass: thWidthClass(20), + }, + ], +}; +</script> + +<template> + <div> + <gl-table + :items="users" + :fields="$options.fields" + :empty-text="s__('AdminUsers|No users found')" + show-empty + stacked="md" + /> + </div> +</template> diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js new file mode 100644 index 00000000000..21780ee9984 --- /dev/null +++ b/app/assets/javascripts/admin/users/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import AdminUsersApp from './components/app.vue'; + +export default function(el = document.querySelector('#js-admin-users-app')) { + if (!el) { + return false; + } + + const { users, paths } = el.dataset; + + return new Vue({ + el, + render: createElement => + createElement(AdminUsersApp, { + props: { + users: convertObjectPropsToCamelCase(JSON.parse(users), { deep: true }), + paths: convertObjectPropsToCamelCase(JSON.parse(paths)), + }, + }), + }); +} diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue index df07038151e..c39a72a45b9 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue @@ -27,25 +27,12 @@ export default { <gl-dropdown-item :key="user.username" data-testid="assigneeDropdownItem" - class="assignee-dropdown-item gl-vertical-align-middle" :active="active" active-class="is-active" + :avatar-url="user.avatar_url" + :secondary-text="`@${user.username}`" @click="$emit('update-alert-assignees', user.username)" > - <span class="gl-relative mr-2"> - <img - :alt="user.username" - :src="user.avatar_url" - :width="32" - class="avatar avatar-inline gl-m-0 s32" - data-qa-selector="avatar_image" - /> - </span> - <span class="d-flex gl-flex-direction-column gl-overflow-hidden"> - <strong class="dropdown-menu-user-full-name"> - {{ user.name }} - </strong> - <span class="dropdown-menu-user-username"> {{ user.username }}</span> - </span> + {{ user.name }} </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue index 5e4fd56738b..3af68d42ddf 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue @@ -13,7 +13,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.mutation.graphql'; import SidebarAssignee from './sidebar_assignee.vue'; @@ -96,7 +96,10 @@ export default { .sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1)); // eslint-disable-line no-nested-ternary }, dropdownClass() { - return this.isDropdownShowing ? 'show' : 'gl-display-none'; + return this.isDropdownShowing ? 'dropdown-menu-selectable show' : 'gl-display-none'; + }, + dropDownTitle() { + return this.userName ?? __('Select assignee'); }, userListValid() { return !this.isDropdownSearching && this.users.length > 0; @@ -217,81 +220,80 @@ export default { </a> </p> - <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-dropdown - ref="dropdown" - :text="userName" - class="w-100" - toggle-class="dropdown-menu-toggle" - @keydown.esc.native="hideDropdown" - @hide="hideDropdown" - > - <p class="gl-new-dropdown-header-top"> - {{ __('Assign To') }} - </p> - <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" /> - <div class="dropdown-content dropdown-body"> - <template v-if="userListValid"> - <gl-dropdown-item - :active="!userName" - active-class="is-active" - @click="updateAlertAssignees('')" - > - {{ __('Unassigned') }} - </gl-dropdown-item> - <gl-dropdown-divider /> - - <gl-dropdown-section-header> - {{ __('Assignee') }} - </gl-dropdown-section-header> - <sidebar-assignee - v-for="user in sortedUsers" - :key="user.username" - :user="user" - :active="user.active" - @update-alert-assignees="updateAlertAssignees" - /> - </template> - <p v-else-if="userListEmpty" class="mx-3 my-2"> - {{ __('No Matching Results') }} - </p> - <gl-loading-icon v-else /> - </div> - </gl-dropdown> - </div> + <gl-dropdown + ref="dropdown" + :text="dropDownTitle" + class="gl-w-full" + :class="dropdownClass" + toggle-class="dropdown-menu-toggle" + @keydown.esc.native="hideDropdown" + @hide="hideDropdown" + > + <p class="gl-new-dropdown-header-top"> + {{ __('Assign To') }} + </p> + <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" /> + <div class="dropdown-content dropdown-body"> + <template v-if="userListValid"> + <gl-dropdown-item + :active="!userName" + active-class="is-active" + @click="updateAlertAssignees('')" + > + {{ __('Unassigned') }} + </gl-dropdown-item> + <gl-dropdown-divider /> - <gl-loading-icon v-if="isUpdating" :inline="true" /> - <div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }"> - <div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users"> - <span class="gl-relative mr-2"> - <img - :alt="userName" - :src="userImg" - :width="32" - class="avatar avatar-inline gl-m-0 s32" - data-qa-selector="avatar_image" + <gl-dropdown-section-header> + {{ __('Assignee') }} + </gl-dropdown-section-header> + <sidebar-assignee + v-for="user in sortedUsers" + :key="user.username" + :user="user" + :active="user.active" + @update-alert-assignees="updateAlertAssignees" /> - </span> - <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden"> - <strong class="dropdown-menu-user-full-name"> - {{ userFullName }} - </strong> - <span class="dropdown-menu-user-username">{{ userName }}</span> - </span> + </template> + <p v-else-if="userListEmpty" class="gl-mx-5 gl-my-4"> + {{ __('No Matching Results') }} + </p> + <gl-loading-icon v-else /> </div> - <span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal"> - {{ __('None') }} - - <gl-button - class="gl-ml-2" - href="#" - variant="link" - data-testid="unassigned-users" - @click="updateAlertAssignees(currentUser)" - > - {{ __('assign yourself') }} - </gl-button> + </gl-dropdown> + </div> + + <gl-loading-icon v-if="isUpdating" :inline="true" /> + <div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }"> + <div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users"> + <span class="gl-relative gl-mr-4"> + <img + :alt="userName" + :src="userImg" + :width="32" + class="avatar avatar-inline gl-m-0 s32" + data-qa-selector="avatar_image" + /> + </span> + <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden"> + <strong class="dropdown-menu-user-full-name"> + {{ userFullName }} + </strong> + <span class="dropdown-menu-user-username">@{{ userName }}</span> </span> </div> + <span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal"> + {{ __('None') }} - + <gl-button + class="gl-ml-2" + href="#" + variant="link" + data-testid="unassigned-users" + @click="updateAlertAssignees(currentUser)" + > + {{ __('assign yourself') }} + </gl-button> + </span> </div> </div> </template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue index 12c0409629f..cf16750dbf8 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -11,7 +11,6 @@ import { GlSprintf, } from '@gitlab/ui'; import { s__, __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import Tracking from '~/tracking'; import { trackAlertIntegrationsViewsOptions, @@ -54,7 +53,6 @@ export default { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, - mixins: [glFeatureFlagsMixin()], props: { integrations: { type: Array, @@ -170,7 +168,7 @@ export default { </template> <template #cell(actions)="{ item }"> - <gl-button-group v-if="glFeatures.httpIntegrationsList" class="gl-ml-3"> + <gl-button-group class="gl-ml-3"> <gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" /> <gl-button v-gl-modal.deleteIntegration diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 3656fc4d7ec..b2be563522a 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -32,6 +32,75 @@ import { // feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171 import mockedCustomMapping from './mocks/parsedMapping.json'; +export const i18n = { + integrationFormSteps: { + step1: { + label: s__('AlertSettings|1. Select integration type'), + enterprise: s__( + 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.', + ), + }, + step2: { + label: s__('AlertSettings|2. Name integration'), + placeholder: s__('AlertSettings|Enter integration name'), + prometheus: s__('AlertSettings|Prometheus'), + }, + step3: { + label: s__('AlertSettings|3. Set up webhook'), + help: s__( + "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", + ), + prometheusHelp: s__( + 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.', + ), + info: s__('AlertSettings|Authorization key'), + reset: s__('AlertSettings|Reset Key'), + }, + step4: { + label: s__('AlertSettings|4. Sample alert payload (optional)'), + help: s__( + 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).', + ), + prometheusHelp: s__( + 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).', + ), + placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'), + resetHeader: s__('AlertSettings|Reset the mapping'), + resetBody: s__( + "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.", + ), + resetOk: s__('AlertSettings|Proceed with editing'), + editPayload: s__('AlertSettings|Edit payload'), + submitPayload: s__('AlertSettings|Submit payload'), + payloadParsedSucessMsg: s__( + 'AlertSettings|Sample payload has been parsed. You can now map the fields.', + ), + }, + step5: { + label: s__('AlertSettings|5. Map fields (optional)'), + intro: s__( + "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.", + ), + }, + prometheusFormUrl: { + label: s__('AlertSettings|Prometheus API base URL'), + help: s__('AlertSettings|URL cannot be blank and must start with http or https'), + }, + restKeyInfo: { + label: s__( + 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.', + ), + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + opsgenie: { + label: s__('AlertSettings|2. Add link to your Opsgenie alert list'), + info: s__( + 'AlertSettings|Utilizing this option will link the GitLab Alerts navigation item to your existing Opsgenie instance. By selecting this option, you cannot receive alerts from any other source in GitLab; it will effectively be turning Alerts within GitLab off as a feature.', + ), + }, + }, +}; + export default { placeholders: { prometheus: targetPrometheusUrlPlaceholder, @@ -39,73 +108,7 @@ export default { }, JSON_VALIDATE_DELAY, typeSet, - i18n: { - integrationFormSteps: { - step1: { - label: s__('AlertSettings|1. Select integration type'), - enterprise: s__( - 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.', - ), - }, - step2: { - label: s__('AlertSettings|2. Name integration'), - placeholder: s__('AlertSettings|Enter integration name'), - }, - step3: { - label: s__('AlertSettings|3. Set up webhook'), - help: s__( - "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", - ), - prometheusHelp: s__( - 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.', - ), - info: s__('AlertSettings|Authorization key'), - reset: s__('AlertSettings|Reset Key'), - }, - step4: { - label: s__('AlertSettings|4. Sample alert payload (optional)'), - help: s__( - 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).', - ), - prometheusHelp: s__( - 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).', - ), - placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'), - resetHeader: s__('AlertSettings|Reset the mapping'), - resetBody: s__( - "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.", - ), - resetOk: s__('AlertSettings|Proceed with editing'), - editPayload: s__('AlertSettings|Edit payload'), - submitPayload: s__('AlertSettings|Submit payload'), - payloadParsedSucessMsg: s__( - 'AlertSettings|Sample payload has been parsed. You can now map the fields.', - ), - }, - step5: { - label: s__('AlertSettings|5. Map fields (optional)'), - intro: s__( - "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.", - ), - }, - prometheusFormUrl: { - label: s__('AlertSettings|Prometheus API base URL'), - help: s__('AlertSettings|URL cannot be blank and must start with http or https'), - }, - restKeyInfo: { - label: s__( - 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.', - ), - }, - // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 - opsgenie: { - label: s__('AlertSettings|2. Add link to your Opsgenie alert list'), - info: s__( - 'AlertSettings|Utilizing this option will link the GitLab Alerts navigation item to your existing Opsgenie instance. By selecting this option, you cannot receive alerts from any other source in GitLab; it will effectively be turning Alerts within GitLab off as a feature.', - ), - }, - }, - }, + i18n, components: { ClipboardButton, GlButton, @@ -216,8 +219,12 @@ export default { return { name: this.currentIntegration?.name || '', active: this.currentIntegration?.active || false, - token: this.currentIntegration?.token || this.selectedIntegrationType.token, - url: this.currentIntegration?.url || this.selectedIntegrationType.url, + token: + this.currentIntegration?.token || + (this.selectedIntegrationType !== this.generic ? this.selectedIntegrationType.token : ''), + url: + this.currentIntegration?.url || + (this.selectedIntegrationType !== this.generic ? this.selectedIntegrationType.url : ''), apiUrl: this.currentIntegration?.apiUrl || '', }; }, @@ -246,8 +253,23 @@ export default { canEditPayload() { return this.hasSamplePayload && !this.resetSamplePayloadConfirmed; }, + isResetAuthKeyDisabled() { + return !this.active && !this.integrationForm.token !== ''; + }, isPayloadEditDisabled() { - return !this.active || this.canEditPayload; + return this.glFeatures.multipleHttpIntegrationsCustomMapping + ? !this.active || this.canEditPayload + : !this.active; + }, + isSubmitTestPayloadDisabled() { + return ( + !this.active || + Boolean(this.integrationTestPayload.error) || + this.integrationTestPayload.json === '' + ); + }, + isSelectDisabled() { + return this.currentIntegration !== null || !this.canAddIntegration; }, }, watch: { @@ -257,7 +279,7 @@ export default { } this.selectedIntegration = val.type; this.active = val.active; - if (val.type === typeSet.http) this.getIntegrationMapping(val.id); + if (val.type === typeSet.http && this.showMappingBuilder) this.getIntegrationMapping(val.id); return this.integrationTypeSelect(); }, }, @@ -297,14 +319,8 @@ export default { }); }, submitWithTestPayload() { - return service - .updateTestAlert(this.testAlertPayload) - .then(() => { - this.submit(); - }) - .catch(() => { - this.$emit('test-payload-failure'); - }); + this.$emit('set-test-alert-payload', this.testAlertPayload); + this.submit(); }, submit() { // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 @@ -323,6 +339,7 @@ export default { return this.$emit('update-integration', integrationPayload); } + this.reset(); return this.$emit('create-new-integration', integrationPayload); }, reset() { @@ -410,7 +427,8 @@ export default { > <gl-form-select v-model="selectedIntegration" - :disabled="currentIntegration !== null || !canAddIntegration" + :disabled="isSelectDisabled" + :class="{ 'gl-bg-gray-100!': isSelectDisabled }" :options="options" @change="integrationTypeSelect" /> @@ -461,8 +479,13 @@ export default { > <gl-form-input v-model="integrationForm.name" + :disabled="isPrometheus" type="text" - :placeholder="$options.i18n.integrationFormSteps.step2.placeholder" + :placeholder=" + isPrometheus + ? $options.i18n.integrationFormSteps.step2.prometheus + : $options.i18n.integrationFormSteps.step2.placeholder + " /> </gl-form-group> <gl-form-group @@ -539,7 +562,7 @@ export default { </template> </gl-form-input-group> - <gl-button v-gl-modal.authKeyModal :disabled="!active"> + <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled"> {{ $options.i18n.integrationFormSteps.step3.reset }} </gl-button> <gl-modal @@ -642,7 +665,7 @@ export default { <gl-button v-if="!isManagingOpsgenie" data-testid="integration-test-and-submit" - :disabled="Boolean(integrationTestPayload.error)" + :disabled="isSubmitTestPayloadDisabled" category="secondary" variant="success" class="gl-mx-3 js-no-auto-disable" diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue deleted file mode 100644 index 0246315bdc5..00000000000 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue +++ /dev/null @@ -1,494 +0,0 @@ -<script> -import { - GlAlert, - GlButton, - GlForm, - GlFormGroup, - GlFormInput, - GlFormInputGroup, - GlFormTextarea, - GlLink, - GlModal, - GlModalDirective, - GlSprintf, - GlFormSelect, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; -import { doesHashExistInUrl } from '~/lib/utils/url_utility'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import ToggleButton from '~/vue_shared/components/toggle_button.vue'; -import csrf from '~/lib/utils/csrf'; -import service from '../services'; -import { - i18n, - integrationTypes, - JSON_VALIDATE_DELAY, - targetPrometheusUrlPlaceholder, - targetOpsgenieUrlPlaceholder, - sectionHash, -} from '../constants'; -import createFlash, { FLASH_TYPES } from '~/flash'; - -export default { - i18n, - csrf, - targetOpsgenieUrlPlaceholder, - targetPrometheusUrlPlaceholder, - components: { - GlAlert, - GlButton, - GlForm, - GlFormGroup, - GlFormInput, - GlFormInputGroup, - GlFormSelect, - GlFormTextarea, - GlLink, - GlModal, - GlSprintf, - ClipboardButton, - ToggleButton, - }, - directives: { - 'gl-modal': GlModalDirective, - }, - inject: ['prometheus', 'generic', 'opsgenie'], - data() { - return { - loading: false, - selectedIntegration: integrationTypes[0].value, - options: integrationTypes, - active: false, - token: '', - targetUrl: '', - feedback: { - variant: 'danger', - feedbackMessage: '', - isFeedbackDismissed: false, - }, - testAlert: { - json: null, - error: null, - }, - canSaveForm: false, - serverError: null, - }; - }, - computed: { - sections() { - return [ - { - text: this.$options.i18n.usageSection, - url: this.generic.alertsUsageUrl, - }, - { - text: this.$options.i18n.setupSection, - url: this.generic.alertsSetupUrl, - }, - ]; - }, - isPrometheus() { - return this.selectedIntegration === 'PROMETHEUS'; - }, - isOpsgenie() { - return this.selectedIntegration === 'OPSGENIE'; - }, - selectedIntegrationType() { - switch (this.selectedIntegration) { - case 'HTTP': { - return { - url: this.generic.url, - token: this.generic.token, - active: this.generic.active, - resetKey: this.resetKey.bind(this), - }; - } - case 'PROMETHEUS': { - return { - url: this.prometheus.url, - token: this.prometheus.token, - active: this.prometheus.active, - resetKey: this.resetKey.bind(this, 'PROMETHEUS'), - targetUrl: this.prometheus.prometheusApiUrl, - }; - } - case 'OPSGENIE': { - return { - targetUrl: this.opsgenie.opsgenieMvcTargetUrl, - active: this.opsgenie.active, - }; - } - default: { - return {}; - } - } - }, - showFeedbackMsg() { - return this.feedback.feedbackMessage && !this.isFeedbackDismissed; - }, - showAlertSave() { - return ( - this.feedback.feedbackMessage === this.$options.i18n.testAlertFailed && - !this.isFeedbackDismissed - ); - }, - prometheusInfo() { - return this.isPrometheus ? this.$options.i18n.prometheusInfo : ''; - }, - jsonIsValid() { - return this.testAlert.error === null; - }, - canTestAlert() { - return this.active && this.testAlert.json !== null; - }, - canSaveConfig() { - return !this.loading && this.canSaveForm; - }, - baseUrlPlaceholder() { - return this.isOpsgenie - ? this.$options.targetOpsgenieUrlPlaceholder - : this.$options.targetPrometheusUrlPlaceholder; - }, - }, - watch: { - 'testAlert.json': debounce(function debouncedJsonValidate() { - this.validateJson(); - }, JSON_VALIDATE_DELAY), - targetUrl(oldVal, newVal) { - if (newVal && oldVal !== this.selectedIntegrationType.targetUrl) { - this.canSaveForm = true; - } - }, - }, - mounted() { - if (this.prometheus.active || this.generic.active || !this.opsgenie.opsgenieMvcIsAvailable) { - this.removeOpsGenieOption(); - } else if (this.opsgenie.active) { - this.setOpsgenieAsDefault(); - } - this.active = this.selectedIntegrationType.active; - this.token = this.selectedIntegrationType.token ?? ''; - }, - methods: { - createUserErrorMessage(errors = {}) { - const error = Object.entries(errors)?.[0]; - if (error) { - const [field, [msg]] = error; - this.serverError = `${field} ${msg}`; - } - }, - setOpsgenieAsDefault() { - this.options = this.options.map(el => { - if (el.value !== 'OPSGENIE') { - return { ...el, disabled: true }; - } - return { ...el, disabled: false }; - }); - this.selectedIntegration = this.options.find(({ value }) => value === 'OPSGENIE').value; - if (this.targetUrl === null) { - this.targetUrl = this.selectedIntegrationType.targetUrl; - } - }, - removeOpsGenieOption() { - this.options = this.options.map(el => { - if (el.value !== 'OPSGENIE') { - return { ...el, disabled: false }; - } - return { ...el, disabled: true }; - }); - }, - resetFormValues() { - this.testAlert.json = null; - this.targetUrl = this.selectedIntegrationType.targetUrl; - this.active = this.selectedIntegrationType.active; - }, - dismissFeedback() { - this.serverError = null; - this.feedback = { ...this.feedback, feedbackMessage: null }; - this.isFeedbackDismissed = false; - }, - resetKey(key) { - const fn = key === 'PROMETHEUS' ? this.resetPrometheusKey() : this.resetGenericKey(); - - return fn - .then(({ data: { token } }) => { - this.token = token; - this.setFeedback({ feedbackMessage: this.$options.i18n.tokenRest, variant: 'success' }); - }) - .catch(() => { - this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); - }); - }, - resetGenericKey() { - this.dismissFeedback(); - return service.updateGenericKey({ - endpoint: this.generic.formPath, - params: { service: { token: '' } }, - }); - }, - resetPrometheusKey() { - return service.updatePrometheusKey({ endpoint: this.prometheus.prometheusResetKeyPath }); - }, - toggleService(value) { - this.canSaveForm = true; - this.active = value; - }, - toggle(value) { - return this.isPrometheus ? this.togglePrometheusActive(value) : this.toggleActivated(value); - }, - toggleActivated(value) { - this.loading = true; - const path = this.isOpsgenie ? this.opsgenie.formPath : this.generic.formPath; - return service - .updateGenericActive({ - endpoint: path, - params: this.isOpsgenie - ? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } } - : { service: { active: value } }, - }) - .then(() => this.notifySuccessAndReload()) - .catch(({ response: { data: { errors } = {} } = {} }) => { - this.createUserErrorMessage(errors); - this.setFeedback({ - feedbackMessage: this.$options.i18n.errorMsg, - variant: 'danger', - }); - }) - .finally(() => { - this.loading = false; - this.canSaveForm = false; - }); - }, - reload() { - if (!doesHashExistInUrl(sectionHash)) { - window.location.hash = sectionHash; - } - window.location.reload(); - }, - togglePrometheusActive(value) { - this.loading = true; - return service - .updatePrometheusActive({ - endpoint: this.prometheus.prometheusFormPath, - params: { - token: this.$options.csrf.token, - config: value, - url: this.targetUrl, - redirect: window.location, - }, - }) - .then(() => this.notifySuccessAndReload()) - .catch(({ response: { data: { errors } = {} } = {} }) => { - this.createUserErrorMessage(errors); - this.setFeedback({ - feedbackMessage: this.$options.i18n.errorMsg, - variant: 'danger', - }); - }) - .finally(() => { - this.loading = false; - this.canSaveForm = false; - }); - }, - notifySuccessAndReload() { - createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.NOTICE }); - setTimeout(() => this.reload(), 1000); - }, - setFeedback({ feedbackMessage, variant }) { - this.feedback = { feedbackMessage, variant }; - }, - validateJson() { - this.testAlert.error = null; - try { - JSON.parse(this.testAlert.json); - } catch (e) { - this.testAlert.error = JSON.stringify(e.message); - } - }, - validateTestAlert() { - this.loading = true; - this.dismissFeedback(); - this.validateJson(); - return service - .updateTestAlert({ - endpoint: this.selectedIntegrationType.url, - data: this.testAlert.json, - token: this.selectedIntegrationType.token, - }) - .then(() => { - this.setFeedback({ - feedbackMessage: this.$options.i18n.testAlertSuccess, - variant: 'success', - }); - }) - .catch(() => { - this.setFeedback({ - feedbackMessage: this.$options.i18n.testAlertFailed, - variant: 'danger', - }); - }) - .finally(() => { - this.loading = false; - }); - }, - onSubmit() { - this.dismissFeedback(); - this.toggle(this.active); - }, - onReset() { - this.testAlert.json = null; - this.dismissFeedback(); - this.targetUrl = this.selectedIntegrationType.targetUrl; - - if (this.canSaveForm) { - this.canSaveForm = false; - this.active = this.selectedIntegrationType.active; - } - }, - }, -}; -</script> - -<template> - <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> - <h5 class="gl-font-lg gl-my-5">{{ $options.i18n.integrationsLabel }}</h5> - - <gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback"> - {{ feedback.feedbackMessage }} - <br /> - <i v-if="serverError">{{ __('Error message:') }} {{ serverError }}</i> - <gl-button - v-if="showAlertSave" - variant="danger" - category="primary" - class="gl-display-block gl-mt-3" - @click="toggle(active)" - > - {{ __('Save anyway') }} - </gl-button> - </gl-alert> - - <div data-testid="alert-settings-description"> - <p v-for="section in sections" :key="section.text"> - <gl-sprintf :message="section.text"> - <template #link="{ content }"> - <gl-link :href="section.url" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - </div> - - <gl-form-group label-for="integration-type" :label="$options.i18n.integration"> - <gl-form-select - id="integration-type" - v-model="selectedIntegration" - :options="options" - data-testid="alert-settings-select" - @change="resetFormValues" - /> - <span class="gl-text-gray-500"> - <gl-sprintf :message="$options.i18n.integrationsInfo"> - <template #link="{ content }"> - <gl-link - class="gl-display-inline-block" - href="https://gitlab.com/groups/gitlab-org/-/epics/4390" - target="_blank" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </span> - </gl-form-group> - <gl-form-group :label="$options.i18n.activeLabel" label-for="active"> - <toggle-button - id="active" - :disabled-input="loading" - :is-loading="loading" - :value="active" - @change="toggleService" - /> - </gl-form-group> - <gl-form-group - v-if="isOpsgenie || isPrometheus" - :label="$options.i18n.apiBaseUrlLabel" - label-for="api-url" - > - <gl-form-input - id="api-url" - v-model="targetUrl" - type="url" - :placeholder="baseUrlPlaceholder" - :disabled="!active" - /> - <span class="gl-text-gray-500"> - {{ $options.i18n.apiBaseUrlHelpText }} - </span> - </gl-form-group> - <template v-if="!isOpsgenie"> - <gl-form-group :label="$options.i18n.urlLabel" label-for="url"> - <gl-form-input-group id="url" readonly :value="selectedIntegrationType.url"> - <template #append> - <clipboard-button - :text="selectedIntegrationType.url" - :title="$options.i18n.copyToClipboard" - class="gl-m-0!" - /> - </template> - </gl-form-input-group> - <span class="gl-text-gray-500"> - {{ prometheusInfo }} - </span> - </gl-form-group> - <gl-form-group :label="$options.i18n.tokenLabel" label-for="authorization-key"> - <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="token"> - <template #append> - <clipboard-button - :text="token" - :title="$options.i18n.copyToClipboard" - class="gl-m-0!" - /> - </template> - </gl-form-input-group> - <gl-button v-gl-modal.tokenModal :disabled="!active" class="gl-mt-3">{{ - $options.i18n.resetKey - }}</gl-button> - <gl-modal - modal-id="tokenModal" - :title="$options.i18n.resetKey" - :ok-title="$options.i18n.resetKey" - ok-variant="danger" - @ok="selectedIntegrationType.resetKey" - > - {{ $options.i18n.restKeyInfo }} - </gl-modal> - </gl-form-group> - <gl-form-group - :label="$options.i18n.alertJson" - label-for="alert-json" - :invalid-feedback="testAlert.error" - > - <gl-form-textarea - id="alert-json" - v-model.trim="testAlert.json" - :disabled="!active" - :state="jsonIsValid" - :placeholder="$options.i18n.alertJsonPlaceholder" - rows="6" - max-rows="10" - /> - </gl-form-group> - - <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ - $options.i18n.testAlertInfo - }}</gl-button> - </template> - <div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"> - <gl-button variant="success" category="primary" :disabled="!canSaveConfig" @click="onSubmit"> - {{ __('Save changes') }} - </gl-button> - <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset"> - {{ __('Cancel') }} - </gl-button> - </div> - </gl-form> -</template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index 1ffc2f80148..a55e63c3bc0 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -1,7 +1,6 @@ <script> import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { fetchPolicies } from '~/lib/graphql'; import createFlash, { FLASH_TYPES } from '~/flash'; import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql'; @@ -15,8 +14,8 @@ import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutati import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql'; import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql'; import IntegrationsList from './alerts_integrations_list.vue'; -import SettingsFormOld from './alerts_settings_form_old.vue'; -import SettingsFormNew from './alerts_settings_form_new.vue'; +import AlertSettingsForm from './alerts_settings_form.vue'; +import service from '../services'; import { typeSet } from '../constants'; import { updateStoreAfterIntegrationDelete, @@ -37,6 +36,9 @@ export default { 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.', ), integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'), + alertSent: s__( + 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.', + ), }, components: { // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 @@ -44,10 +46,8 @@ export default { GlLink, GlSprintf, IntegrationsList, - SettingsFormOld, - SettingsFormNew, + AlertSettingsForm, }, - mixins: [glFeatureFlagsMixin()], inject: { generic: { default: {}, @@ -93,6 +93,7 @@ export default { data() { return { isUpdating: false, + testAlertPayload: null, integrations: {}, currentIntegration: null, }; @@ -101,25 +102,12 @@ export default { loading() { return this.$apollo.queries.integrations.loading; }, - integrationsOptionsOld() { - return [ - { - name: s__('AlertSettings|HTTP endpoint'), - type: s__('AlertsIntegrations|HTTP endpoint'), - active: this.generic.active, - }, - { - name: s__('AlertSettings|External Prometheus'), - type: s__('AlertsIntegrations|Prometheus'), - active: this.prometheus.active, - }, - ]; - }, canAddIntegration() { return this.multiIntegrations || this.integrations?.list?.length < 2; }, canManageOpsgenie() { return ( + this.opsgenie.active || this.integrations?.list?.every(({ active }) => active === false) || this.integrations?.list?.length === 0 ); @@ -149,6 +137,19 @@ export default { if (error) { return createFlash({ message: error }); } + + if (this.testAlertPayload) { + const integration = + httpIntegrationCreate?.integration || prometheusIntegrationCreate?.integration; + + const payload = { + ...this.testAlertPayload, + endpoint: integration.url, + token: integration.token, + }; + return this.validateAlertPayload(payload); + } + return createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.SUCCESS, @@ -179,6 +180,13 @@ export default { if (error) { return createFlash({ message: error }); } + + if (this.testAlertPayload) { + return this.validateAlertPayload(); + } + + this.clearCurrentIntegration(); + return createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.SUCCESS, @@ -189,6 +197,7 @@ export default { }) .finally(() => { this.isUpdating = false; + this.testAlertPayload = null; }); }, resetToken({ type, variables }) { @@ -212,7 +221,13 @@ export default { const integration = httpIntegrationResetToken?.integration || prometheusIntegrationResetToken?.integration; - this.currentIntegration = integration; + + this.$apollo.mutate({ + mutation: updateCurrentIntergrationMutation, + variables: { + ...integration, + }, + }); return createFlash({ message: this.$options.i18n.changesSaved, @@ -280,8 +295,21 @@ export default { variables: {}, }); }, - testPayloadFailure() { - createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + setTestAlertPayload(payload) { + this.testAlertPayload = payload; + }, + validateAlertPayload(payload) { + return service + .updateTestAlert(payload ?? this.testAlertPayload) + .then(() => { + return createFlash({ + message: this.$options.i18n.alertSent, + type: FLASH_TYPES.SUCCESS, + }); + }) + .catch(() => { + createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + }); }, }, }; @@ -310,13 +338,12 @@ export default { </gl-alert> <integrations-list v-else - :integrations="glFeatures.httpIntegrationsList ? integrations.list : integrationsOptionsOld" + :integrations="integrations.list" :loading="loading" @edit-integration="editIntegration" @delete-integration="deleteIntegration" /> - <settings-form-new - v-if="glFeatures.httpIntegrationsList" + <alert-settings-form :loading="isUpdating" :can-add-integration="canAddIntegration" :can-manage-opsgenie="canManageOpsgenie" @@ -324,8 +351,7 @@ export default { @update-integration="updateIntegration" @reset-token="resetToken" @clear-current-integration="clearCurrentIntegration" - @test-payload-failure="testPayloadFailure" + @set-test-alert-payload="setTestAlertPayload" /> - <settings-form-old v-else /> </div> </template> diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js index 1835d6b46aa..e45ea772ddd 100644 --- a/app/assets/javascripts/alerts_settings/services/index.js +++ b/app/assets/javascripts/alerts_settings/services/index.js @@ -2,30 +2,9 @@ import axios from '~/lib/utils/axios_utils'; export default { - // TODO: All this code save updateTestAlert will be deleted as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/255501 - updateGenericKey({ endpoint, params }) { - return axios.put(endpoint, params); - }, - updatePrometheusKey({ endpoint }) { - return axios.post(endpoint); - }, updateGenericActive({ endpoint, params }) { return axios.put(endpoint, params); }, - updatePrometheusActive({ endpoint, params: { token, config, url, redirect } }) { - const data = new FormData(); - data.set('_method', 'put'); - data.set('authenticity_token', token); - data.set('service[manual_configuration]', config); - data.set('service[api_url]', url); - data.set('redirect_to', redirect); - - return axios.post(endpoint, data, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); - }, updateTestAlert({ endpoint, data, token }) { return axios.post(endpoint, data, { headers: { diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql deleted file mode 100644 index 40cef95c2e7..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql +++ /dev/null @@ -1,4 +0,0 @@ -fragment Count on InstanceStatisticsMeasurement { - count - recordedAt -} diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index f469f49ce20..8daccae3467 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -69,6 +69,7 @@ const Api = { issuePath: '/api/:version/projects/:id/issues/:issue_iid', tagsPath: '/api/:version/projects/:id/repository/tags', freezePeriodsPath: '/api/:version/projects/:id/freeze_periods', + usageDataIncrementCounterPath: '/api/:version/usage_data/increment_counter', usageDataIncrementUniqueUsersPath: '/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', @@ -389,7 +390,10 @@ const Api = { params: { ...defaults, ...options }, }) .then(({ data }) => callback(data)) - .catch(() => flash(__('Something went wrong while fetching projects'))); + .catch(() => { + flash(__('Something went wrong while fetching projects')); + callback(); + }); }, commit(id, sha, params = {}) { @@ -751,6 +755,19 @@ const Api = { return axios.post(url, freezePeriod); }, + trackRedisCounterEvent(event) { + if (!gon.features?.usageDataApi) { + return null; + } + + const url = Api.buildUrl(this.usageDataIncrementCounterPath); + const headers = { + 'Content-Type': 'application/json', + }; + + return axios.post(url, { event }, { headers }); + }, + trackRedisHllUserEvent(event) { if (!gon.features?.usageDataApi) { return null; diff --git a/app/assets/javascripts/authentication/mount_2fa.js b/app/assets/javascripts/authentication/mount_2fa.js index dd5a42fa5fc..6dead2f03db 100644 --- a/app/assets/javascripts/authentication/mount_2fa.js +++ b/app/assets/javascripts/authentication/mount_2fa.js @@ -13,11 +13,17 @@ export const mount2faAuthentication = () => { }; export const mount2faRegistration = () => { + const el = $('#js-register-token-2fa'); + + if (!el.length) { + return; + } + if (gon.webauthn) { - const webauthnRegister = new WebAuthnRegister($('#js-register-token-2fa'), gon.webauthn); + const webauthnRegister = new WebAuthnRegister(el, gon.webauthn); webauthnRegister.start(); } else { - const u2fRegister = new U2FRegister($('#js-register-token-2fa'), gon.u2f); + const u2fRegister = new U2FRegister(el, gon.u2f); u2fRegister.start(); } }; diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue new file mode 100644 index 00000000000..87502db8b82 --- /dev/null +++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue @@ -0,0 +1,174 @@ +<script> +import Mousetrap from 'mousetrap'; +import { GlSprintf, GlButton, GlAlert } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Tracking from '~/tracking'; +import { __ } from '~/locale'; +import { + COPY_BUTTON_ACTION, + DOWNLOAD_BUTTON_ACTION, + PRINT_BUTTON_ACTION, + TRACKING_LABEL_PREFIX, + RECOVERY_CODE_DOWNLOAD_FILENAME, + COPY_KEYBOARD_SHORTCUT, +} from '../constants'; + +export const i18n = { + pageTitle: __('Two-factor Authentication Recovery codes'), + alertTitle: __('Please copy, download, or print your recovery codes before proceeding.'), + pageDescription: __( + 'Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{boldStart}will%{boldEnd} lose access to your account.', + ), + copyButton: __('Copy codes'), + downloadButton: __('Download codes'), + printButton: __('Print codes'), + proceedButton: __('Proceed'), +}; + +export default { + name: 'RecoveryCodes', + copyButtonAction: COPY_BUTTON_ACTION, + downloadButtonAction: DOWNLOAD_BUTTON_ACTION, + printButtonAction: PRINT_BUTTON_ACTION, + trackingLabelPrefix: TRACKING_LABEL_PREFIX, + recoveryCodeDownloadFilename: RECOVERY_CODE_DOWNLOAD_FILENAME, + i18n, + mousetrap: null, + components: { GlSprintf, GlButton, GlAlert, ClipboardButton }, + mixins: [Tracking.mixin()], + props: { + codes: { + type: Array, + required: true, + }, + profileAccountPath: { + type: String, + required: true, + }, + }, + data() { + return { + proceedButtonDisabled: true, + }; + }, + computed: { + codesAsString() { + return this.codes.join('\n'); + }, + codeDownloadUrl() { + return `data:text/plain;charset=utf-8,${encodeURIComponent(this.codesAsString)}`; + }, + }, + created() { + this.$options.mousetrap = new Mousetrap(); + + this.$options.mousetrap.bind(COPY_KEYBOARD_SHORTCUT, this.handleKeyboardCopy); + }, + beforeDestroy() { + if (!this.$options.mousetrap) { + return; + } + + this.$options.mousetrap.unbind(COPY_KEYBOARD_SHORTCUT); + }, + methods: { + handleButtonClick(action) { + this.proceedButtonDisabled = false; + + if (action === this.$options.printButtonAction) { + window.print(); + } + + this.track('click_button', { label: `${this.$options.trackingLabelPrefix}${action}_button` }); + }, + handleKeyboardCopy() { + if (!window.getSelection) { + return; + } + + const copiedText = window.getSelection().toString(); + + if (copiedText.includes(this.codesAsString)) { + this.proceedButtonDisabled = false; + this.track('copy_keyboard_shortcut', { + label: `${this.$options.trackingLabelPrefix}manual_copy`, + }); + } + }, + }, +}; +</script> + +<template> + <div> + <h3 class="page-title"> + {{ $options.i18n.pageTitle }} + </h3> + <hr /> + <gl-alert variant="info" :dismissible="false"> + {{ $options.i18n.alertTitle }} + </gl-alert> + <p class="gl-mt-5"> + <gl-sprintf :message="$options.i18n.pageDescription"> + <template #bold="{ content }" + ><strong>{{ content }}</strong></template + > + </gl-sprintf> + </p> + + <div + class="codes-to-print gl-my-5 gl-p-5 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base" + data-testid="recovery-codes" + data-qa-selector="codes_content" + > + <ul class="gl-m-0 gl-pl-5"> + <li v-for="(code, index) in codes" :key="index"> + <span class="gl-font-monospace" data-qa-selector="code_content">{{ code }}</span> + </li> + </ul> + </div> + <div class="gl-my-n2 gl-mx-n2 gl-display-flex gl-flex-wrap"> + <div class="gl-p-2"> + <clipboard-button + :title="$options.i18n.copyButton" + :text="codesAsString" + data-qa-selector="copy_button" + @click="handleButtonClick($options.copyButtonAction)" + > + {{ $options.i18n.copyButton }} + </clipboard-button> + </div> + <div class="gl-p-2"> + <gl-button + :href="codeDownloadUrl" + :title="$options.i18n.downloadButton" + icon="download" + :download="$options.recoveryCodeDownloadFilename" + @click="handleButtonClick($options.downloadButtonAction)" + > + {{ $options.i18n.downloadButton }} + </gl-button> + </div> + <div class="gl-p-2"> + <gl-button + :title="$options.i18n.printButton" + @click="handleButtonClick($options.printButtonAction)" + > + {{ $options.i18n.printButton }} + </gl-button> + </div> + <div class="gl-p-2"> + <gl-button + :href="profileAccountPath" + :disabled="proceedButtonDisabled" + :title="$options.i18n.proceedButton" + variant="success" + data-qa-selector="proceed_button" + data-track-event="click_button" + :data-track-label="`${$options.trackingLabelPrefix}proceed_button`" + >{{ $options.i18n.proceedButton }}</gl-button + > + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/authentication/two_factor_auth/constants.js b/app/assets/javascripts/authentication/two_factor_auth/constants.js new file mode 100644 index 00000000000..35fc49c88b2 --- /dev/null +++ b/app/assets/javascripts/authentication/two_factor_auth/constants.js @@ -0,0 +1,11 @@ +export const COPY_BUTTON_ACTION = 'copy'; +export const DOWNLOAD_BUTTON_ACTION = 'download'; +export const PRINT_BUTTON_ACTION = 'print'; + +export const TRACKING_LABEL_PREFIX = '2fa_recovery_codes_'; + +export const RECOVERY_CODE_DOWNLOAD_FILENAME = 'gitlab-recovery-codes.txt'; + +export const SUCCESS_QUERY_PARAM = 'two_factor_auth_enabled_successfully'; + +export const COPY_KEYBOARD_SHORTCUT = 'mod+c'; diff --git a/app/assets/javascripts/authentication/two_factor_auth/index.js b/app/assets/javascripts/authentication/two_factor_auth/index.js new file mode 100644 index 00000000000..5e59c44e8cd --- /dev/null +++ b/app/assets/javascripts/authentication/two_factor_auth/index.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import { updateHistory, removeParams } from '~/lib/utils/url_utility'; +import RecoveryCodes from './components/recovery_codes.vue'; +import { SUCCESS_QUERY_PARAM } from './constants'; + +export const initRecoveryCodes = () => { + const el = document.querySelector('.js-2fa-recovery-codes'); + + if (!el) { + return false; + } + + const { codes = '[]', profileAccountPath = '' } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(RecoveryCodes, { + props: { + codes: JSON.parse(codes), + profileAccountPath, + }, + }); + }, + }); +}; + +export const initClose2faSuccessMessage = () => { + const closeButton = document.querySelector('.js-close-2fa-enabled-success-alert'); + + if (!closeButton) { + return; + } + + closeButton.addEventListener( + 'click', + () => { + updateHistory({ + url: removeParams([SUCCESS_QUERY_PARAM]), + title: document.title, + replace: true, + }); + }, + { once: true }, + ); +}; diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 5f50fcc112e..0a05e0d44ce 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -74,6 +74,7 @@ export default class Autosave { } dispose() { + // eslint-disable-next-line @gitlab/no-global-event-off this.field.off('input'); } } diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 17e6255700a..d937060536a 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -596,6 +596,7 @@ export class AwardsHandler { hideMenuElement($emojiMenu) { $emojiMenu.on(transitionEndEventString, e => { if (e.currentTarget === e.target) { + // eslint-disable-next-line @gitlab/no-global-event-off $emojiMenu.removeClass(IS_RENDERED).off(transitionEndEventString); } }); diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index 0b8c6aff219..c3512773457 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -84,7 +84,7 @@ export default { <div v-show="hasError" class="btn-group"> <div class="btn btn-default btn-sm disabled"> - <gl-icon :size="16" class="gl-ml-3 gl-mr-3" name="doc-image" aria-hidden="true" /> + <gl-icon :size="16" class="gl-ml-3 gl-mr-3" name="doc-image" /> </div> <div class="btn btn-default btn-sm disabled"> <span class="gl-ml-3 gl-mr-3">{{ s__('Badges|No badge image') }}</span> diff --git a/app/assets/javascripts/behaviors/select2.js b/app/assets/javascripts/behaviors/select2.js index 37b75bb5e56..1f222d8c1f6 100644 --- a/app/assets/javascripts/behaviors/select2.js +++ b/app/assets/javascripts/behaviors/select2.js @@ -1,22 +1,29 @@ import $ from 'jquery'; +import { loadCSSFile } from '../lib/utils/css_utils'; export default () => { - if ($('select.select2').length) { + const $select2Elements = $('select.select2'); + if ($select2Elements.length) { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - $('select.select2').select2({ - width: 'resolve', - minimumResultsForSearch: 10, - dropdownAutoWidth: true, - }); + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + $select2Elements.select2({ + width: 'resolve', + minimumResultsForSearch: 10, + dropdownAutoWidth: true, + }); - // Close select2 on escape - $('.js-select2').on('select2-close', () => { - setTimeout(() => { - $('.select2-container-active').removeClass('select2-container-active'); - $(':focus').blur(); - }, 1); - }); + // Close select2 on escape + $('.js-select2').on('select2-close', () => { + requestAnimationFrame(() => { + $('.select2-container-active').removeClass('select2-container-active'); + $(':focus').blur(); + }); + }); + }) + .catch(() => {}); }) .catch(() => {}); } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index a53150f8d61..c0f67923191 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -97,6 +97,7 @@ export default class Shortcuts { e.preventDefault(); }); + // eslint-disable-next-line @gitlab/no-global-event-off $('.js-shortcuts-modal-trigger') .off('click') .on('click', this.onToggleHelp); diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue index 902dd0b8eec..a5b594fbd88 100644 --- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue +++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue @@ -50,7 +50,6 @@ export default { :aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE" :title="$options.SIMPLE_BLOB_VIEWER_TITLE" :selected="isSimpleViewer" - :class="{ active: isSimpleViewer }" icon="code" category="primary" variant="default" @@ -61,7 +60,6 @@ export default { :aria-label="$options.RICH_BLOB_VIEWER_TITLE" :title="$options.RICH_BLOB_VIEWER_TITLE" :selected="isRichViewer" - :class="{ active: isRichViewer }" icon="document" category="primary" variant="default" diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 5058ca7122d..8f64bda1ba6 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -82,7 +82,6 @@ export default class FileTemplateMediator { initPageEvents() { this.listenForFilenameInput(); - this.prepFileContentForSubmit(); this.listenForPreviewMode(); } @@ -92,12 +91,6 @@ export default class FileTemplateMediator { }); } - prepFileContentForSubmit() { - this.$commitForm.submit(() => { - this.$fileContent.val(this.editor.getValue()); - }); - } - listenForPreviewMode() { this.$navLinks.on('click', 'a', e => { const urlPieces = e.target.href.split('#'); diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index bd39aa2e16f..2532aeea989 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -12,7 +12,10 @@ export default class FileTemplateSelector { this.$dropdown = $(cfg.dropdown); this.$wrapper = $(cfg.wrapper); - this.$loadingIcon = this.$wrapper.find('.fa-chevron-down'); + this.$dropdownIcon = this.$wrapper.find('.dropdown-menu-toggle-icon'); + this.$loadingIcon = $( + '<div class="gl-spinner gl-spinner-orange gl-spinner-sm gl-absolute gl-top-3 gl-right-3 gl-display-none"></div>', + ).insertAfter(this.$dropdownIcon); this.$dropdownToggleText = this.$wrapper.find('.dropdown-toggle-text'); this.initDropdown(); @@ -45,15 +48,13 @@ export default class FileTemplateSelector { } renderLoading() { - this.$loadingIcon - .addClass('gl-spinner gl-spinner-orange gl-spinner-sm') - .removeClass('fa-chevron-down'); + this.$loadingIcon.removeClass('gl-display-none'); + this.$dropdownIcon.addClass('gl-display-none'); } renderLoaded() { - this.$loadingIcon - .addClass('fa-chevron-down') - .removeClass('gl-spinner gl-spinner-orange gl-spinner-sm'); + this.$loadingIcon.addClass('gl-display-none'); + this.$dropdownIcon.removeClass('gl-display-none'); } reportSelection(options) { diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue index 06f436adb8e..6fee40fb061 100644 --- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue +++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue @@ -107,7 +107,7 @@ export default { v-if="!popoverDismissed" show :target="target" - placement="rightbottom" + placement="right" trigger="manual" container="viewport" :css-classes="['suggest-gitlab-ci-yml', 'ml-4']" diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index 257458138dc..ae9bb3455f0 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -10,7 +10,10 @@ export default class TemplateSelector { this.dropdown = dropdown; this.$dropdownContainer = wrapper; this.$filenameInput = $input || $('#file_name'); - this.$dropdownIcon = $('.fa-chevron-down', dropdown); + this.$dropdownIcon = $('.dropdown-menu-toggle-icon', dropdown); + this.$loadingIcon = $( + '<div class="gl-spinner gl-spinner-orange gl-spinner-sm gl-absolute gl-top-3 gl-right-3 gl-display-none"></div>', + ).insertAfter(this.$dropdownIcon); this.initDropdown(dropdown, data); this.listenForFilenameInput(); @@ -92,10 +95,12 @@ export default class TemplateSelector { } startLoadingSpinner() { - this.$dropdownIcon.addClass('spinner').removeClass('fa-chevron-down'); + this.$loadingIcon.removeClass('gl-display-none'); + this.$dropdownIcon.addClass('gl-display-none'); } stopLoadingSpinner() { - this.$dropdownIcon.addClass('fa-chevron-down').removeClass('spinner'); + this.$loadingIcon.addClass('gl-display-none'); + this.$dropdownIcon.removeClass('gl-display-none'); } } diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index aa76364c466..01350acad0c 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -132,16 +132,16 @@ export default class BlobViewer { const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`); if (this.activeViewer === newViewer) return; - const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active'); + const oldButton = document.querySelector('.js-blob-viewer-switch-btn.selected'); const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`); const oldViewer = this.$fileHolder[0].querySelector(`.blob-viewer:not([data-type='${name}'])`); if (oldButton) { - oldButton.classList.remove('active'); + oldButton.classList.remove('selected'); } if (newButton) { - newButton.classList.add('active'); + newButton.classList.add('selected'); newButton.blur(); } diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index f84e39baa53..678044687a9 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -38,9 +38,20 @@ const initPopovers = () => { } }; +export const initUploadForm = () => { + const uploadBlobForm = $('.js-upload-blob-form'); + if (uploadBlobForm.length) { + const method = uploadBlobForm.data('method'); + + new BlobFileDropzone(uploadBlobForm, method); + new NewCommitForm(uploadBlobForm); + + disableButtonIfEmptyField(uploadBlobForm.find('.js-commit-message'), '.btn-upload-file'); + } +}; + export default () => { const editBlobForm = $('.js-edit-blob-form'); - const uploadBlobForm = $('.js-upload-blob-form'); const deleteBlobForm = $('.js-delete-blob-form'); if (editBlobForm.length) { @@ -80,14 +91,7 @@ export default () => { window.onbeforeunload = () => ''; } - if (uploadBlobForm.length) { - const method = uploadBlobForm.data('method'); - - new BlobFileDropzone(uploadBlobForm, method); - new NewCommitForm(uploadBlobForm); - - disableButtonIfEmptyField(uploadBlobForm.find('.js-commit-message'), '.btn-upload-file'); - } + initUploadForm(); if (deleteBlobForm.length) { new NewCommitForm(deleteBlobForm); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index e6b0a6fc1c5..1bc51aa1d6f 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -5,7 +5,8 @@ import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; import TemplateSelectorMediator from '../blob/file_template_mediator'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; import EditorLite from '~/editor/editor_lite'; -import FileTemplateExtension from '~/editor/editor_file_template_ext'; +import { FileTemplateExtension } from '~/editor/editor_file_template_ext'; +import { insertFinalNewline } from '~/lib/utils/text_utility'; export default class EditBlob { // The options object has: @@ -16,11 +17,11 @@ export default class EditBlob { if (this.options.isMarkdown) { import('~/editor/editor_markdown_ext') - .then(MarkdownExtension => { - this.editor.use(MarkdownExtension.default); + .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { + this.editor.use(new MarkdownExtension()); addEditorMarkdownListeners(this.editor); }) - .catch(() => createFlash(BLOB_EDITOR_ERROR)); + .catch(e => createFlash(`${BLOB_EDITOR_ERROR}: ${e}`)); } this.initModePanesAndLinks(); @@ -42,14 +43,14 @@ export default class EditBlob { blobPath: fileNameEl.value, blobContent: editorEl.innerText, }); - this.editor.use(FileTemplateExtension); + this.editor.use(new FileTemplateExtension()); fileNameEl.addEventListener('change', () => { this.editor.updateModelLanguage(fileNameEl.value); }); form.addEventListener('submit', () => { - fileContentEl.value = this.editor.getValue(); + fileContentEl.value = insertFinalNewline(this.editor.getValue()); }); } diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 6b7b0c2e28d..e5ff41dab74 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,31 +1,39 @@ import { sortBy } from 'lodash'; -import ListIssue from 'ee_else_ce/boards/models/issue'; +import axios from '~/lib/utils/axios_utils'; import { ListType } from './constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import boardsStore from '~/boards/stores/boards_store'; export function getMilestone() { return null; } +export function updateListPosition(listObj) { + const { listType } = listObj; + let { position } = listObj; + if (listType === ListType.closed) { + position = Infinity; + } else if (listType === ListType.backlog) { + position = -Infinity; + } + + return { ...listObj, position }; +} + export function formatBoardLists(lists) { - const formattedLists = lists.nodes.map(list => - boardsStore.updateListPosition({ ...list, doNotFetchIssues: true }), - ); - return formattedLists.reduce((map, list) => { + return lists.nodes.reduce((map, list) => { return { ...map, - [list.id]: list, + [list.id]: updateListPosition(list), }; }, {}); } export function formatIssue(issue) { - return new ListIssue({ + return { ...issue, labels: issue.labels?.nodes || [], assignees: issue.assignees?.nodes || [], - }); + }; } export function formatListIssues(listIssues) { @@ -44,12 +52,12 @@ export function formatListIssues(listIssues) { [list.id]: sortedIssues.map(i => { const id = getIdFromGraphQLId(i.id); - const listIssue = new ListIssue({ + const listIssue = { ...i, id, labels: i.labels?.nodes || [], assignees: i.assignees?.nodes || [], - }); + }; issues[id] = listIssue; @@ -83,21 +91,48 @@ export function fullLabelId(label) { } export function moveIssueListHelper(issue, fromList, toList) { - if (toList.type === ListType.label) { - issue.addLabel(toList.label); + const updatedIssue = issue; + if ( + toList.listType === ListType.label && + !updatedIssue.labels.find(label => label.id === toList.label.id) + ) { + updatedIssue.labels.push(toList.label); } - if (fromList && fromList.type === ListType.label) { - issue.removeLabel(fromList.label); + if (fromList?.label && fromList.listType === ListType.label) { + updatedIssue.labels = updatedIssue.labels.filter(label => fromList.label.id !== label.id); } - if (toList.type === ListType.assignee) { - issue.addAssignee(toList.assignee); + if ( + toList.listType === ListType.assignee && + !updatedIssue.assignees.find(assignee => assignee.id === toList.assignee.id) + ) { + updatedIssue.assignees.push(toList.assignee); + } + if (fromList?.assignee && fromList.listType === ListType.assignee) { + updatedIssue.assignees = updatedIssue.assignees.filter( + assignee => assignee.id !== fromList.assignee.id, + ); } - if (fromList && fromList.type === ListType.assignee) { - issue.removeAssignee(fromList.assignee); + + return updatedIssue; +} + +export function getBoardsPath(endpoint, board) { + const path = `${endpoint}${board.id ? `/${board.id}` : ''}.json`; + + if (board.id) { + return axios.put(path, { board }); } + return axios.post(path, { board }); +} + +export function isListDraggable(list) { + return list.listType !== ListType.backlog && list.listType !== ListType.closed; +} - return issue; +// EE-specific feature. Find the implementation in the `ee/`-folder +export function transformBoardConfig() { + return ''; } export default { @@ -106,4 +141,6 @@ export default { formatListIssues, fullBoardId, fullLabelId, + getBoardsPath, + isListDraggable, }; diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue index c81f171af2b..1469efae5a6 100644 --- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue +++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue @@ -1,18 +1,20 @@ <script> -import { mapActions, mapGetters } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import { cloneDeep } from 'lodash'; import { GlDropdownItem, GlDropdownDivider, GlAvatarLabeled, GlAvatarLink, GlSearchBoxByType, + GlLoadingIcon, } from '@gitlab/ui'; import { __, n__ } from '~/locale'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql'; -import searchUsers from '~/boards/queries/users_search.query.graphql'; +import searchUsers from '~/boards/graphql/users_search.query.graphql'; export default { noSearchDelay: 0, @@ -32,12 +34,13 @@ export default { GlAvatarLabeled, GlAvatarLink, GlSearchBoxByType, + GlLoadingIcon, }, data() { return { search: '', participants: [], - selected: this.$store.getters.activeIssue.assignees, + selected: [], }; }, apollo: { @@ -72,6 +75,7 @@ export default { }, computed: { ...mapGetters(['activeIssue']), + ...mapState(['isSettingAssignees']), assigneeText() { return n__('Assignee', '%d Assignees', this.selected.length); }, @@ -89,9 +93,20 @@ export default { isSearchEmpty() { return this.search === ''; }, + currentUser() { + return gon?.current_username; + }, + }, + created() { + this.selected = cloneDeep(this.activeIssue.assignees); }, methods: { ...mapActions(['setAssignees']), + async assignSelf() { + const [currentUserObject] = await this.setAssignees(this.currentUser); + + this.selectAssignee(currentUserObject); + }, clearSelected() { this.selected = []; }, @@ -117,9 +132,9 @@ export default { </script> <template> - <board-editable-item :title="assigneeText" @close="saveAssignees"> + <board-editable-item :loading="isSettingAssignees" :title="assigneeText" @close="saveAssignees"> <template #collapsed> - <issuable-assignees :users="activeIssue.assignees" /> + <issuable-assignees :users="activeIssue.assignees" @assign-self="assignSelf" /> </template> <template #default> @@ -132,45 +147,48 @@ export default { <gl-search-box-by-type v-model.trim="search" /> </template> <template #items> - <gl-dropdown-item - :is-checked="selectedIsEmpty" - data-testid="unassign" - class="mt-2" - @click="selectAssignee()" - >{{ $options.i18n.unassigned }}</gl-dropdown-item - > - <gl-dropdown-divider data-testid="unassign-divider" /> - <gl-dropdown-item - v-for="item in selected" - :key="item.id" - :is-checked="isChecked(item.username)" - @click="unselect(item.username)" - > - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="item.name" - :sub-label="item.username" - :src="item.avatarUrl || item.avatar" - /> - </gl-avatar-link> - </gl-dropdown-item> - <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" /> - <gl-dropdown-item - v-for="unselectedUser in unSelectedFiltered" - :key="unselectedUser.id" - :data-testid="`item_${unselectedUser.name}`" - @click="selectAssignee(unselectedUser)" - > - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="unselectedUser.name" - :sub-label="unselectedUser.username" - :src="unselectedUser.avatarUrl || unselectedUser.avatar" - /> - </gl-avatar-link> - </gl-dropdown-item> + <gl-loading-icon v-if="$apollo.queries.participants.loading" size="lg" /> + <template v-else> + <gl-dropdown-item + :is-checked="selectedIsEmpty" + data-testid="unassign" + class="mt-2" + @click="selectAssignee()" + >{{ $options.i18n.unassigned }}</gl-dropdown-item + > + <gl-dropdown-divider data-testid="unassign-divider" /> + <gl-dropdown-item + v-for="item in selected" + :key="item.id" + :is-checked="isChecked(item.username)" + @click="unselect(item.username)" + > + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="item.name" + :sub-label="item.username" + :src="item.avatarUrl || item.avatar" + /> + </gl-avatar-link> + </gl-dropdown-item> + <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" /> + <gl-dropdown-item + v-for="unselectedUser in unSelectedFiltered" + :key="unselectedUser.id" + :data-testid="`item_${unselectedUser.name}`" + @click="selectAssignee(unselectedUser)" + > + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="unselectedUser.name" + :sub-label="unselectedUser.username" + :src="unselectedUser.avatarUrl || unselectedUser.avatar" + /> + </gl-avatar-link> + </gl-dropdown-item> + </template> </template> </multi-select-dropdown> </template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index cb93340bcf8..753e6941c43 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -2,15 +2,12 @@ // This component is being replaced in favor of './board_column_new.vue' for GraphQL boards import Sortable from 'sortablejs'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; -import EmptyComponent from '~/vue_shared/components/empty_component'; import BoardList from './board_list.vue'; import boardsStore from '../stores/boards_store'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; -import { ListType } from '../constants'; export default { components: { - BoardPromotionState: EmptyComponent, BoardListHeader, BoardList, }, @@ -42,9 +39,6 @@ export default { }; }, computed: { - showBoardListAndBoardInfo() { - return this.list.type !== ListType.promotion; - }, listIssues() { return this.list.issues; }, @@ -105,16 +99,7 @@ export default { class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" > <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> - <board-list - v-if="showBoardListAndBoardInfo" - ref="board-list" - :disabled="disabled" - :issues="listIssues" - :list="list" - /> - - <!-- Will be only available in EE --> - <board-promotion-state v-if="list.id === 'promotion'" /> + <board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" /> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue index 8a59355eb83..7839f45c48b 100644 --- a/app/assets/javascripts/boards/components/board_column_new.vue +++ b/app/assets/javascripts/boards/components/board_column_new.vue @@ -1,13 +1,11 @@ <script> import { mapGetters, mapActions, mapState } from 'vuex'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.vue'; -import BoardPromotionState from 'ee_else_ce/boards/components/board_promotion_state'; import BoardList from './board_list_new.vue'; -import { ListType } from '../constants'; +import { isListDraggable } from '../boards_util'; export default { components: { - BoardPromotionState, BoardListHeader, BoardList, }, @@ -35,22 +33,17 @@ export default { computed: { ...mapState(['filterParams']), ...mapGetters(['getIssuesByList']), - showBoardListAndBoardInfo() { - return this.list.type !== ListType.promotion; - }, listIssues() { return this.getIssuesByList(this.list.id); }, - shouldFetchIssues() { - return this.list.type !== ListType.blank; + isListDraggable() { + return isListDraggable(this.list); }, }, watch: { filterParams: { handler() { - if (this.shouldFetchIssues) { - this.fetchIssuesForList({ listId: this.list.id }); - } + this.fetchIssuesForList({ listId: this.list.id }); }, deep: true, immediate: true, @@ -58,7 +51,6 @@ export default { }, methods: { ...mapActions(['fetchIssuesForList']), - // TODO: Reordering of lists https://gitlab.com/gitlab-org/gitlab/-/issues/280515 }, }; </script> @@ -66,13 +58,12 @@ export default { <template> <div :class="{ - 'is-draggable': !list.preset, - 'is-expandable': list.isExpandable, - 'is-collapsed': !list.isExpanded, - 'board-type-assignee': list.type === 'assignee', + 'is-draggable': isListDraggable, + 'is-collapsed': list.collapsed, + 'board-type-assignee': list.listType === 'assignee', }" :data-id="list.id" - class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" + class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal is-expandable" data-qa-selector="board_list" > <div @@ -80,15 +71,12 @@ export default { > <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> <board-list - v-if="showBoardListAndBoardInfo" ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" + :can-admin-list="canAdminList" /> - - <!-- Will be only available in EE --> - <board-promotion-state v-if="list.id === 'promotion'" /> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue index 754b00b54e0..99d1e4a2611 100644 --- a/app/assets/javascripts/boards/components/board_configuration_options.vue +++ b/app/assets/javascripts/boards/components/board_configuration_options.vue @@ -42,7 +42,7 @@ export default { </script> <template> - <div class="append-bottom-20"> + <div class="gl-mb-5"> <label class="label-bold gl-font-lg" for="board-new-name"> {{ __('List options') }} </label> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 92976574efb..b366aa6fdb3 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,10 +1,13 @@ <script> +import Draggable from 'vuedraggable'; import { mapState, mapGetters, mapActions } from 'vuex'; import { sortBy } from 'lodash'; import { GlAlert } from '@gitlab/ui'; -import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; +import BoardColumn from './board_column.vue'; import BoardColumnNew from './board_column_new.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import defaultSortableConfig from '~/sortable/sortable_config'; +import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options'; export default { components: { @@ -32,18 +35,51 @@ export default { ...mapState(['boardLists', 'error']), ...mapGetters(['isSwimlanesOn']), boardListsToUse() { - const lists = - this.glFeatures.graphqlBoardLists || this.isSwimlanesOn ? this.boardLists : this.lists; - return sortBy([...Object.values(lists)], 'position'); + return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn + ? sortBy([...Object.values(this.boardLists)], 'position') + : this.lists; + }, + canDragColumns() { + return this.glFeatures.graphqlBoardLists && this.canAdminList; + }, + boardColumnWrapper() { + return this.canDragColumns ? Draggable : 'div'; + }, + draggableOptions() { + const options = { + ...defaultSortableConfig, + disabled: this.disabled, + draggable: '.is-draggable', + fallbackOnBody: false, + group: 'boards-list', + tag: 'div', + value: this.lists, + }; + + return this.canDragColumns ? options : {}; }, - }, - mounted() { - if (this.glFeatures.graphqlBoardLists) { - this.showPromotionList(); - } }, methods: { - ...mapActions(['showPromotionList']), + ...mapActions(['moveList']), + handleDragOnStart() { + sortableStart(); + }, + + handleDragOnEnd(params) { + sortableEnd(); + + const { item, newIndex, oldIndex, to } = params; + + const listId = item.dataset.id; + const replacedListId = to.children[newIndex].dataset.id; + + this.moveList({ + listId, + replacedListId, + newIndex, + adjustmentValue: newIndex < oldIndex ? 1 : -1, + }); + }, }, }; </script> @@ -53,10 +89,14 @@ export default { <gl-alert v-if="error" variant="danger" :dismissible="false"> {{ error }} </gl-alert> - <div + <component + :is="boardColumnWrapper" v-if="!isSwimlanesOn" + ref="list" + v-bind="draggableOptions" class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap" - data-qa-selector="boards_list" + @start="handleDragOnStart" + @end="handleDragOnEnd" > <board-column v-for="list in boardListsToUse" @@ -66,7 +106,7 @@ export default { :list="list" :disabled="disabled" /> - </div> + </component> <template v-else> <epics-swimlanes diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index e4ef3600ff9..dab934352ca 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,11 +1,14 @@ <script> -import { __ } from '~/locale'; +import { GlModal } from '@gitlab/ui'; +import { pick } from 'lodash'; +import { __, s__ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; -import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import { visitUrl } from '~/lib/utils/url_utility'; import boardsStore from '~/boards/stores/boards_store'; +import { fullBoardId, getBoardsPath } from '../boards_util'; import BoardConfigurationOptions from './board_configuration_options.vue'; +import createBoardMutation from '../graphql/board.mutation.graphql'; const boardDefaults = { id: false, @@ -19,10 +22,28 @@ const boardDefaults = { hide_closed_list: false, }; +const formType = { + new: 'new', + delete: 'delete', + edit: 'edit', +}; + export default { + i18n: { + [formType.new]: { title: s__('Board|Create new board'), btnText: s__('Board|Create board') }, + [formType.delete]: { title: s__('Board|Delete board'), btnText: __('Delete') }, + [formType.edit]: { title: s__('Board|Edit board'), btnText: __('Save changes') }, + scopeModalTitle: s__('Board|Board scope'), + cancelButtonText: __('Cancel'), + deleteErrorMessage: s__('Board|Failed to delete board. Please try again.'), + saveErrorMessage: __('Unable to save your changes. Please try again.'), + deleteConfirmationMessage: s__('Board|Are you sure you want to delete this board?'), + titleFieldLabel: __('Title'), + titleFieldPlaceholder: s__('Board|Enter board name'), + }, components: { BoardScope: () => import('ee_component/boards/components/board_scope.vue'), - DeprecatedModal, + GlModal, BoardConfigurationOptions, }, props: { @@ -63,36 +84,35 @@ export default { required: false, default: false, }, + currentBoard: { + type: Object, + required: true, + }, + }, + inject: { + endpoints: { + default: {}, + }, }, data() { return { board: { ...boardDefaults, ...this.currentBoard }, - currentBoard: boardsStore.state.currentBoard, currentPage: boardsStore.state.currentPage, isLoading: false, }; }, computed: { isNewForm() { - return this.currentPage === 'new'; + return this.currentPage === formType.new; }, isDeleteForm() { - return this.currentPage === 'delete'; + return this.currentPage === formType.delete; }, isEditForm() { - return this.currentPage === 'edit'; - }, - isVisible() { - return this.currentPage !== ''; + return this.currentPage === formType.edit; }, buttonText() { - if (this.isNewForm) { - return __('Create board'); - } - if (this.isDeleteForm) { - return __('Delete'); - } - return __('Save changes'); + return this.$options.i18n[this.currentPage].btnText; }, buttonKind() { if (this.isNewForm) { @@ -104,16 +124,11 @@ export default { return 'info'; }, title() { - if (this.isNewForm) { - return __('Create new board'); - } - if (this.isDeleteForm) { - return __('Delete board'); - } if (this.readonly) { - return __('Board scope'); + return this.$options.i18n.scopeModalTitle; } - return __('Edit board'); + + return this.$options.i18n[this.currentPage].title; }, readonly() { return !this.canAdminBoard; @@ -121,6 +136,33 @@ export default { submitDisabled() { return this.isLoading || this.board.name.length === 0; }, + primaryProps() { + return { + text: this.buttonText, + attributes: [ + { + variant: this.buttonKind, + disabled: this.submitDisabled, + loading: this.isLoading, + 'data-qa-selector': 'save_changes_button', + }, + ], + }; + }, + cancelProps() { + return { + text: this.$options.i18n.cancelButtonText, + }; + }, + boardPayload() { + const { assignee, milestone, labels } = this.board; + return { + ...this.board, + assignee_id: assignee?.id, + milestone_id: milestone?.id, + label_ids: labels.length ? labels.map(b => b.id) : [''], + }; + }, }, mounted() { this.resetFormState(); @@ -129,6 +171,31 @@ export default { } }, methods: { + callBoardMutation(id) { + return this.$apollo.mutate({ + mutation: createBoardMutation, + variables: { + ...pick(this.boardPayload, ['hideClosedList', 'hideBacklogList']), + id, + }, + }); + }, + async updateBoard() { + const responses = await Promise.all([ + // Remove unnecessary REST API call when https://gitlab.com/gitlab-org/gitlab/-/issues/282299#note_462996301 is resolved + getBoardsPath(this.endpoints.boardsEndpoint, this.boardPayload), + this.callBoardMutation(fullBoardId(this.boardPayload.id)), + ]); + + return responses[0].data; + }, + async createBoard() { + // TODO: change this to use `createBoard` mutation https://gitlab.com/gitlab-org/gitlab/-/issues/292466 is resolved + const boardData = await getBoardsPath(this.endpoints.boardsEndpoint, this.boardPayload); + this.callBoardMutation(fullBoardId(boardData.data.id)); + + return boardData.data || boardData; + }, submit() { if (this.board.name.length === 0) return; this.isLoading = true; @@ -136,31 +203,21 @@ export default { boardsStore .deleteBoard(this.currentBoard) .then(() => { + this.isLoading = false; visitUrl(boardsStore.rootPath); }) .catch(() => { - Flash(__('Failed to delete board. Please try again.')); + Flash(this.$options.i18n.deleteErrorMessage); this.isLoading = false; }); } else { - boardsStore - .createBoard(this.board) - .then(resp => { - // This handles 2 use cases - // - In create call we only get one parameter, the new board - // - In update call, due to Promise.all, we get REST response in - // array index 0 - - if (Array.isArray(resp)) { - return resp[0].data; - } - return resp.data ? resp.data : resp; - }) + const boardAction = this.boardPayload.id ? this.updateBoard : this.createBoard; + boardAction() .then(data => { visitUrl(data.board_path); }) .catch(() => { - Flash(__('Unable to save your changes. Please try again.')); + Flash(this.$options.i18n.saveErrorMessage); this.isLoading = false; }); } @@ -181,53 +238,58 @@ export default { </script> <template> - <deprecated-modal - v-show="isVisible" + <gl-modal + modal-id="board-config-modal" + modal-class="board-config-modal" + content-class="gl-absolute gl-top-7" + visible :hide-footer="readonly" :title="title" - :primary-button-label="buttonText" - :kind="buttonKind" - :submit-disabled="submitDisabled" - modal-dialog-class="board-config-modal" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="submit" @cancel="cancel" - @submit="submit" + @close="cancel" + @hide.prevent > - <template #body> - <p v-if="isDeleteForm">{{ __('Are you sure you want to delete this board?') }}</p> - <form v-else class="js-board-config-modal" @submit.prevent> - <div v-if="!readonly" class="append-bottom-20"> - <label class="label-bold gl-font-lg" for="board-new-name">{{ __('Title') }}</label> - <input - id="board-new-name" - ref="name" - v-model="board.name" - class="form-control" - data-qa-selector="board_name_field" - type="text" - :placeholder="__('Enter board name')" - @keyup.enter="submit" - /> - </div> - - <board-configuration-options - :is-new-form="isNewForm" - :board="board" - :current-board="currentBoard" + <p v-if="isDeleteForm" data-testid="delete-confirmation-message"> + {{ $options.i18n.deleteConfirmationMessage }} + </p> + <form v-else class="js-board-config-modal" data-testid="board-form-wrapper" @submit.prevent> + <div v-if="!readonly" class="gl-mb-5" data-testid="board-form"> + <label class="gl-font-weight-bold gl-font-lg" for="board-new-name"> + {{ $options.i18n.titleFieldLabel }} + </label> + <input + id="board-new-name" + ref="name" + v-model="board.name" + class="form-control" + data-qa-selector="board_name_field" + type="text" + :placeholder="$options.i18n.titleFieldPlaceholder" + @keyup.enter="submit" /> + </div> - <board-scope - v-if="scopedIssueBoardFeatureEnabled" - :collapse-scope="isNewForm" - :board="board" - :can-admin-board="canAdminBoard" - :labels-path="labelsPath" - :labels-web-url="labelsWebUrl" - :enable-scoped-labels="enableScopedLabels" - :project-id="projectId" - :group-id="groupId" - :weights="weights" - /> - </form> - </template> - </deprecated-modal> + <board-configuration-options + :is-new-form="isNewForm" + :board="board" + :current-board="currentBoard" + /> + + <board-scope + v-if="scopedIssueBoardFeatureEnabled" + :collapse-scope="isNewForm" + :board="board" + :can-admin-board="canAdminBoard" + :labels-path="labelsPath" + :labels-web-url="labelsWebUrl" + :enable-scoped-labels="enableScopedLabels" + :project-id="projectId" + :group-id="groupId" + :weights="weights" + /> + </form> + </gl-modal> </template> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 53989e2d9de..1f87b563e73 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,7 +6,6 @@ import boardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; import { sprintf, __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { getBoardSortableDefaultOptions, @@ -25,7 +24,6 @@ export default { boardNewIssue, GlLoadingIcon, }, - mixins: [glFeatureFlagMixin()], props: { disabled: { type: Boolean, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index d85ba2038a7..3db5c2e0830 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -72,12 +72,7 @@ export default { return this.list?.label?.description || this.list.title || ''; }, showListHeaderButton() { - return ( - !this.disabled && - this.listType !== ListType.closed && - this.listType !== ListType.blank && - this.listType !== ListType.promotion - ); + return !this.disabled && this.listType !== ListType.closed; }, showMilestoneListDetails() { return ( @@ -109,9 +104,6 @@ export default { this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded ); }, - showBoardListAndBoardInfo() { - return this.listType !== ListType.blank && this.listType !== ListType.promotion; - }, uniqueKey() { // eslint-disable-next-line @gitlab/require-i18n-strings return `boards.${this.boardId}.${this.listType}.${this.list.id}`; @@ -190,7 +182,8 @@ export default { :title="chevronTooltip" :icon="chevronIcon" class="board-title-caret no-drag gl-cursor-pointer" - variant="link" + category="tertiary" + size="small" @click="toggleExpanded" /> <!-- The following is only true in EE and if it is a milestone --> @@ -288,7 +281,6 @@ export default { </gl-tooltip> <div - v-if="showBoardListAndBoardInfo" class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary" :class="{ 'gl-display-none!': !list.isExpanded && isSwimlanesHeader, diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_new.vue index 99347a4cd4d..44eb2aa34c2 100644 --- a/app/assets/javascripts/boards/components/board_list_header_new.vue +++ b/app/assets/javascripts/boards/components/board_list_header_new.vue @@ -9,15 +9,22 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; -import { n__, s__ } from '~/locale'; +import { n__, s__, __ } from '~/locale'; import AccessorUtilities from '../../lib/utils/accessor'; import IssueCount from './issue_count.vue'; import eventHub from '../eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; import { inactiveId, LIST, ListType } from '../constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; +import { isListDraggable } from '~/boards/boards_util'; export default { + i18n: { + newIssue: __('New issue'), + listSettings: __('List settings'), + expand: s__('Boards|Expand'), + collapse: s__('Boards|Collapse'), + }, components: { GlButtonGroup, GlButton, @@ -66,57 +73,49 @@ export default { return Boolean(this.currentUserId); }, listType() { - return this.list.type; + return this.list.listType; }, listAssignee() { return this.list?.assignee?.username || ''; }, listTitle() { - return this.list?.label?.description || this.list.title || ''; + return this.list?.label?.description || this.list?.assignee?.name || this.list.title || ''; }, showListHeaderButton() { - return ( - !this.disabled && - this.listType !== ListType.closed && - this.listType !== ListType.blank && - this.listType !== ListType.promotion - ); + return !this.disabled && this.listType !== ListType.closed; }, showMilestoneListDetails() { return ( - this.list.type === ListType.milestone && + this.listType === ListType.milestone && this.list.milestone && - (this.list.isExpanded || !this.isSwimlanesHeader) + (!this.list.collapsed || !this.isSwimlanesHeader) ); }, showAssigneeListDetails() { return ( - this.list.type === ListType.assignee && (this.list.isExpanded || !this.isSwimlanesHeader) + this.listType === ListType.assignee && (!this.list.collapsed || !this.isSwimlanesHeader) ); }, issuesCount() { - return this.list.issuesSize; + return this.list.issuesCount; }, issuesTooltipLabel() { return n__(`%d issue`, `%d issues`, this.issuesCount); }, chevronTooltip() { - return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); + return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; }, chevronIcon() { - return this.list.isExpanded ? 'chevron-right' : 'chevron-down'; + return this.list.collapsed ? 'chevron-down' : 'chevron-right'; }, isNewIssueShown() { return this.listType === ListType.backlog || this.showListHeaderButton; }, isSettingsShown() { return ( - this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded + this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed ); }, - showBoardListAndBoardInfo() { - return this.listType !== ListType.blank && this.listType !== ListType.promotion; - }, uniqueKey() { // eslint-disable-next-line @gitlab/require-i18n-strings return `boards.${this.boardId}.${this.listType}.${this.list.id}`; @@ -127,6 +126,9 @@ export default { headerStyle() { return { borderTopColor: this.list?.label?.color }; }, + userCanDrag() { + return !this.disabled && isListDraggable(this.list); + }, }, methods: { ...mapActions(['updateList', 'setActiveId']), @@ -145,7 +147,7 @@ export default { eventHub.$emit(`toggle-issue-form-${this.list.id}`); }, toggleExpanded() { - this.list.isExpanded = !this.list.isExpanded; + this.list.collapsed = !this.list.collapsed; if (!this.isLoggedIn) { this.addToLocalStorage(); @@ -159,11 +161,11 @@ export default { }, addToLocalStorage() { if (AccessorUtilities.isLocalStorageAccessSafe()) { - localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); + localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed); } }, updateListFunction() { - this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded }); + this.updateList({ listId: this.list.id, collapsed: this.list.collapsed }); }, }, }; @@ -173,7 +175,7 @@ export default { <header :class="{ 'has-border': list.label && list.label.color, - 'gl-h-full': !list.isExpanded, + 'gl-h-full': list.collapsed, 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, }" :style="headerStyle" @@ -183,22 +185,22 @@ export default { > <h3 :class="{ - 'user-can-drag': !disabled && !list.preset, - 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader, - 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, - 'gl-py-2': !list.isExpanded && isSwimlanesHeader, - 'gl-flex-direction-column': !list.isExpanded, + 'user-can-drag': userCanDrag, + 'gl-py-3 gl-h-full': list.collapsed && !isSwimlanesHeader, + 'gl-border-b-0': list.collapsed || isSwimlanesHeader, + 'gl-py-2': list.collapsed && isSwimlanesHeader, + 'gl-flex-direction-column': list.collapsed, }" class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle" > <gl-button - v-if="list.isExpandable" v-gl-tooltip.hover :aria-label="chevronTooltip" :title="chevronTooltip" :icon="chevronIcon" class="board-title-caret no-drag gl-cursor-pointer" - variant="link" + category="tertiary" + size="small" @click="toggleExpanded" /> <!-- EE start --> @@ -207,8 +209,8 @@ export default { aria-hidden="true" class="milestone-icon" :class="{ - 'gl-mt-3 gl-rotate-90': !list.isExpanded, - 'gl-mr-2': list.isExpanded, + 'gl-mt-3 gl-rotate-90': list.collapsed, + 'gl-mr-2': !list.collapsed, }" > <gl-icon name="timer" /> @@ -216,17 +218,17 @@ export default { <a v-if="showAssigneeListDetails" - :href="list.assignee.path" + :href="list.assignee.webUrl" class="user-avatar-link js-no-trigger" :class="{ - 'gl-mt-3 gl-rotate-90': !list.isExpanded, + 'gl-mt-3 gl-rotate-90': list.collapsed, }" > <img v-gl-tooltip.hover.bottom :title="listAssignee" :alt="list.assignee.name" - :src="list.assignee.avatar" + :src="list.assignee.avatarUrl" class="avatar s20" height="20" width="20" @@ -236,9 +238,9 @@ export default { <div class="board-title-text" :class="{ - 'gl-display-none': !list.isExpanded && isSwimlanesHeader, - 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded, - 'gl-flex-grow-1': list.isExpanded, + 'gl-display-none': list.collapsed && isSwimlanesHeader, + 'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed, + 'gl-flex-grow-1': !list.collapsed, }" > <!-- EE start --> @@ -246,16 +248,16 @@ export default { v-if="listType !== 'label'" v-gl-tooltip.hover :class="{ - 'gl-display-block': !list.isExpanded || listType === 'milestone', + 'gl-display-block': list.collapsed || listType === 'milestone', }" :title="listTitle" class="board-title-main-text gl-text-truncate" > - {{ list.title }} + {{ listTitle }} </span> <span v-if="listType === 'assignee'" - v-show="list.isExpanded" + v-show="!list.collapsed" class="gl-ml-2 gl-font-weight-normal gl-text-gray-500" > @{{ listAssignee }} @@ -267,21 +269,21 @@ export default { :background-color="list.label.color" :description="list.label.description" :scoped="showScopedLabels(list.label)" - :size="!list.isExpanded ? 'sm' : ''" + :size="list.collapsed ? 'sm' : ''" :title="list.label.title" /> </div> <!-- EE start --> <span - v-if="isSwimlanesHeader && !list.isExpanded" + v-if="isSwimlanesHeader && list.collapsed" ref="collapsedInfo" aria-hidden="true" - class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500" + class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-gray-500" > <gl-icon name="information" /> </span> - <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo"> + <gl-tooltip v-if="isSwimlanesHeader && list.collapsed" :target="() => $refs.collapsedInfo"> <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div> <div v-if="list.maxIssueCount !== 0"> • @@ -301,11 +303,10 @@ export default { <!-- EE end --> <div - v-if="showBoardListAndBoardInfo" class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500" :class="{ - 'gl-display-none!': !list.isExpanded && isSwimlanesHeader, - 'gl-p-0': !list.isExpanded, + 'gl-display-none!': list.collapsed && isSwimlanesHeader, + 'gl-p-0': list.collapsed, }" > <span class="gl-display-inline-flex"> @@ -331,11 +332,11 @@ export default { > <gl-button v-if="isNewIssueShown" - v-show="list.isExpanded" + v-show="!list.collapsed" ref="newIssueBtn" v-gl-tooltip.hover - :aria-label="__('New issue')" - :title="__('New issue')" + :aria-label="$options.i18n.newIssue" + :title="$options.i18n.newIssue" class="issue-count-badge-add-button no-drag" icon="plus" @click="showNewIssueForm" @@ -345,13 +346,13 @@ export default { v-if="isSettingsShown" ref="settingsBtn" v-gl-tooltip.hover - :aria-label="__('List settings')" + :aria-label="$options.i18n.listSettings" class="no-drag js-board-settings-button" - :title="__('List settings')" + :title="$options.i18n.listSettings" icon="settings" @click="openSidebarSettings" /> - <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip> + <gl-tooltip :target="() => $refs.settingsBtn">{{ $options.i18n.listSettings }}</gl-tooltip> </gl-button-group> </h3> </header> diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue index 396aedcc557..92a381a8f57 100644 --- a/app/assets/javascripts/boards/components/board_list_new.vue +++ b/app/assets/javascripts/boards/components/board_list_new.vue @@ -1,21 +1,26 @@ <script> +import Draggable from 'vuedraggable'; import { mapActions, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; +import defaultSortableConfig from '~/sortable/sortable_config'; +import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options'; import BoardNewIssue from './board_new_issue_new.vue'; import BoardCard from './board_card.vue'; import eventHub from '../eventhub'; -import boardsStore from '../stores/boards_store'; import { sprintf, __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'BoardList', + i18n: { + loadingIssues: __('Loading issues'), + loadingMoreissues: __('Loading more issues'), + showingAllIssues: __('Showing all issues'), + }, components: { BoardCard, BoardNewIssue, GlLoadingIcon, }, - mixins: [glFeatureFlagMixin()], props: { disabled: { type: Boolean, @@ -29,11 +34,15 @@ export default { type: Array, required: true, }, + canAdminList: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { scrollOffset: 250, - filters: boardsStore.state.filters, showCount: false, showIssueForm: false, }; @@ -43,11 +52,11 @@ export default { paginatedIssueText() { return sprintf(__('Showing %{pageSize} of %{total} issues'), { pageSize: this.issues.length, - total: this.list.issuesSize, + total: this.list.issuesCount, }); }, issuesSizeExceedsMax() { - return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount; + return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount; }, hasNextPage() { return this.pageInfoByListId[this.list.id].hasNextPage; @@ -55,15 +64,34 @@ export default { loading() { return this.listsFlags[this.list.id]?.isLoading; }, + loadingMore() { + return this.listsFlags[this.list.id]?.isLoadingMore; + }, + 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; + }, + showingAllIssues() { + return this.issues.length === this.list.issuesCount; + }, + treeRootWrapper() { + return this.canAdminList ? Draggable : 'ul'; + }, + treeRootOptions() { + const options = { + ...defaultSortableConfig, + fallbackOnBody: false, + group: 'board-list', + tag: 'ul', + 'ghost-class': 'board-card-drag-active', + 'data-list-id': this.list.id, + value: this.issues, + }; + + return this.canAdminList ? options : {}; + }, }, watch: { - filters: { - handler() { - this.list.loadingMore = false; - this.$refs.list.scrollTop = 0; - }, - deep: true, - }, issues() { this.$nextTick(() => { this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); @@ -76,35 +104,29 @@ export default { }, mounted() { // Scroll event on list to load more - this.$refs.list.addEventListener('scroll', this.onScroll); + this.listRef.addEventListener('scroll', this.onScroll); }, beforeDestroy() { eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); - this.$refs.list.removeEventListener('scroll', this.onScroll); + this.listRef.removeEventListener('scroll', this.onScroll); }, methods: { - ...mapActions(['fetchIssuesForList']), + ...mapActions(['fetchIssuesForList', 'moveIssue']), listHeight() { - return this.$refs.list.getBoundingClientRect().height; + return this.listRef.getBoundingClientRect().height; }, scrollHeight() { - return this.$refs.list.scrollHeight; + return this.listRef.scrollHeight; }, scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); + return this.listRef.scrollTop + this.listHeight(); }, scrollToTop() { - this.$refs.list.scrollTop = 0; + this.listRef.scrollTop = 0; }, loadNextPage() { - const loadingDone = () => { - this.list.loadingMore = false; - }; - this.list.loadingMore = true; - this.fetchIssuesForList({ listId: this.list.id, fetchNext: true }) - .then(loadingDone) - .catch(loadingDone); + this.fetchIssuesForList({ listId: this.list.id, fetchNext: true }); }, toggleForm() { this.showIssueForm = !this.showIssueForm; @@ -112,7 +134,7 @@ export default { onScroll() { window.requestAnimationFrame(() => { if ( - !this.list.loadingMore && + !this.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset && this.hasNextPage ) { @@ -120,32 +142,83 @@ export default { } }); }, + handleDragOnStart() { + sortableStart(); + }, + handleDragOnEnd(params) { + sortableEnd(); + const { newIndex, oldIndex, from, to, item } = params; + const { issueId, issueIid, issuePath } = item.dataset; + const { children } = to; + let moveBeforeId; + let moveAfterId; + + const getIssueId = el => Number(el.dataset.issueId); + + // If issue is being moved within the same list + if (from === to) { + if (newIndex > oldIndex && children.length > 1) { + // If issue is being moved down we look for the issue that ends up before + moveBeforeId = getIssueId(children[newIndex]); + } else if (newIndex < oldIndex && children.length > 1) { + // If issue is being moved up we look for the issue that ends up after + moveAfterId = getIssueId(children[newIndex]); + } else { + // If issue remains in the same list at the same position we do nothing + return; + } + } else { + // We look for the issue that ends up before the moved issue if it exists + if (children[newIndex - 1]) { + moveBeforeId = getIssueId(children[newIndex - 1]); + } + // We look for the issue that ends up after the moved issue if it exists + if (children[newIndex]) { + moveAfterId = getIssueId(children[newIndex]); + } + } + + this.moveIssue({ + issueId, + issueIid, + issuePath, + fromListId: from.dataset.listId, + toListId: to.dataset.listId, + moveBeforeId, + moveAfterId, + }); + }, }, }; </script> <template> <div - v-show="list.isExpanded" + v-show="!list.collapsed" class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column" data-qa-selector="board_list_cards_area" > <div v-if="loading" class="gl-mt-4 gl-text-center" - :aria-label="__('Loading issues')" + :aria-label="$options.i18n.loadingIssues" data-testid="board_list_loading" > <gl-loading-icon /> </div> - <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> - <ul + <board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" /> + <component + :is="treeRootWrapper" v-show="!loading" ref="list" + v-bind="treeRootOptions" :data-board="list.id" - :data-board-type="list.type" + :data-board-type="list.listType" :class="{ 'bg-danger-100': issuesSizeExceedsMax }" class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" + data-testid="tree-root-wrapper" + @start="handleDragOnStart" + @end="handleDragOnEnd" > <board-card v-for="(issue, index) in issues" @@ -157,10 +230,10 @@ export default { :disabled="disabled" /> <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> - <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> - <span v-if="issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> + <gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" /> + <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span> <span v-else>{{ paginatedIssueText }}</span> </li> - </ul> + </component> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_promotion_state.js b/app/assets/javascripts/boards/components/board_promotion_state.js deleted file mode 100644 index ff8b4c56321..00000000000 --- a/app/assets/javascripts/boards/components/board_promotion_state.js +++ /dev/null @@ -1 +0,0 @@ -export default {}; diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 80070b25bd0..60db8fefe82 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -53,7 +53,7 @@ export default { return this.activeList.label; }, boardListType() { - return this.activeList.type || null; + return this.activeList.type || this.activeList.listType || null; }, listTypeTitle() { return this.$options.labelListText; diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 0b079c78209..4f23c38d0f7 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -3,17 +3,18 @@ import { throttle } from 'lodash'; import { GlLoadingIcon, GlSearchBoxByType, - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownHeader, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, + GlModalDirective, } from '@gitlab/ui'; import httpStatusCodes from '~/lib/utils/http_status'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import projectQuery from '../queries/project_boards.query.graphql'; -import groupQuery from '../queries/group_boards.query.graphql'; +import projectQuery from '../graphql/project_boards.query.graphql'; +import groupQuery from '../graphql/group_boards.query.graphql'; import boardsStore from '../stores/boards_store'; import BoardForm from './board_form.vue'; @@ -26,10 +27,13 @@ export default { BoardForm, GlLoadingIcon, GlSearchBoxByType, - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownHeader, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, + }, + directives: { + GlModalDirective, }, props: { currentBoard: { @@ -108,7 +112,7 @@ export default { return this.groupId ? 'group' : 'project'; }, loading() { - return this.loadingRecentBoards && this.loadingBoards; + return this.loadingRecentBoards || Boolean(this.loadingBoards); }, currentPage() { return this.state.currentPage; @@ -235,22 +239,17 @@ export default { <template> <div class="boards-switcher js-boards-selector gl-mr-3"> <span class="boards-selector-wrapper js-boards-selector-wrapper"> - <gl-deprecated-dropdown + <gl-dropdown data-qa-selector="boards_dropdown" toggle-class="dropdown-menu-toggle js-dropdown-toggle" menu-class="flex-column dropdown-extended-height" :text="board.name" @show="loadBoards" > - <div> - <div class="dropdown-title mb-0" @mousedown.prevent> - {{ s__('IssueBoards|Switch board') }} - </div> - </div> - - <gl-deprecated-dropdown-header class="mt-0"> - <gl-search-box-by-type ref="searchBox" v-model="filterTerm" /> - </gl-deprecated-dropdown-header> + <p class="gl-new-dropdown-header-top" @mousedown.prevent> + {{ s__('IssueBoards|Switch board') }} + </p> + <gl-search-box-by-type ref="searchBox" v-model="filterTerm" class="m-2" /> <div v-if="!loading" @@ -259,49 +258,50 @@ export default { class="dropdown-content flex-fill" @scroll.passive="throttledSetScrollFade" > - <gl-deprecated-dropdown-item + <gl-dropdown-item v-show="filteredBoards.length === 0" class="gl-pointer-events-none text-secondary" > {{ s__('IssueBoards|No matching boards found') }} - </gl-deprecated-dropdown-item> + </gl-dropdown-item> - <h6 v-if="showRecentSection" class="dropdown-bold-header my-0"> + <gl-dropdown-section-header v-if="showRecentSection"> {{ __('Recent') }} - </h6> + </gl-dropdown-section-header> <template v-if="showRecentSection"> - <gl-deprecated-dropdown-item + <gl-dropdown-item v-for="recentBoard in recentBoards" :key="`recent-${recentBoard.id}`" class="js-dropdown-item" :href="`${boardBaseUrl}/${recentBoard.id}`" > {{ recentBoard.name }} - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </template> - <hr v-if="showRecentSection" class="my-1" /> + <gl-dropdown-divider v-if="showRecentSection" /> - <h6 v-if="showRecentSection" class="dropdown-bold-header my-0"> + <gl-dropdown-section-header v-if="showRecentSection"> {{ __('All') }} - </h6> + </gl-dropdown-section-header> - <gl-deprecated-dropdown-item + <gl-dropdown-item v-for="otherBoard in filteredBoards" :key="otherBoard.id" class="js-dropdown-item" :href="`${boardBaseUrl}/${otherBoard.id}`" > {{ otherBoard.name }} - </gl-deprecated-dropdown-item> - <gl-deprecated-dropdown-item v-if="hasMissingBoards" class="small unclickable"> + </gl-dropdown-item> + + <gl-dropdown-item v-if="hasMissingBoards" class="no-pointer-events"> {{ s__( 'IssueBoards|Some of your boards are hidden, activate a license to see them again.', ) }} - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </div> <div @@ -313,25 +313,27 @@ export default { <gl-loading-icon v-if="loading" /> <div v-if="canAdminBoard"> - <gl-deprecated-dropdown-divider /> + <gl-dropdown-divider /> - <gl-deprecated-dropdown-item + <gl-dropdown-item v-if="multipleIssueBoardsAvailable" + v-gl-modal-directive="'board-config-modal'" data-qa-selector="create_new_board_button" @click.prevent="showPage('new')" > {{ s__('IssueBoards|Create new board') }} - </gl-deprecated-dropdown-item> + </gl-dropdown-item> - <gl-deprecated-dropdown-item + <gl-dropdown-item v-if="showDelete" + v-gl-modal-directive="'board-config-modal'" class="text-danger js-delete-board" @click.prevent="showPage('delete')" > {{ s__('IssueBoards|Delete board') }} - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </div> - </gl-deprecated-dropdown> + </gl-dropdown> <board-form v-if="currentPage" @@ -343,6 +345,7 @@ export default { :scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled" :weights="weights" :enable-scoped-labels="enabledScopedLabels" + :current-board="currentBoard" /> </span> </div> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 45ce1e51489..ddd20ff281c 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -10,6 +10,7 @@ import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; import boardsStore from '../stores/boards_store'; import { isScopedLabel } from '~/lib/utils/common_utils'; +import { ListType } from '../constants'; export default { components: { @@ -122,7 +123,13 @@ export default { return true; }, isNonListLabel(label) { - return label.id && !(this.list.type === 'label' && this.list.title === label.title); + return ( + label.id && + !( + (this.list.type || this.list.listType) === ListType.label && + this.list.title === label.title + ) + ); }, filterByLabel(label) { if (!this.updateFilters) return; @@ -158,9 +165,13 @@ export default { class="confidential-icon gl-mr-2" :aria-label="__('Confidential')" /> - <a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{ - issue.title - }}</a> + <a + :href="issue.path || issue.webUrl || ''" + :title="issue.title" + class="js-no-trigger" + @mousemove.stop + >{{ issue.title }}</a + > </h4> </div> <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> @@ -196,7 +207,11 @@ export default { #{{ issue.iid }} </span> <span class="board-info-items gl-mt-3 gl-display-inline-block"> - <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" :closed="issue.closed" /> + <issue-due-date + v-if="issue.dueDate" + :date="issue.dueDate" + :closed="issue.closed || Boolean(issue.closedAt)" + /> <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> <issue-card-weight v-if="validIssueWeight" diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 47eee5306da..d1011c24977 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -15,6 +15,7 @@ function shouldCreateListGraphQL(label) { return store.getters.shouldUseGraphQL && !store.getters.getListByLabelId(fullLabelId(label)); } +// eslint-disable-next-line @gitlab/no-global-event-off $(document) .off('created.label') .on('created.label', (e, label, addNewList) => { diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index f90fe582566..9c90938fc52 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -7,6 +7,7 @@ import eventHub from '../eventhub'; import Api from '../../api'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { ListType } from '../constants'; export default { name: 'BoardProjectSelect', @@ -53,7 +54,7 @@ export default { this.loading = true; const additionalAttrs = {}; - if (this.list.type && this.list.type !== 'backlog') { + if ((this.list.type || this.list.listType) !== ListType.backlog) { additionalAttrs.min_access_level = featureAccessLevel.EVERYONE; } 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 5fb7a9b210c..ce267be6d45 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -50,6 +50,13 @@ export default { } window.removeEventListener('click', this.collapseWhenOffClick); }, + toggle({ emitEvent = true } = {}) { + if (this.edit) { + this.collapse({ emitEvent }); + } else { + this.expand(); + } + }, }, }; </script> @@ -64,18 +71,18 @@ export default { <gl-button v-if="canUpdate" variant="link" - class="gl-text-gray-900!" + class="gl-text-gray-900! js-sidebar-dropdown-toggle" data-testid="edit-button" - @click="expand()" + @click="toggle" > {{ __('Edit') }} </gl-button> </div> - <div v-show="!edit" class="gl-text-gray-400" data-testid="collapsed-content"> + <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content"> <slot name="collapsed">{{ __('None') }}</slot> </div> <div v-show="edit" data-testid="expanded-content"> - <slot></slot> + <slot :edit="edit"></slot> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue index 6935ead2706..904ceaed1b3 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue @@ -79,7 +79,7 @@ export default { <span class="gl-mx-2">-</span> <gl-button variant="link" - class="gl-text-gray-400!" + class="gl-text-gray-500!" data-testid="reset-button" :disabled="loading" @click="setDueDate(null)" diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index 9d537a4ef2c..6a407bd6ba6 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -92,7 +92,7 @@ export default { @close="removeLabel(label.id)" /> </template> - <template> + <template #default="{ edit }"> <labels-select ref="labelsSelect" :allow-label-edit="false" @@ -105,6 +105,7 @@ export default { :labels-filter-base-path="labelsFilterBasePath" :labels-list-title="__('Select label')" :dropdown-button-text="__('Choose labels')" + :is-editing="edit" variant="embedded" class="gl-display-block labels gl-w-full" @updateSelectedLabels="setLabels" diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue new file mode 100644 index 00000000000..78c3f8acc62 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue @@ -0,0 +1,161 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { fetchPolicies } from '~/lib/graphql'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import groupMilestones from '../../graphql/group_milestones.query.graphql'; +import createFlash from '~/flash'; +import { __, s__ } from '~/locale'; + +export default { + components: { + BoardEditableItem, + GlDropdown, + GlLoadingIcon, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlDropdownDivider, + }, + data() { + return { + milestones: [], + searchTitle: '', + loading: false, + edit: false, + }; + }, + apollo: { + milestones: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: groupMilestones, + debounce: 250, + skip() { + return !this.edit; + }, + variables() { + return { + fullPath: this.groupFullPath, + searchTitle: this.searchTitle, + state: 'active', + includeDescendants: true, + }; + }, + update(data) { + const edges = data?.group?.milestones?.edges ?? []; + return edges.map(item => item.node); + }, + error() { + createFlash({ message: this.$options.i18n.fetchMilestonesError }); + }, + }, + }, + computed: { + ...mapGetters({ issue: 'activeIssue' }), + hasMilestone() { + return this.issue.milestone !== null; + }, + groupFullPath() { + const { referencePath = '' } = this.issue; + return referencePath.slice(0, referencePath.indexOf('/')); + }, + projectPath() { + const { referencePath = '' } = this.issue; + return referencePath.slice(0, referencePath.indexOf('#')); + }, + dropdownText() { + return this.issue.milestone?.title ?? this.$options.i18n.noMilestone; + }, + }, + mounted() { + this.$root.$on('bv::dropdown::hide', () => { + this.$refs.sidebarItem.collapse(); + }); + }, + methods: { + ...mapActions(['setActiveIssueMilestone']), + handleOpen() { + this.edit = true; + this.$refs.dropdown.show(); + }, + async setMilestone(milestoneId) { + this.loading = true; + this.searchTitle = ''; + this.$refs.sidebarItem.collapse(); + + try { + const input = { milestoneId, projectPath: this.projectPath }; + await this.setActiveIssueMilestone(input); + } catch (e) { + createFlash({ message: this.$options.i18n.updateMilestoneError }); + } finally { + this.loading = false; + } + }, + }, + i18n: { + milestone: __('Milestone'), + noMilestone: __('No milestone'), + assignMilestone: __('Assign milestone'), + noMilestonesFound: s__('Milestones|No milestones found'), + fetchMilestonesError: __('There was a problem fetching milestones.'), + updateMilestoneError: __('An error occurred while updating the milestone.'), + }, +}; +</script> + +<template> + <board-editable-item + ref="sidebarItem" + :title="$options.i18n.milestone" + :loading="loading" + @open="handleOpen()" + @close="edit = false" + > + <template v-if="hasMilestone" #collapsed> + <strong class="gl-text-gray-900">{{ issue.milestone.title }}</strong> + </template> + <template> + <gl-dropdown + ref="dropdown" + :text="dropdownText" + :header-text="$options.i18n.assignMilestone" + block + > + <gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" /> + <gl-dropdown-item + data-testid="no-milestone-item" + :is-check-item="true" + :is-checked="!issue.milestone" + @click="setMilestone(null)" + > + {{ $options.i18n.noMilestone }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-loading-icon v-if="$apollo.loading" class="gl-py-4" /> + <template v-else-if="milestones.length > 0"> + <gl-dropdown-item + v-for="milestone in milestones" + :key="milestone.id" + :is-check-item="true" + :is-checked="issue.milestone && milestone.id === issue.milestone.id" + data-testid="milestone-item" + @click="setMilestone(milestone.id)" + > + {{ milestone.title }} + </gl-dropdown-item> + </template> + <gl-dropdown-text v-else data-testid="no-milestones-found"> + {{ $options.i18n.noMilestonesFound }} + </gl-dropdown-text> + </gl-dropdown> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 49cb560594c..9264fac5eda 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -9,8 +9,6 @@ export const ListType = { backlog: 'backlog', closed: 'closed', label: 'label', - promotion: 'promotion', - blank: 'blank', }; export const inactiveId = 0; @@ -18,11 +16,7 @@ export const inactiveId = 0; export const ISSUABLE = 'issuable'; export const LIST = 'list'; -/* eslint-disable-next-line @gitlab/require-i18n-strings */ -export const DEFAULT_LABELS = ['to do', 'doing']; - export default { BoardType, ListType, - DEFAULT_LABELS, }; diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js index 419a640d5c5..b6b34556663 100644 --- a/app/assets/javascripts/boards/ee_functions.js +++ b/app/assets/javascripts/boards/ee_functions.js @@ -1,5 +1,3 @@ -export const setPromotionState = () => {}; - export const setWeightFetchingState = () => {}; export const setEpicFetchingState = () => {}; diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 4fa78ecd5a4..1667dcc9f2e 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -1,7 +1,10 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; +import { transformBoardConfig } from 'ee_else_ce/boards/boards_util'; import FilteredSearchContainer from '../filtered_search/container'; import boardsStore from './stores/boards_store'; +import vuexstore from './stores'; +import { updateHistory } from '~/lib/utils/url_utility'; export default class FilteredSearchBoards extends FilteredSearchManager { constructor(store, updateUrl = false, cantEdit = []) { @@ -22,18 +25,28 @@ export default class FilteredSearchBoards extends FilteredSearchManager { this.isHandledAsync = true; this.cantEdit = cantEdit.filter(i => typeof i === 'string'); this.cantEditWithValue = cantEdit.filter(i => typeof i === 'object'); + + if (vuexstore.getters.shouldUseGraphQL && vuexstore.state.boardConfig) { + const boardConfigPath = transformBoardConfig(vuexstore.state.boardConfig); + if (boardConfigPath !== '') { + const filterPath = window.location.search ? `${window.location.search}&` : '?'; + updateHistory({ + url: `${filterPath}${transformBoardConfig(vuexstore.state.boardConfig)}`, + }); + } + } } updateObject(path) { const groupByParam = new URLSearchParams(window.location.search).get('group_by'); this.store.path = `${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`; - if (gon.features.boardsWithSwimlanes || gon.features.graphqlBoardLists) { - boardsStore.updateFiltersUrl(); - boardsStore.performSearch(); - } - - if (this.updateUrl) { + if (vuexstore.getters.shouldUseGraphQL) { + updateHistory({ + url: `?${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`, + }); + vuexstore.dispatch('performSearch'); + } else if (this.updateUrl) { boardsStore.updateFiltersUrl(); } } diff --git a/app/assets/javascripts/boards/queries/board.fragment.graphql b/app/assets/javascripts/boards/graphql/board.fragment.graphql index 872a4c4afbc..872a4c4afbc 100644 --- a/app/assets/javascripts/boards/queries/board.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/board.fragment.graphql diff --git a/app/assets/javascripts/boards/queries/board.mutation.graphql b/app/assets/javascripts/boards/graphql/board.mutation.graphql index ef2b81a7939..ef2b81a7939 100644 --- a/app/assets/javascripts/boards/queries/board.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board.mutation.graphql diff --git a/app/assets/javascripts/boards/queries/board_labels.query.graphql b/app/assets/javascripts/boards/graphql/board_labels.query.graphql index 42a94419a97..42a94419a97 100644 --- a/app/assets/javascripts/boards/queries/board_labels.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_labels.query.graphql diff --git a/app/assets/javascripts/boards/queries/board_list.fragment.graphql b/app/assets/javascripts/boards/graphql/board_list.fragment.graphql index bbf3314377e..bbf3314377e 100644 --- a/app/assets/javascripts/boards/queries/board_list.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/board_list.fragment.graphql diff --git a/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql index 48420b349ae..f78a21baa7f 100644 --- a/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/boards/queries/board_list.fragment.graphql" +#import "ee_else_ce/boards/graphql/board_list.fragment.graphql" mutation CreateBoardList( $boardId: BoardID! diff --git a/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_destroy.mutation.graphql index ef3fd36e980..ef3fd36e980 100644 --- a/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_destroy.mutation.graphql diff --git a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql index d85b736720b..d85b736720b 100644 --- a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql diff --git a/app/assets/javascripts/boards/queries/board_list_update.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql index b474c9acb93..b474c9acb93 100644 --- a/app/assets/javascripts/boards/queries/board_list_update.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql diff --git a/app/assets/javascripts/boards/queries/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql index 88425e9a9c1..eb922f162f8 100644 --- a/app/assets/javascripts/boards/queries/board_lists.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/boards/queries/board_list.fragment.graphql" +#import "ee_else_ce/boards/graphql/board_list.fragment.graphql" query ListIssues( $fullPath: ID! diff --git a/app/assets/javascripts/boards/queries/group_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_boards.query.graphql index 74c224add7d..feafd6ae10d 100644 --- a/app/assets/javascripts/boards/queries/group_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_boards.query.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/boards/queries/board.fragment.graphql" +#import "ee_else_ce/boards/graphql/board.fragment.graphql" query group_boards($fullPath: ID!) { group(fullPath: $fullPath) { diff --git a/app/assets/javascripts/boards/graphql/group_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_milestones.query.graphql new file mode 100644 index 00000000000..f2ab12ef4a7 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/group_milestones.query.graphql @@ -0,0 +1,17 @@ +query groupMilestones( + $fullPath: ID! + $state: MilestoneStateEnum + $includeDescendants: Boolean + $searchTitle: String +) { + group(fullPath: $fullPath) { + milestones(state: $state, includeDescendants: $includeDescendants, searchTitle: $searchTitle) { + edges { + node { + id + title + } + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql index 4b429f875a6..1395bef39ed 100644 --- a/app/assets/javascripts/boards/queries/issue.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql @@ -11,6 +11,10 @@ fragment IssueNode on Issue { webUrl subscribed relativePosition + milestone { + id + title + } assignees { nodes { ...User diff --git a/app/assets/javascripts/boards/queries/issue_create.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql index 65be147be07..c1a2361a4e8 100644 --- a/app/assets/javascripts/boards/queries/issue_create.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/boards/queries/issue.fragment.graphql" +#import "ee_else_ce/boards/graphql/issue.fragment.graphql" mutation CreateIssue($input: CreateIssueInput!) { createIssue(input: $input) { diff --git a/app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql index ff6aa597f48..3c574fd8c87 100644 --- a/app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/boards/queries/issue.fragment.graphql" +#import "ee_else_ce/boards/graphql/issue.fragment.graphql" mutation IssueMoveList( $projectPath: ID! diff --git a/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql index bbea248cf85..bbea248cf85 100644 --- a/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql diff --git a/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql index 3c5f4b3e3bd..3c5f4b3e3bd 100644 --- a/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql diff --git a/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql new file mode 100644 index 00000000000..5dc78a03a06 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql @@ -0,0 +1,12 @@ +mutation issueSetMilestone($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + milestone { + id + title + description + } + } + errors + } +} diff --git a/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql index 1f383245ac2..1f383245ac2 100644 --- a/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql diff --git a/app/assets/javascripts/boards/queries/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index 5dbfe4675c6..43af7d2b2f1 100644 --- a/app/assets/javascripts/boards/queries/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/boards/queries/issue.fragment.graphql" +#import "ee_else_ce/boards/graphql/issue.fragment.graphql" query ListIssues( $fullPath: ID! diff --git a/app/assets/javascripts/boards/queries/project_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_boards.query.graphql index a1326bd5eff..f98d25ba671 100644 --- a/app/assets/javascripts/boards/queries/project_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_boards.query.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/boards/queries/board.fragment.graphql" +#import "ee_else_ce/boards/graphql/board.fragment.graphql" query project_boards($fullPath: ID!) { project(fullPath: $fullPath) { diff --git a/app/assets/javascripts/boards/queries/users_search.query.graphql b/app/assets/javascripts/boards/graphql/users_search.query.graphql index ca016495d79..ca016495d79 100644 --- a/app/assets/javascripts/boards/queries/users_search.query.graphql +++ b/app/assets/javascripts/boards/graphql/users_search.query.graphql diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index d3e40299d8d..64a4f246735 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { mapActions, mapGetters, mapState } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; @@ -9,7 +9,6 @@ import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; import toggleLabels from 'ee_else_ce/boards/toggle_labels'; import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes'; import { - setPromotionState, setWeightFetchingState, setEpicFetchingState, getMilestoneTitle, @@ -41,7 +40,6 @@ import { NavigationType, convertObjectPropsToCamelCase, parseBoolean, - urlParamsToObject, } from '~/lib/utils/common_utils'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; @@ -77,7 +75,6 @@ export default () => { el: $boardApp, components: { BoardContent, - Board: () => import('ee_else_ce/boards/components/board_column.vue'), BoardSidebar, BoardAddIssuesModal, BoardSettingsSidebar: () => import('~/boards/components/board_settings_sidebar.vue'), @@ -114,7 +111,6 @@ export default () => { }; }, computed: { - ...mapState(['isShowingEpicsSwimlanes']), ...mapGetters(['shouldUseGraphQL']), detailIssueVisible() { return Object.keys(this.detailIssue.issue).length; @@ -133,7 +129,17 @@ export default () => { ...endpoints, boardType: this.parent, disabled: this.disabled, - showPromotion: parseBoolean($boardApp.getAttribute('data-show-promotion')), + boardConfig: { + milestoneId: parseInt($boardApp.dataset.boardMilestoneId, 10), + milestoneTitle: $boardApp.dataset.boardMilestoneTitle || '', + iterationId: parseInt($boardApp.dataset.boardIterationId, 10), + iterationTitle: $boardApp.dataset.boardIterationTitle || '', + assigneeUsername: $boardApp.dataset.boardAssigneeUsername, + labels: $boardApp.dataset.labels ? JSON.parse($boardApp.dataset.labels || []) : [], + weight: $boardApp.dataset.boardWeight + ? parseInt($boardApp.dataset.boardWeight, 10) + : null, + }, }); boardsStore.setEndpoints(endpoints); boardsStore.rootPath = this.boardsEndpoint; @@ -142,7 +148,6 @@ export default () => { eventHub.$on('newDetailIssue', this.updateDetailIssue); eventHub.$on('clearDetailIssue', this.clearDetailIssue); sidebarEventHub.$on('toggleSubscription', this.toggleSubscription); - eventHub.$on('performSearch', this.performSearch); eventHub.$on('initialBoardLoad', this.initialBoardLoad); }, beforeDestroy() { @@ -150,7 +155,6 @@ export default () => { eventHub.$off('newDetailIssue', this.updateDetailIssue); eventHub.$off('clearDetailIssue', this.clearDetailIssue); sidebarEventHub.$off('toggleSubscription', this.toggleSubscription); - eventHub.$off('performSearch', this.performSearch); eventHub.$off('initialBoardLoad', this.initialBoardLoad); }, mounted() { @@ -166,22 +170,13 @@ export default () => { } }, methods: { - ...mapActions([ - 'setInitialBoardData', - 'setFilters', - 'fetchEpicsSwimlanes', - 'resetIssues', - 'resetEpics', - 'fetchLists', - ]), + ...mapActions(['setInitialBoardData', 'performSearch']), initialBoardLoad() { boardsStore .all() .then(res => res.data) .then(lists => { lists.forEach(list => boardsStore.addList(list)); - boardsStore.addBlankState(); - setPromotionState(boardsStore); this.loading = false; }) .catch(() => { @@ -191,17 +186,6 @@ export default () => { updateTokens() { this.filterManager.updateTokens(); }, - performSearch() { - this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search))); - if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) { - this.resetEpics(); - this.resetIssues(); - this.fetchEpicsSwimlanes({}); - } else if (gon.features.graphqlBoardLists && !this.isShowingEpicsSwimlanes) { - this.fetchLists(); - this.resetIssues(); - } - }, updateDetailIssue(newIssue, multiSelect = false) { const { sidebarInfoEndpoint } = newIssue; if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { @@ -303,7 +287,7 @@ export default () => { const issueBoardsModal = document.getElementById('js-add-issues-btn'); - if (issueBoardsModal) { + if (issueBoardsModal && gon.features.addIssuesButton) { // eslint-disable-next-line no-new new Vue({ el: issueBoardsModal, @@ -350,5 +334,8 @@ export default () => { toggleEpicsSwimlanes(); } - mountMultipleBoardsSwitcher(); + mountMultipleBoardsSwitcher({ + boardsEndpoint: $boardApp.dataset.boardsEndpoint, + recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint, + }); }; diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index 51bb72b7657..df65ebb7526 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -10,7 +10,7 @@ const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); -export default () => { +export default (endpoints = {}) => { const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher'); return new Vue({ el: boardsSwitcherElement, @@ -35,6 +35,9 @@ export default () => { return { boardsSelectorProps }; }, + provide: { + endpoints, + }, render(createElement) { return createElement(BoardsSelector, { props: this.boardsSelectorProps, diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index dd950a45076..59b97eba9fe 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,9 +1,10 @@ import { pick } from 'lodash'; -import boardListsQuery from 'ee_else_ce/boards/queries/board_lists.query.graphql'; +import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { BoardType, ListType, inactiveId, DEFAULT_LABELS } from '~/boards/constants'; +import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils'; +import { BoardType, ListType, inactiveId } from '~/boards/constants'; import * as types from './mutation_types'; import { formatBoardLists, @@ -12,19 +13,20 @@ import { formatListsPageInfo, formatIssue, } from '../boards_util'; -import boardStore from '~/boards/stores/boards_store'; - -import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; -import listsIssuesQuery from '../queries/lists_issues.query.graphql'; -import boardLabelsQuery from '../queries/board_labels.query.graphql'; -import createBoardListMutation from '../queries/board_list_create.mutation.graphql'; -import updateBoardListMutation from '../queries/board_list_update.mutation.graphql'; -import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql'; -import destroyBoardListMutation from '../queries/board_list_destroy.mutation.graphql'; -import issueCreateMutation from '../queries/issue_create.mutation.graphql'; -import issueSetLabels from '../queries/issue_set_labels.mutation.graphql'; -import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql'; -import issueSetSubscriptionMutation from '../graphql/mutations/issue_set_subscription.mutation.graphql'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; +import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; +import boardLabelsQuery from '../graphql/board_labels.query.graphql'; +import createBoardListMutation from '../graphql/board_list_create.mutation.graphql'; +import updateBoardListMutation from '../graphql/board_list_update.mutation.graphql'; +import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql'; +import destroyBoardListMutation from '../graphql/board_list_destroy.mutation.graphql'; +import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; +import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql'; +import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql'; +import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql'; +import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -63,6 +65,18 @@ export default { commit(types.SET_FILTERS, filterParams); }, + performSearch({ dispatch }) { + dispatch( + 'setFilters', + convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)), + ); + + if (gon.features.graphqlBoardLists) { + dispatch('fetchLists'); + dispatch('resetIssues'); + } + }, + fetchLists: ({ commit, state, dispatch }) => { const { endpoints, boardType, filterParams } = state; const { fullPath, boardId } = endpoints; @@ -87,7 +101,6 @@ export default { if (!lists.nodes.find(l => l.listType === ListType.backlog) && !hideBacklogList) { dispatch('createList', { backlog: true }); } - dispatch('generateDefaultLists'); }) .catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE)); }, @@ -118,15 +131,9 @@ export default { }, addList: ({ commit }, list) => { - // Temporarily using positioning logic from boardStore - commit( - types.RECEIVE_ADD_LIST_SUCCESS, - boardStore.updateListPosition({ ...list, doNotFetchIssues: true }), - ); + commit(types.RECEIVE_ADD_LIST_SUCCESS, list); }, - showPromotionList: () => {}, - fetchLabels: ({ state, commit }, searchTerm) => { const { endpoints, boardType } = state; const { fullPath } = endpoints; @@ -150,35 +157,14 @@ export default { .catch(() => commit(types.RECEIVE_LABELS_FAILURE)); }, - generateDefaultLists: async ({ state, commit, dispatch }) => { - if (state.disabled) { - return; - } - if ( - Object.entries(state.boardLists).find( - ([, list]) => list.type !== ListType.backlog && list.type !== ListType.closed, - ) - ) { - return; - } - - const fetchLabelsAndCreateList = label => { - return dispatch('fetchLabels', label) - .then(res => { - if (res.length > 0) { - dispatch('createList', { labelId: res[0].id }); - } - }) - .catch(() => commit(types.GENERATE_DEFAULT_LISTS_FAILURE)); - }; - - await Promise.all(DEFAULT_LABELS.map(fetchLabelsAndCreateList)); - }, - moveList: ( { state, commit, dispatch }, { listId, replacedListId, newIndex, adjustmentValue }, ) => { + if (listId === replacedListId) { + return; + } + const { boardLists } = state; const backupList = { ...boardLists }; const movedList = boardLists[listId]; @@ -315,9 +301,11 @@ export default { }, setAssignees: ({ commit, getters }, assigneeUsernames) => { + commit(types.SET_ASSIGNEE_LOADING, true); + return gqlClient .mutate({ - mutation: updateAssignees, + mutation: updateAssigneesMutation, variables: { iid: getters.activeIssue.iid, projectPath: getters.activeIssue.referencePath.split('#')[0], @@ -325,14 +313,48 @@ export default { }, }) .then(({ data }) => { + const { nodes } = data.issueSetAssignees?.issue?.assignees || []; + commit('UPDATE_ISSUE_BY_ID', { issueId: getters.activeIssue.id, prop: 'assignees', - value: data.issueSetAssignees.issue.assignees.nodes, + value: nodes, }); + + return nodes; + }) + .catch(() => { + createFlash({ message: __('An error occurred while updating assignees.') }); + }) + .finally(() => { + commit(types.SET_ASSIGNEE_LOADING, false); }); }, + setActiveIssueMilestone: async ({ commit, getters }, input) => { + const { activeIssue } = getters; + const { data } = await gqlClient.mutate({ + mutation: issueSetMilestoneMutation, + variables: { + input: { + iid: String(activeIssue.iid), + milestoneId: getIdFromGraphQLId(input.milestoneId), + projectPath: input.projectPath, + }, + }, + }); + + if (data.updateIssue.errors?.length > 0) { + throw new Error(data.updateIssue.errors); + } + + commit(types.UPDATE_ISSUE_BY_ID, { + issueId: activeIssue.id, + prop: 'milestone', + value: data.updateIssue.issue.milestone, + }); + }, + createNewIssue: ({ commit, state }, issueInput) => { const input = issueInput; const { boardType, endpoints } = state; @@ -378,7 +400,7 @@ export default { setActiveIssueLabels: async ({ commit, getters }, input) => { const { activeIssue } = getters; const { data } = await gqlClient.mutate({ - mutation: issueSetLabels, + mutation: issueSetLabelsMutation, variables: { input: { iid: String(activeIssue.iid), @@ -403,7 +425,7 @@ export default { setActiveIssueDueDate: async ({ commit, getters }, input) => { const { activeIssue } = getters; const { data } = await gqlClient.mutate({ - mutation: issueSetDueDate, + mutation: issueSetDueDateMutation, variables: { input: { iid: String(activeIssue.iid), diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 337b2897fe9..36702b6ca5f 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -1,9 +1,8 @@ /* eslint-disable no-shadow, no-param-reassign,consistent-return */ /* global List */ /* global ListIssue */ -import { sortBy, pick } from 'lodash'; +import { sortBy } from 'lodash'; import Vue from 'vue'; -import Cookies from 'js-cookie'; import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; import { urlParamsToObject, @@ -22,8 +21,6 @@ import ListLabel from '../models/label'; import ListAssignee from '../models/assignee'; import ListMilestone from '../models/milestone'; -import createBoardMutation from '../queries/board.mutation.graphql'; - const PER_PAGE = 20; export const gqlClient = createDefaultClient(); @@ -125,20 +122,6 @@ const boardsStore = { .querySelector(`.js-board-list-${getIdFromGraphQLId(listId)}`) ?.classList.remove('is-active'); }, - shouldAddBlankState() { - // Decide whether to add the blank state - return !this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0]; - }, - addBlankState() { - if (!this.shouldAddBlankState() || this.welcomeIsHidden()) return; - - this.generateDefaultLists() - .then(res => res.data) - .then(data => Promise.all(data.map(list => this.addList(list)))) - .catch(() => { - this.removeList(undefined, 'label'); - }); - }, findIssueLabel(issue, findLabel) { return issue.labels.find(label => label.id === findLabel.id); @@ -202,9 +185,6 @@ const boardsStore = { return list.issues.find(issue => issue.id === id); }, - welcomeIsHidden() { - return parseBoolean(Cookies.get('issue_board_welcome_hidden')); - }, removeList(id, type = 'blank') { const list = this.findList('id', id, type); @@ -302,11 +282,7 @@ const boardsStore = { onNewListIssueResponse(list, issue, data) { issue.refreshData(data); - if ( - !gon.features.boardsWithSwimlanes && - !gon.features.graphqlBoardLists && - list.issues.length > 1 - ) { + if (list.issues.length > 1) { const moveBeforeId = list.issues[1].id; this.moveIssue(issue.id, null, null, null, moveBeforeId); } @@ -516,10 +492,6 @@ const boardsStore = { eventHub.$emit('updateTokens'); }, - performSearch() { - eventHub.$emit('performSearch'); - }, - setListDetail(newList) { this.detail.list = newList; }, @@ -566,10 +538,6 @@ const boardsStore = { return axios.get(this.state.endpoints.listsEndpoint); }, - generateDefaultLists() { - return axios.post(this.state.endpoints.listsEndpointGenerate, {}); - }, - createList(entityId, entityType) { const list = { [entityType]: entityId, @@ -785,52 +753,6 @@ const boardsStore = { return axios.get(this.state.endpoints.recentBoardsEndpoint); }, - createBoard(board) { - const boardPayload = { ...board }; - boardPayload.label_ids = (board.labels || []).map(b => b.id); - - if (boardPayload.label_ids.length === 0) { - boardPayload.label_ids = ['']; - } - - if (boardPayload.assignee) { - boardPayload.assignee_id = boardPayload.assignee.id; - } - - if (boardPayload.milestone) { - boardPayload.milestone_id = boardPayload.milestone.id; - } - - if (boardPayload.id) { - const input = { - ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']), - id: this.generateBoardGid(boardPayload.id), - }; - - return Promise.all([ - axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }), - gqlClient.mutate({ - mutation: createBoardMutation, - variables: input, - }), - ]); - } - - return axios - .post(this.generateBoardsPath(), { board: boardPayload }) - .then(resp => resp.data) - .then(data => { - gqlClient.mutate({ - mutation: createBoardMutation, - variables: { - ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']), - id: this.generateBoardGid(data.id), - }, - }); - return data; - }); - }, - deleteBoard({ id }) { return axios.delete(this.generateBoardsPath(id)); }, diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index cd28b4a0ff7..ca6887b6f45 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -2,15 +2,8 @@ import { find } from 'lodash'; import { inactiveId } from '../constants'; export default { - labelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), isSidebarOpen: state => state.activeId !== inactiveId, - isSwimlanesOn: state => { - if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) { - return false; - } - - return state.isShowingEpicsSwimlanes; - }, + isSwimlanesOn: () => false, getIssueById: state => id => { return state.issues[id] || {}; }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 3a57cb9b5e1..2b2c2bee51c 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -34,4 +34,5 @@ export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID'; +export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING'; export const RESET_ISSUES = 'RESET_ISSUES'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index bb083158c8f..8c4e514710f 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -13,7 +13,7 @@ const notImplemented = () => { export const removeIssueFromList = ({ state, listId, issueId }) => { Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId)); const list = state.boardLists[listId]; - Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize - 1 }); + Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount - 1 }); }; export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { @@ -27,16 +27,16 @@ export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfter listIssues.splice(newIndex, 0, issueId); Vue.set(state.issuesByListId, listId, listIssues); const list = state.boardLists[listId]; - Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize + 1 }); + Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + 1 }); }; export default { [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { - const { boardType, disabled, showPromotion, ...endpoints } = data; + const { boardType, disabled, boardConfig, ...endpoints } = data; state.endpoints = endpoints; state.boardType = boardType; state.disabled = disabled; - state.showPromotion = showPromotion; + state.boardConfig = boardConfig; }, [mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => { @@ -143,6 +143,10 @@ export default { Vue.set(state.issues[issueId], prop, value); }, + [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) { + state.isSettingAssignees = isLoading; + }, + [mutationTypes.REQUEST_ADD_ISSUE]: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index b91c09f8051..573e98e56e0 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -4,16 +4,17 @@ export default () => ({ endpoints: {}, boardType: null, disabled: false, - showPromotion: false, isShowingLabels: true, activeId: inactiveId, sidebarType: '', boardLists: {}, listsFlags: {}, issuesByListId: {}, + isSettingAssignees: false, pageInfoByListId: {}, issues: {}, filterParams: {}, + boardConfig: {}, error: undefined, // TODO: remove after ce/ee split of board_content.vue isShowingEpicsSwimlanes: false, diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue index def45026b35..731ed2ddd01 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue @@ -1,8 +1,8 @@ <script> import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui'; import EditorLite from '~/vue_shared/components/editor_lite.vue'; -import CiLintResults from './ci_lint_results.vue'; -import lintCIMutation from '../graphql/mutations/lint_ci.mutation.graphql'; +import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; +import lintCiMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql'; export default { components: { @@ -56,7 +56,7 @@ export default { lintCI: { valid, errors, warnings, jobs }, }, } = await this.$apollo.mutate({ - mutation: lintCIMutation, + mutation: lintCiMutation, variables: { endpoint: this.endpoint, content: this.content, dry: this.dryRun }, }); @@ -119,6 +119,7 @@ export default { <ci-lint-results v-if="showingResults" + class="col-sm-12 gl-mt-5" :valid="valid" :jobs="jobs" :errors="errors" diff --git a/app/assets/javascripts/ci_lint/graphql/resolvers.js b/app/assets/javascripts/ci_lint/graphql/resolvers.js deleted file mode 100644 index 126b4c664b2..00000000000 --- a/app/assets/javascripts/ci_lint/graphql/resolvers.js +++ /dev/null @@ -1,34 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -const resolvers = { - Mutation: { - lintCI: (_, { endpoint, content, dry_run }) => { - return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({ - valid: data.valid, - errors: data.errors, - warnings: data.warnings, - jobs: data.jobs.map(job => { - const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null; - - return { - name: job.name, - stage: job.stage, - beforeScript: job.before_script, - script: job.script, - afterScript: job.after_script, - tagList: job.tag_list, - environment: job.environment, - when: job.when, - allowFailure: job.allow_failure, - only, - except: job.except, - __typename: 'CiLintJob', - }; - }), - __typename: 'CiLintContent', - })); - }, - }, -}; - -export default resolvers; diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js index e4cda4cb369..274aab45deb 100644 --- a/app/assets/javascripts/ci_lint/index.js +++ b/app/assets/javascripts/ci_lint/index.js @@ -1,8 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { resolvers } from '~/pipeline_editor/graphql/resolvers'; + import CiLint from './components/ci_lint.vue'; -import resolvers from './graphql/resolvers'; Vue.use(VueApollo); diff --git a/app/assets/javascripts/clone_panel.js b/app/assets/javascripts/clone_panel.js new file mode 100644 index 00000000000..362e6c5c5ce --- /dev/null +++ b/app/assets/javascripts/clone_panel.js @@ -0,0 +1,42 @@ +import $ from 'jquery'; + +export default function initClonePanel() { + const $cloneOptions = $('ul.clone-options-dropdown'); + if ($cloneOptions.length) { + const $cloneUrlField = $('#clone_url'); + const $cloneBtnLabel = $('.js-git-clone-holder .js-clone-dropdown-label'); + const mobileCloneField = document.querySelector( + '.js-mobile-git-clone .js-clone-dropdown-label', + ); + + const selectedCloneOption = $cloneBtnLabel.text().trim(); + if (selectedCloneOption.length > 0) { + $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active'); + } + + $('a', $cloneOptions).on('click', e => { + e.preventDefault(); + const $this = $(e.currentTarget); + const url = $this.attr('href'); + const cloneType = $this.data('cloneType'); + + $('.is-active', $cloneOptions).removeClass('is-active'); + $(`a[data-clone-type="${cloneType}"]`).each(function switchProtocol() { + const $el = $(this); + const activeText = $el.find('.dropdown-menu-inner-title').text(); + const $container = $el.closest('.js-git-clone-holder, .js-mobile-git-clone'); + const $label = $container.find('.js-clone-dropdown-label'); + + $el.toggleClass('is-active'); + $label.text(activeText); + }); + + if (mobileCloneField) { + mobileCloneField.dataset.clipboardText = url; + } else { + $cloneUrlField.val(url); + } + $('.js-git-empty .js-clone').text(url); + }); + } +} diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js deleted file mode 100644 index 9bbbe07e7a1..00000000000 --- a/app/assets/javascripts/close_reopen_report_toggle.js +++ /dev/null @@ -1,92 +0,0 @@ -import DropLab from './droplab/drop_lab'; -import ISetter from './droplab/plugins/input_setter'; - -// Todo: Remove this when fixing issue in input_setter plugin -const InputSetter = { ...ISetter }; - -class CloseReopenReportToggle { - constructor(opts = {}) { - this.dropdownTrigger = opts.dropdownTrigger; - this.dropdownList = opts.dropdownList; - this.button = opts.button; - } - - initDroplab() { - this.reopenItem = this.dropdownList.querySelector('.reopen-item'); - this.closeItem = this.dropdownList.querySelector('.close-item'); - - this.droplab = new DropLab(); - - const config = this.setConfig(); - - this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config); - } - - updateButton(isClosed) { - this.toggleButtonType(isClosed); - - this.button.blur(); - } - - toggleButtonType(isClosed) { - const [showItem, hideItem] = this.getButtonTypes(isClosed); - - showItem.classList.remove('hidden'); - showItem.classList.add('droplab-item-selected'); - - hideItem.classList.add('hidden'); - hideItem.classList.remove('droplab-item-selected'); - - showItem.click(); - } - - getButtonTypes(isClosed) { - return isClosed ? [this.reopenItem, this.closeItem] : [this.closeItem, this.reopenItem]; - } - - setDisable(shouldDisable = true) { - if (shouldDisable) { - this.button.setAttribute('disabled', 'true'); - this.dropdownTrigger.setAttribute('disabled', 'true'); - } else { - this.button.removeAttribute('disabled'); - this.dropdownTrigger.removeAttribute('disabled'); - } - } - - setConfig() { - const config = { - InputSetter: [ - { - input: this.button, - valueAttribute: 'data-text', - inputAttribute: 'data-value', - }, - { - input: this.button, - valueAttribute: 'data-text', - inputAttribute: 'title', - }, - { - input: this.button, - valueAttribute: 'data-button-class', - inputAttribute: 'class', - }, - { - input: this.dropdownTrigger, - valueAttribute: 'data-toggle-class', - inputAttribute: 'class', - }, - { - input: this.button, - valueAttribute: 'data-url', - inputAttribute: 'data-endpoint', - }, - ], - }; - - return config; - } -} - -export default CloseReopenReportToggle; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index a75646db162..a533a1a78e8 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -52,6 +52,7 @@ export default class Clusters { clusterStatus, clusterStatusReason, helpPath, + helmHelpPath, ingressHelpPath, ingressDnsHelpPath, ingressModSecurityHelpPath, @@ -68,8 +69,9 @@ export default class Clusters { this.clusterBannerDismissedKey = `cluster_${this.clusterId}_banner_dismissed`; this.store = new ClustersStore(); - this.store.setHelpPaths( + this.store.setHelpPaths({ helpPath, + helmHelpPath, ingressHelpPath, ingressDnsHelpPath, ingressModSecurityHelpPath, @@ -78,7 +80,7 @@ export default class Clusters { deployBoardsHelpPath, cloudRunHelpPath, ciliumHelpPath, - ); + }); this.store.setManagePrometheusPath(managePrometheusPath); this.store.updateStatus(clusterStatus); this.store.updateStatusReason(clusterStatusReason); @@ -162,6 +164,7 @@ export default class Clusters { 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, @@ -262,13 +265,21 @@ export default class Clusters { 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'); + // eslint-disable-next-line @gitlab/no-global-event-off eventHub.$off('setIngressModSecurityEnabled'); + // eslint-disable-next-line @gitlab/no-global-event-off eventHub.$off('setIngressModSecurityMode'); + // eslint-disable-next-line @gitlab/no-global-event-off eventHub.$off('resetIngressModSecurityChanges'); + // eslint-disable-next-line @gitlab/no-global-event-off eventHub.$off('setFluentdSettings'); } diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 271d862afab..fdffaa24d03 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -1,6 +1,7 @@ <script> -import { GlLoadingIcon, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlLoadingIcon, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; 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 kubernetesLogo from 'images/cluster_app_logos/kubernetes.png'; import certManagerLogo from 'images/cluster_app_logos/cert_manager.png'; @@ -29,6 +30,7 @@ export default { CrossplaneProviderStack, IngressModsecuritySettings, FluentdOutputSettings, + GlAlert, }, props: { type: { @@ -46,6 +48,11 @@ export default { required: false, default: '', }, + helmHelpPath: { + type: String, + required: false, + default: '', + }, ingressHelpPath: { type: String, required: false, @@ -150,6 +157,7 @@ export default { }, logos: { gitlabLogo, + helmLogo, jupyterhubLogo, kubernetesLogo, certManagerLogo, @@ -173,6 +181,35 @@ export default { <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" @@ -257,8 +294,8 @@ export default { </p> </template> <template v-else> - <div class="bs-callout bs-callout-info"> - <strong data-testid="ingressCostWarning"> + <gl-alert variant="info" :dismissible="false"> + <span data-testid="ingressCostWarning"> <gl-sprintf :message=" s__( @@ -272,8 +309,8 @@ export default { }}</gl-link> </template> </gl-sprintf> - </strong> - </div> + </span> + </gl-alert> </template> </template> </application-row> @@ -536,13 +573,13 @@ export default { title-link="https://github.com/knative/docs" > <template #description> - <p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info"> + <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> - </p> + </gl-alert> <p> {{ s__(`ClusterIntegration|Knative extends Kubernetes to provide diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index cb415d902e8..d80bd6f5b42 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -7,6 +7,7 @@ import { GlSearchBoxByType, GlSprintf, GlButton, + GlAlert, } from '@gitlab/ui'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import { __, s__ } from '~/locale'; @@ -25,6 +26,7 @@ export default { GlDropdownItem, GlSearchBoxByType, GlSprintf, + GlAlert, }, props: { knative: { @@ -106,12 +108,13 @@ export default { <template> <div class="row"> - <div + <gl-alert v-if="knative.updateFailed" - class="bs-callout bs-callout-danger cluster-application-banner col-12 mt-2 mb-2 js-cluster-knative-domain-name-failure-message" + 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.') }} - </div> + </gl-alert> <div :class="{ 'col-md-6': knativeInstalled, 'col-12': !knativeInstalled }" diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue index 477dd13db4f..2a197e40b60 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -16,7 +16,7 @@ import { const CUSTOM_APP_WARNING_TEXT = { [HELM]: sprintf( s__( - 'ClusterIntegration|The associated Tiller pod, the %{gitlabManagedAppsNamespace} namespace, and all of its resources will be deleted and cannot be restored.', + 'ClusterIntegration|The associated Tiller pod will be deleted and cannot be restored. Your other applications will remain unaffected.', ), { gitlabManagedAppsNamespace: '<code>gitlab-managed-apps</code>', diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js index 683b0e18534..1dd815ae44d 100644 --- a/app/assets/javascripts/clusters/services/application_state_machine.js +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -193,6 +193,12 @@ const applicationStateMachine = { uninstallSuccessful: true, }, }, + [NOT_INSTALLABLE]: { + target: NOT_INSTALLABLE, + effects: { + uninstallSuccessful: true, + }, + }, [UNINSTALL_ERRORED]: { target: INSTALLED, effects: { diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 53868b7c02d..88505eac3a9 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -36,6 +36,7 @@ export default class ClusterStore { constructor() { this.state = { helpPath: null, + helmHelpPath: null, ingressHelpPath: null, environmentsHelpPath: null, clustersHelpPath: null, @@ -49,7 +50,7 @@ export default class ClusterStore { applications: { helm: { ...applicationInitialState, - title: s__('ClusterIntegration|Helm Tiller'), + title: s__('ClusterIntegration|Legacy Helm Tiller server'), }, ingress: { ...applicationInitialState, @@ -126,26 +127,10 @@ export default class ClusterStore { }; } - setHelpPaths( - helpPath, - ingressHelpPath, - ingressDnsHelpPath, - ingressModSecurityHelpPath, - environmentsHelpPath, - clustersHelpPath, - deployBoardsHelpPath, - cloudRunHelpPath, - ciliumHelpPath, - ) { - this.state.helpPath = helpPath; - this.state.ingressHelpPath = ingressHelpPath; - this.state.ingressDnsHelpPath = ingressDnsHelpPath; - this.state.ingressModSecurityHelpPath = ingressModSecurityHelpPath; - this.state.environmentsHelpPath = environmentsHelpPath; - this.state.clustersHelpPath = clustersHelpPath; - this.state.deployBoardsHelpPath = deployBoardsHelpPath; - this.state.cloudRunHelpPath = cloudRunHelpPath; - this.state.ciliumHelpPath = ciliumHelpPath; + setHelpPaths(helpPaths) { + Object.assign(this.state, { + ...helpPaths, + }); } setManagePrometheusPath(managePrometheusPath) { diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 542890d9b04..b70f8d6e736 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -27,7 +27,7 @@ export default class ImageFile { initViewModes() { const viewMode = viewModes[0]; - $('.view-modes', this.file).removeClass('hide'); + $('.view-modes', this.file).removeClass('gl-display-none'); $('.view-modes-menu', this.file).on('click', 'li', event => { if (!$(event.currentTarget).hasClass('active')) { return this.activateViewMode(event.currentTarget.className); @@ -42,12 +42,10 @@ export default class ImageFile { .filter(`.${viewMode}`) .addClass('active'); - // eslint-disable-next-line no-jquery/no-fade - return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(200, () => { - // eslint-disable-next-line no-jquery/no-fade - $(`.view.${viewMode}`, this.file).fadeIn(200); - return this.initView(viewMode); - }); + $(`.view:visible:not(.${viewMode})`, this.file).addClass('gl-display-none'); + $(`.view.${viewMode}`, this.file).removeClass('gl-display-none'); + + return this.initView(viewMode); } initView(viewMode) { @@ -74,12 +72,14 @@ export default class ImageFile { callback(e, left); }; + // eslint-disable-next-line @gitlab/no-global-event-off $el .off('mousedown') .off('touchstart') .on('mousedown', dragStart) .on('touchstart', dragStart); + // eslint-disable-next-line @gitlab/no-global-event-off $body .off('mouseup') .off('mousemove') @@ -120,7 +120,7 @@ export default class ImageFile { return this.requestImageInfo($('img', wrap), (width, height) => { $('.image-info .meta-width', wrap).text(`${width}px`); $('.image-info .meta-height', wrap).text(`${height}px`); - return $('.image-info', wrap).removeClass('hide'); + return $('.image-info', wrap).removeClass('gl-display-none'); }); }); }, diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index 7dd75d03ab9..b18c109937d 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -31,7 +31,7 @@ export default class CommitsList { const search = this.searchField.val(); if (search === this.lastSearch) return Promise.resolve(); const commitsUrl = `${form.attr('action')}?${form.serialize()}`; - this.content.fadeTo('fast', 0.5); + this.content.addClass('gl-opacity-5'); const params = form.serializeArray().reduce( (acc, obj) => Object.assign(acc, { @@ -47,7 +47,7 @@ export default class CommitsList { .then(({ data }) => { this.lastSearch = search; this.content.html(data.html); - this.content.fadeTo('fast', 1.0); + this.content.removeClass('gl-opacity-5'); // Change url so if user reload a page - search results are saved window.history.replaceState( @@ -59,7 +59,7 @@ export default class CommitsList { ); }) .catch(() => { - this.content.fadeTo('fast', 1.0); + this.content.removeClass('gl-opacity-5'); this.lastSearch = null; }); } diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js index df0fa1ae88b..2a1244149ff 100644 --- a/app/assets/javascripts/commons/bootstrap.js +++ b/app/assets/javascripts/commons/bootstrap.js @@ -1,7 +1,14 @@ import $ from 'jquery'; // bootstrap jQuery plugins -import 'bootstrap'; +import 'bootstrap/js/dist/alert'; +import 'bootstrap/js/dist/button'; +import 'bootstrap/js/dist/collapse'; +import 'bootstrap/js/dist/modal'; +import 'bootstrap/js/dist/dropdown'; +import 'bootstrap/js/dist/popover'; +import 'bootstrap/js/dist/tooltip'; +import 'bootstrap/js/dist/tab'; // custom jQuery functions $.fn.extend({ diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js index 7321e4d18cc..4f7bc829b0c 100644 --- a/app/assets/javascripts/confirm_danger_modal.js +++ b/app/assets/javascripts/confirm_danger_modal.js @@ -14,6 +14,7 @@ function openConfirmDangerModal($form, $modal, text) { $submit.disable(); $input.focus(); + // eslint-disable-next-line @gitlab/no-global-event-off $input.off('input').on('input', function handleInput() { const confirmText = rstrip($(this).val()); if (confirmText === confirmTextMatch) { @@ -23,6 +24,7 @@ function openConfirmDangerModal($form, $modal, text) { } }); + // eslint-disable-next-line @gitlab/no-global-event-off $('.js-confirm-danger-submit', $modal) .off('click') .on('click', () => { diff --git a/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue index e9d484bdd94..1e3a19b9da1 100644 --- a/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue +++ b/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue @@ -154,6 +154,7 @@ export default { }); }, beforeDestroy() { + // eslint-disable-next-line @gitlab/no-global-event-off $(this.$refs.dropdown).off(); }, methods: { diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index 2858561e033..a7425735733 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -1,10 +1,17 @@ <script> import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex'; -import { GlFormGroup, GlFormInput, GlFormCheckbox, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { + GlFormGroup, + GlFormInput, + GlFormCheckbox, + GlIcon, + GlLink, + GlSprintf, + GlButton, +} from '@gitlab/ui'; import { s__ } from '~/locale'; import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles'); const { mapState: mapKeyPairsState, mapActions: mapKeyPairsActions } = createNamespacedHelpers( @@ -29,7 +36,7 @@ export default { GlIcon, GlLink, GlSprintf, - LoadingButton, + GlButton, }, props: { gitlabManagedClusterHelpPath: { @@ -508,13 +515,16 @@ export default { </p> </div> <div class="form-group"> - <loading-button - class="js-create-cluster btn-success" + <gl-button + variant="success" + category="primary" + class="js-create-cluster" :disabled="createClusterButtonDisabled" :loading="isCreatingCluster" - :label="createClusterButtonLabel" @click="createCluster()" - /> + > + {{ createClusterButtonLabel }} + </gl-button> </div> </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index f3950a3343a..b182d4dff13 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -42,7 +42,13 @@ export const createRole = ({ dispatch, state: { createRolePath } }, payload) => dispatch('createRoleSuccess', awsData); }) - .catch(error => dispatch('createRoleError', { error })); + .catch(error => { + let message = error; + if (error?.response?.data?.message) { + message = error.response.data.message; + } + dispatch('createRoleError', { error: message }); + }); }; export const requestCreateRole = ({ commit }) => { 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 85d9f0d66ab..522fef423af 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 @@ -179,13 +179,13 @@ export default { 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral' " target="_blank" - >{{ content }} <gl-icon name="external-link" aria-hidden="true" + >{{ content }} <gl-icon name="external-link" /></gl-link> </template> <template #docsLink="{ content }"> <gl-link :href="docsUrl" target="_blank" - >{{ content }} <gl-icon name="external-link" aria-hidden="true" + >{{ content }} <gl-icon name="external-link" /></gl-link> </template> diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js index 9c0ed7f79d4..0d53efe8689 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/create_label.js @@ -29,11 +29,17 @@ export default class CreateLabelDropdown { } cleanBinding() { + // eslint-disable-next-line @gitlab/no-global-event-off this.$colorSuggestions.off('click'); + // eslint-disable-next-line @gitlab/no-global-event-off this.$newLabelField.off('keyup change'); + // eslint-disable-next-line @gitlab/no-global-event-off this.$newColorField.off('keyup change'); + // eslint-disable-next-line @gitlab/no-global-event-off this.$dropdownBack.off('click'); + // eslint-disable-next-line @gitlab/no-global-event-off this.$cancelButton.off('click'); + // eslint-disable-next-line @gitlab/no-global-event-off this.$newLabelCreateButton.off('click'); } diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue index b2c9cd4e597..4c44aac4e2a 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue @@ -27,7 +27,6 @@ export default { ), }" name="warning" - aria-hidden="true" /> {{ n__('Showing %d event', 'Showing %d events', 50) }} </span> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 4cccabca28b..70ebe91a3b2 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -74,6 +74,7 @@ export default () => { const $dropdown = $('.js-ca-dropdown'); const $label = $dropdown.find('.dropdown-label'); + // eslint-disable-next-line @gitlab/no-global-event-off $dropdown .find('li a') .off('click') diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js index c17f2d2efe4..fe57dd2dc8f 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js @@ -622,6 +622,7 @@ export class GitLabDropdown { // eslint-disable-next-line class-methods-use-this removeArrowKeyEvent() { + // eslint-disable-next-line @gitlab/no-global-event-off return $('body').off('keydown'); } diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index e07279ba39d..fb86568c304 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -4,6 +4,7 @@ import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import allVersionsMixin from '../../mixins/all_versions'; import Toolbar from '../../components/toolbar/index.vue'; import DesignDestroyer from '../../components/design_destroyer.vue'; @@ -37,7 +38,7 @@ import { TOGGLE_TODO_ERROR, designDeletionError, } from '../../utils/error_messages'; -import { trackDesignDetailView } from '../../utils/tracking'; +import { trackDesignDetailView, usagePingDesignDetailView } from '../../utils/tracking'; import { DESIGNS_ROUTE_NAME } from '../../router/constants'; import { ACTIVE_DISCUSSION_SOURCE_TYPES, DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../../constants'; @@ -55,7 +56,7 @@ export default { GlAlert, DesignSidebar, }, - mixins: [allVersionsMixin], + mixins: [allVersionsMixin, glFeatureFlagsMixin()], props: { id: { type: String, @@ -150,7 +151,7 @@ export default { }, mounted() { Mousetrap.bind('esc', this.closeDesign); - this.trackEvent(); + this.trackPageViewEvent(); // Set active discussion immediately. // This will ensure that, if a note is specified in the URL hash, @@ -274,7 +275,7 @@ export default { query: this.$route.query, }); }, - trackEvent() { + trackPageViewEvent() { // TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue trackDesignDetailView( 'issue-design-collection', @@ -282,6 +283,10 @@ export default { this.$route.query.version || this.latestVersionId, this.isLatestVersion, ); + + if (this.glFeatures.usageDataDesignAction) { + usagePingDesignDetailView(); + } }, updateActiveDiscussion(id, source = ACTIVE_DISCUSSION_SOURCE_TYPES.discussion) { this.$apollo.mutate({ diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js index 4a39268c38b..37296f5b4ff 100644 --- a/app/assets/javascripts/design_management/utils/tracking.js +++ b/app/assets/javascripts/design_management/utils/tracking.js @@ -1,24 +1,34 @@ import Tracking from '~/tracking'; +import Api from '~/api'; -// Tracking Constants +// Snowplow tracking constants const DESIGN_TRACKING_CONTEXT_SCHEMAS = { VIEW_DESIGN_SCHEMA: 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0', }; -const DESIGN_TRACKING_EVENTS = { + +export const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; + +export const DESIGN_SNOWPLOW_EVENT_TYPES = { VIEW_DESIGN: 'view_design', CREATE_DESIGN: 'create_design', UPDATE_DESIGN: 'update_design', }; -export const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; +export const DESIGN_USAGE_PING_EVENT_TYPES = { + DESIGN_ACTION: 'design_action', +}; +/** + * Track "design detail" view in Snowplow + */ export function trackDesignDetailView( referer = '', owner = '', designVersion = 1, latestVersion = false, ) { - const eventName = DESIGN_TRACKING_EVENTS.VIEW_DESIGN; + const eventName = DESIGN_SNOWPLOW_EVENT_TYPES.VIEW_DESIGN; + Tracking.event(DESIGN_TRACKING_PAGE_NAME, eventName, { label: eventName, context: { @@ -34,9 +44,16 @@ export function trackDesignDetailView( } export function trackDesignCreate() { - return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENTS.CREATE_DESIGN); + return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_SNOWPLOW_EVENT_TYPES.CREATE_DESIGN); } export function trackDesignUpdate() { - return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENTS.UPDATE_DESIGN); + return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_SNOWPLOW_EVENT_TYPES.UPDATE_DESIGN); +} + +/** + * Track "design detail" view via usage ping + */ +export function usagePingDesignDetailView() { + Api.trackRedisHllUserEvent(DESIGN_USAGE_PING_EVENT_TYPES.DESIGN_ACTION); } diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 9d8d184a3f6..7827c78b658 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon, GlPagination, GlSprintf } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Mousetrap from 'mousetrap'; import { __ } from '~/locale'; import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; @@ -9,7 +10,10 @@ import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { isSingleViewStyle } from '~/helpers/diffs_helper'; import { updateHistory } from '~/lib/utils/url_utility'; -import eventHub from '../../notes/event_hub'; + +import notesEventHub from '../../notes/event_hub'; +import eventHub from '../event_hub'; + import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; import NoChanges from './no_changes.vue'; @@ -21,6 +25,7 @@ import MergeConflictWarning from './merge_conflict_warning.vue'; import CollapsedFilesWarning from './collapsed_files_warning.vue'; import { diffsApp } from '../utils/performance'; +import { fileByFile } from '../utils/preferences'; import { TREE_LIST_WIDTH_STORAGE_KEY, @@ -33,6 +38,7 @@ import { ALERT_OVERFLOW_HIDDEN, ALERT_MERGE_CONFLICT, ALERT_COLLAPSED_FILES, + EVT_VIEW_FILE_BY_FILE, } from '../constants'; export default { @@ -113,7 +119,7 @@ export default { required: false, default: false, }, - viewDiffsFileByFile: { + fileByFileUserPreference: { type: Boolean, required: false, default: false, @@ -153,6 +159,7 @@ export default { 'conflictResolutionPath', 'canMerge', 'hasConflicts', + 'viewDiffsFileByFile', ]), ...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']), ...mapGetters(['isNotesFetched', 'getNoteableData']), @@ -230,9 +237,6 @@ export default { } }, diffViewType() { - if (!this.glFeatures.unifiedDiffLines && (this.needsReload() || this.needsFirstLoad())) { - this.refetchDiffData(); - } this.adjustView(); }, shouldShow() { @@ -256,7 +260,7 @@ export default { projectPath: this.projectPath, dismissEndpoint: this.dismissEndpoint, showSuggestPopover: this.showSuggestPopover, - viewDiffsFileByFile: this.viewDiffsFileByFile, + viewDiffsFileByFile: fileByFile(this.fileByFileUserPreference), }); if (this.shouldShow) { @@ -279,9 +283,8 @@ export default { }, created() { this.adjustView(); + this.subscribeToEvents(); - eventHub.$once('fetchDiffData', this.fetchData); - eventHub.$on('refetchDiffData', this.refetchDiffData); this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; this.unwatchDiscussions = this.$watch( @@ -301,9 +304,7 @@ export default { }, beforeDestroy() { diffsApp.deinstrument(); - - eventHub.$off('fetchDiffData', this.fetchData); - eventHub.$off('refetchDiffData', this.refetchDiffData); + this.unsubscribeFromEvents(); this.removeEventListeners(); }, methods: { @@ -319,9 +320,23 @@ export default { 'setHighlightedRow', 'cacheTreeListWidth', 'scrollToFile', - 'toggleShowTreeList', + 'setShowTreeList', 'navigateToDiffFileIndex', + 'setFileByFile', ]), + subscribeToEvents() { + notesEventHub.$once('fetchDiffData', this.fetchData); + notesEventHub.$on('refetchDiffData', this.refetchDiffData); + eventHub.$on(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener); + }, + unsubscribeFromEvents() { + eventHub.$off(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener); + notesEventHub.$off('refetchDiffData', this.refetchDiffData); + notesEventHub.$off('fetchDiffData', this.fetchData); + }, + fileByFileListener({ setting } = {}) { + this.setFileByFile({ fileByFile: setting }); + }, navigateToDiffFileNumber(number) { this.navigateToDiffFileIndex(number - 1); }, @@ -346,7 +361,7 @@ export default { this.fetchDiffFilesMeta() .then(({ real_size }) => { this.diffFilesLength = parseInt(real_size, 10); - if (toggleTree) this.hideTreeListIfJustOneFile(); + if (toggleTree) this.setTreeDisplay(); this.startDiffRendering(); }) @@ -356,6 +371,7 @@ export default { this.fetchDiffFilesBatch() .then(() => { + if (toggleTree) this.setTreeDisplay(); // Guarantee the discussions are assigned after the batch finishes. // Just watching the length of the discussions or the diff files // isn't enough, because with split diff loading, neither will @@ -372,7 +388,7 @@ export default { } if (!this.isNotesFetched) { - eventHub.$emit('fetchNotesData'); + notesEventHub.$emit('fetchNotesData'); } }, setDiscussions() { @@ -425,12 +441,17 @@ export default { this.scrollToFile(this.diffFiles[targetIndex].file_path); } }, - hideTreeListIfJustOneFile() { + setTreeDisplay() { const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY); + let showTreeList = true; - if ((storedTreeShow === null && this.diffFiles.length <= 1) || storedTreeShow === 'false') { - this.toggleShowTreeList(false); + if (storedTreeShow !== null) { + showTreeList = parseBoolean(storedTreeShow); + } else if (!bp.isDesktop() || (!this.isBatchLoading && this.diffFiles.length <= 1)) { + showTreeList = false; } + + return this.setShowTreeList({ showTreeList, saving: false }); }, }, minTreeWidth: MIN_TREE_WIDTH, @@ -521,6 +542,7 @@ export default { <template #total>{{ diffFiles.length }}</template> </gl-sprintf> </div> + <gl-loading-icon v-else-if="retrievingBatches" size="lg" /> </template> <no-changes v-else :changes-empty-state-illustration="changesEmptyStateIllustration" /> </div> diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 1b747fb7f20..a548354f257 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -136,7 +136,12 @@ export default { class="d-inline-flex mb-2" /> <gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group"> - <gl-button label class="gl-font-monospace" v-text="commit.short_id" /> + <gl-button + label + class="gl-font-monospace" + data-testid="commit-sha-short-id" + v-text="commit.short_id" + /> <clipboard-button :text="commit.id" :title="__('Copy commit SHA')" diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue index adef5d94624..da34a7ee19b 100644 --- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue +++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue @@ -1,10 +1,11 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { - GlIcon, + GlDropdown, + GlDropdownItem, TimeAgo, }, props: { @@ -22,57 +23,35 @@ export default { </script> <template> - <span class="dropdown inline"> - <a - class="dropdown-menu-toggle btn btn-default w-100" - data-toggle="dropdown" - aria-expanded="false" + <gl-dropdown :text="selectedVersionName" data-qa-selector="dropdown_content"> + <gl-dropdown-item + v-for="version in versions" + :key="version.id" + :class="{ + 'is-active': version.selected, + }" + :is-check-item="true" + :is-checked="version.selected" + :href="version.href" > - <span> {{ selectedVersionName }} </span> - <gl-icon :size="12" name="angle-down" class="position-absolute" /> - </a> - <div class="dropdown-menu dropdown-select dropdown-menu-selectable"> - <div class="dropdown-content" data-qa-selector="dropdown_content"> - <ul> - <li v-for="version in versions" :key="version.id"> - <a :class="{ 'is-active': version.selected }" :href="version.href"> - <div> - <strong> - {{ version.versionName }} - <template v-if="version.isHead">{{ - s__('DiffsCompareBaseBranch|(HEAD)') - }}</template> - <template v-else-if="version.isBase">{{ - s__('DiffsCompareBaseBranch|(base)') - }}</template> - </strong> - </div> - <div> - <small class="commit-sha"> {{ version.short_commit_sha }} </small> - </div> - <div> - <small> - <template v-if="version.commitsText"> - {{ version.commitsText }} - </template> - <time-ago - v-if="version.created_at" - :time="version.created_at" - class="js-timeago" - /> - </small> - </div> - </a> - </li> - </ul> + <div> + <strong> + {{ version.versionName }} + <template v-if="version.isHead">{{ s__('DiffsCompareBaseBranch|(HEAD)') }}</template> + <template v-else-if="version.isBase">{{ s__('DiffsCompareBaseBranch|(base)') }}</template> + </strong> </div> - </div> - </span> + <div> + <small class="commit-sha"> {{ version.short_commit_sha }} </small> + </div> + <div> + <small> + <template v-if="version.commitsText"> + {{ version.commitsText }} + </template> + <time-ago v-if="version.created_at" :time="version.created_at" class="js-timeago" /> + </small> + </div> + </gl-dropdown-item> + </gl-dropdown> </template> - -<style> -.dropdown { - min-width: 0; - max-height: 170px; -} -</style> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 700d5ec86c8..f3cc359a679 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -65,11 +65,7 @@ export default { polyfillSticky(this.$el); }, methods: { - ...mapActions('diffs', [ - 'setInlineDiffViewType', - 'setParallelDiffViewType', - 'toggleShowTreeList', - ]), + ...mapActions('diffs', ['setInlineDiffViewType', 'setParallelDiffViewType', 'setShowTreeList']), expandAllFiles() { eventHub.$emit(EVT_EXPAND_ALL_FILES); }, @@ -92,7 +88,7 @@ export default { class="gl-mr-3 js-toggle-tree-list" :title="toggleFileBrowserTitle" :selected="showTreeList" - @click="toggleShowTreeList" + @click="setShowTreeList({ showTreeList: !showTreeList })" /> <gl-sprintf v-if="showDropdowns" diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 401064fb18f..f938ea368d8 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -87,7 +87,7 @@ export default { return this.getUserData; }, mappedLines() { - if (this.glFeatures.unifiedDiffLines && this.glFeatures.unifiedDiffComponents) { + if (this.glFeatures.unifiedDiffComponents) { return this.diffLines(this.diffFile, true).map(mapParallel(this)) || []; } @@ -95,9 +95,7 @@ export default { if (this.isInlineView) { return this.diffFile.highlighted_diff_lines.map(mapInline(this)); } - return this.glFeatures.unifiedDiffLines - ? this.diffLines(this.diffFile).map(mapParallel(this)) - : this.diffFile.parallel_diff_lines.map(mapParallel(this)) || []; + return this.diffLines(this.diffFile).map(mapParallel(this)); }, }, updated() { @@ -129,9 +127,7 @@ export default { <template> <div class="diff-content"> <div class="diff-viewer"> - <template - v-if="isTextFile && glFeatures.unifiedDiffLines && glFeatures.unifiedDiffComponents" - > + <template v-if="isTextFile && glFeatures.unifiedDiffComponents"> <diff-view :diff-file="diffFile" :diff-lines="mappedLines" @@ -173,12 +169,16 @@ export default { :a-mode="diffFile.a_mode" :b-mode="diffFile.b_mode" > - <image-diff-overlay - slot="image-overlay" - :discussions="imageDiscussions" - :file-hash="diffFileHash" - :can-comment="getNoteableData.current_user.can_create_note && !diffFile.brokenSymlink" - /> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <image-diff-overlay + v-if="renderedWidth" + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + :discussions="imageDiscussions" + :file-hash="diffFileHash" + :can-comment="getNoteableData.current_user.can_create_note && !diffFile.brokenSymlink" + /> + </template> <div v-if="showNotesContainer" class="note-container"> <user-avatar-link v-if="diffFileCommentForm && author" diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 4c49dfb5de9..2401e12e4f6 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -4,7 +4,7 @@ import { GlIcon } from '@gitlab/ui'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; +import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, INLINE_DIFF_LINES_KEY } from '../constants'; import * as utils from '../store/utils'; const EXPAND_ALL = 0; @@ -14,7 +14,6 @@ const EXPAND_DOWN = 2; const lineNumberByViewType = (viewType, diffLine) => { const numberGetters = { [INLINE_DIFF_VIEW_TYPE]: line => line?.new_line, - [PARALLEL_DIFF_VIEW_TYPE]: line => (line?.right || line?.left)?.new_line, }; const numberGetter = numberGetters[viewType]; return numberGetter && numberGetter(diffLine); @@ -57,9 +56,6 @@ export default { }, computed: { ...mapState({ - diffViewType(state) { - return this.glFeatures.unifiedDiffLines ? INLINE_DIFF_VIEW_TYPE : state.diffs.diffViewType; - }, diffFiles: state => state.diffs.diffFiles, }), canExpandUp() { @@ -77,16 +73,14 @@ export default { ...mapActions('diffs', ['loadMoreLines']), getPrevLineNumber(oldLineNumber, newLineNumber) { const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash); - const lines = { - [INLINE_DIFF_VIEW_TYPE]: diffFile.highlighted_diff_lines, - [PARALLEL_DIFF_VIEW_TYPE]: diffFile.parallel_diff_lines, - }; - const index = utils.getPreviousLineIndex(this.diffViewType, diffFile, { + const index = utils.getPreviousLineIndex(INLINE_DIFF_VIEW_TYPE, diffFile, { oldLineNumber, newLineNumber, }); - return lineNumberByViewType(this.diffViewType, lines[this.diffViewType][index - 2]) || 0; + return ( + lineNumberByViewType(INLINE_DIFF_VIEW_TYPE, diffFile[INLINE_DIFF_LINES_KEY][index - 2]) || 0 + ); }, callLoadMoreLines( endpoint, @@ -113,7 +107,7 @@ export default { this.isRequesting = true; const endpoint = this.contextLinesPath; const { fileHash } = this; - const view = this.diffViewType; + const view = INLINE_DIFF_VIEW_TYPE; const oldLineNumber = this.line.meta_data.old_pos || 0; const newLineNumber = this.line.meta_data.new_pos || 0; const offset = newLineNumber - oldLineNumber; @@ -232,11 +226,11 @@ export default { class="gl-mx-2 gl-cursor-pointer js-unfold-down gl-display-inline-block gl-py-4" @click="handleExpandLines(EXPAND_DOWN)" > - <gl-icon :size="12" name="expand-down" aria-hidden="true" /> + <gl-icon :size="12" name="expand-down" /> <span>{{ $options.i18n.showMore }}</span> </a> <a class="gl-mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()"> - <gl-icon :size="12" name="expand" aria-hidden="true" /> + <gl-icon :size="12" name="expand" /> <span>{{ $options.i18n.showAll }}</span> </a> <a @@ -244,7 +238,7 @@ export default { class="gl-mx-2 gl-cursor-pointer js-unfold gl-display-inline-block gl-py-4" @click="handleExpandLines(EXPAND_UP)" > - <gl-icon :size="12" name="expand-up" aria-hidden="true" /> + <gl-icon :size="12" name="expand-up" /> <span>{{ $options.i18n.showMore }}</span> </a> </div> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 32191d7e309..ed94cabe124 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -10,7 +10,7 @@ import notesEventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; import { diffViewerErrors } from '~/ide/constants'; -import { collapsedType, isCollapsed } from '../diff_file'; +import { collapsedType, isCollapsed } from '../utils/diff_file'; import { DIFF_FILE_AUTOMATIC_COLLAPSE, DIFF_FILE_MANUAL_COLLAPSE, diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 0d99a2e8a60..53d1383b82e 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -19,7 +19,7 @@ import { __, s__, sprintf } from '~/locale'; import { diffViewerModes } from '~/ide/constants'; import DiffStats from './diff_stats.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; -import { isCollapsed } from '../diff_file'; +import { isCollapsed } from '../utils/diff_file'; import { DIFF_FILE_HEADER } from '../i18n'; export default { @@ -221,7 +221,6 @@ export default { ref="collapseIcon" :name="collapseIcon" :size="16" - aria-hidden="true" class="diff-toggle-caret gl-mr-2" @click.stop="handleToggleFile" /> diff --git a/app/assets/javascripts/diffs/components/diff_file_row.vue b/app/assets/javascripts/diffs/components/diff_file_row.vue index 3888eb781fb..6c5d9170c9e 100644 --- a/app/assets/javascripts/diffs/components/diff_file_row.vue +++ b/app/assets/javascripts/diffs/components/diff_file_row.vue @@ -41,10 +41,6 @@ export default { return !this.hideFileStats && this.file.type === 'blob'; }, fileClasses() { - if (!this.glFeatures.highlightCurrentDiffRow) { - return ''; - } - return this.file.type === 'blob' && !this.viewedFiles[this.file.fileHash] ? 'gl-font-weight-bold' : ''; 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 55f5a736cdf..172a2bdde7d 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -7,7 +7,7 @@ import noteForm from '../../notes/components/note_form.vue'; import MultilineCommentForm from '../../notes/components/multiline_comment_form.vue'; import autosave from '../../notes/mixins/autosave'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import { DIFF_NOTE_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; +import { DIFF_NOTE_TYPE, INLINE_DIFF_LINES_KEY, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; import { commentLineOptions, formatLineRange, @@ -102,13 +102,13 @@ export default { }; const getDiffLines = () => { if (this.diffViewType === PARALLEL_DIFF_VIEW_TYPE) { - return (this.glFeatures.unifiedDiffLines - ? this.diffLines(this.diffFile) - : this.diffFile.parallel_diff_lines - ).reduce(combineSides, []); + return this.diffLines(this.diffFile, this.glFeatures.unifiedDiffComponents).reduce( + combineSides, + [], + ); } - return this.diffFile.highlighted_diff_lines; + return this.diffFile[INLINE_DIFF_LINES_KEY]; }; const side = this.line.type === 'new' ? 'right' : 'left'; const lines = getDiffLines(); diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 77a97c67f3b..c0719e2a7d9 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -157,10 +157,10 @@ export default { " /> </div> - <div :class="classNameMapCellLeft" class="diff-td diff-line-num old_line"> + <div v-if="inline" :class="classNameMapCellLeft" class="diff-td diff-line-num old_line"> <a - v-if="line.left.old_line" - :data-linenumber="line.left.old_line" + v-if="line.left.new_line" + :data-linenumber="line.left.new_line" :href="line.lineHrefOld" @click="setHighlightedRow(line.lineCode)" > @@ -179,21 +179,14 @@ export default { </template> <template v-else> <div data-testid="leftEmptyCell" class="diff-td diff-line-num old_line empty-cell"></div> - <div 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"></div> <div class="diff-td line-coverage left-side empty-cell"></div> <div class="diff-td line_content with-coverage parallel left-side empty-cell"></div> </template> </div> - <div - v-if="!inline || (line.right && Boolean(line.right.type))" - class="diff-grid-right right-side" - > + <div v-if="!inline" class="diff-grid-right right-side"> <template v-if="line.right"> - <div - :class="classNameMapCellRight" - data-testid="rightLineNumber" - class="diff-td diff-line-num new_line" - > + <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line"> <span v-if="shouldRenderCommentButton" v-gl-tooltip @@ -231,15 +224,6 @@ export default { " /> </div> - <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line"> - <a - v-if="line.right.new_line" - :data-linenumber="line.right.new_line" - :href="line.lineHrefNew" - @click="setHighlightedRow(line.lineCode)" - > - </a> - </div> <div v-gl-tooltip.hover :title="coverageState.text" diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue index 3956c2fab49..6a1e0d8cbd6 100644 --- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue +++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue @@ -4,6 +4,10 @@ import { isArray } from 'lodash'; import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff'; import { GlIcon } from '@gitlab/ui'; +function calcPercent(pos, size, renderedSize) { + return (((pos / size) * 100) / ((renderedSize / size) * 100)) * 100; +} + export default { name: 'ImageDiffOverlay', components: { @@ -39,6 +43,14 @@ export default { required: false, default: true, }, + renderedWidth: { + type: Number, + required: true, + }, + renderedHeight: { + type: Number, + required: true, + }, }, computed: { ...mapGetters('diffs', ['getDiffFileByHash', 'getCommentFormForDiffFile']), @@ -59,33 +71,33 @@ export default { }, getPositionForObject(meta) { const { x, y, width, height } = meta; - const imageWidth = this.getImageDimensions().width; - const imageHeight = this.getImageDimensions().height; - const widthRatio = imageWidth / width; - const heightRatio = imageHeight / height; return { - x: Math.round(x * widthRatio), - y: Math.round(y * heightRatio), + x: (x / width) * 100, + y: (y / height) * 100, }; }, getPosition(discussion) { const { x, y } = this.getPositionForObject(discussion.position); return { - left: `${x}px`, - top: `${y}px`, + left: `${x}%`, + top: `${y}%`, }; }, clickedImage(x, y) { const { width, height } = this.getImageDimensions(); + const xPercent = calcPercent(x, width, this.renderedWidth); + const yPercent = calcPercent(y, height, this.renderedHeight); this.openDiffFileCommentForm({ fileHash: this.fileHash, width, height, - x, - y, + x: width * (xPercent / 100), + y: height * (yPercent / 100), + xPercent, + yPercent, }); }, }, @@ -112,22 +124,19 @@ export default { type="button" @click="clickedToggle(discussion)" > - <gl-icon v-if="showCommentIcon" name="image-comment-dark" /> + <gl-icon v-if="showCommentIcon" name="image-comment-dark" :size="24" /> <template v-else> {{ toggleText(discussion, index) }} </template> </button> <button - v-if="currentCommentForm" - :style="{ - left: `${currentCommentForm.x}px`, - top: `${currentCommentForm.y}px`, - }" + v-if="canComment && currentCommentForm" + :style="{ left: `${currentCommentForm.xPercent}%`, top: `${currentCommentForm.yPercent}%` }" :aria-label="__('Comment form position')" - class="btn-transparent comment-indicator" + class="btn-transparent comment-indicator position-absolute" type="button" > - <gl-icon name="image-comment-dark" /> + <gl-icon name="image-comment-dark" :size="24" /> </button> </div> </template> diff --git a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue index e47bea8e589..587efd6ed41 100644 --- a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue +++ b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlAlert } from '@gitlab/ui'; +import { GlButton, GlAlert, GlModalDirective } from '@gitlab/ui'; import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants'; export default { @@ -7,6 +7,9 @@ export default { GlAlert, GlButton, }, + directives: { + GlModalDirective, + }, props: { limited: { type: Boolean, @@ -60,9 +63,8 @@ export default { </gl-button> <gl-button v-if="mergeable" + v-gl-modal-directive="'modal-merge-info'" class="gl-alert-action" - data-toggle="modal" - data-target="#modal_merge_info" > {{ __('Merge locally') }} </gl-button> diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue index 78647065c8e..2fe2fd6b3d8 100644 --- a/app/assets/javascripts/diffs/components/settings_dropdown.vue +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -1,23 +1,22 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlButtonGroup, GlButton, GlDropdown } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlButtonGroup, GlButton, GlDropdown, GlFormCheckbox } from '@gitlab/ui'; + +import eventHub from '../event_hub'; +import { EVT_VIEW_FILE_BY_FILE } from '../constants'; +import { SETTINGS_DROPDOWN } from '../i18n'; export default { + i18n: SETTINGS_DROPDOWN, components: { GlButtonGroup, GlButton, GlDropdown, + GlFormCheckbox, }, computed: { ...mapGetters('diffs', ['isInlineView', 'isParallelView']), - ...mapState('diffs', ['renderTreeList', 'showWhitespace']), - }, - mounted() { - this.patchAriaLabel(); - }, - updated() { - this.patchAriaLabel(); + ...mapState('diffs', ['renderTreeList', 'showWhitespace', 'viewDiffsFileByFile']), }, methods: { ...mapActions('diffs', [ @@ -26,17 +25,21 @@ export default { 'setRenderTreeList', 'setShowWhitespace', ]), - patchAriaLabel() { - this.$el - .querySelector('.js-show-diff-settings') - .setAttribute('aria-label', __('Diff view settings')); + toggleFileByFile() { + eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting: !this.viewDiffsFileByFile }); }, }, }; </script> <template> - <gl-dropdown icon="settings" toggle-class="js-show-diff-settings" right> + <gl-dropdown + icon="settings" + :text="__('Diff view settings')" + :text-sr-only="true" + toggle-class="js-show-diff-settings" + right + > <div class="gl-px-3"> <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('File browser') }}</span> <gl-button-group class="gl-display-flex"> @@ -90,5 +93,15 @@ export default { {{ __('Show whitespace changes') }} </label> </div> + <div class="gl-mt-3 gl-px-3"> + <gl-form-checkbox + data-testid="file-by-file" + class="gl-mb-0" + :checked="viewDiffsFileByFile" + @input="toggleFileByFile" + > + {{ $options.i18n.fileByFile }} + </gl-form-checkbox> + </div> </gl-dropdown> </template> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 79f8c08e389..07e27bd8e47 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -77,6 +77,11 @@ export const ALERT_COLLAPSED_FILES = 'collapsed'; export const DIFF_FILE_AUTOMATIC_COLLAPSE = 'automatic'; export const DIFF_FILE_MANUAL_COLLAPSE = 'manual'; +// Diff view single file mode +export const DIFF_FILE_BY_FILE_COOKIE_NAME = 'fileViewMode'; +export const DIFF_VIEW_FILE_BY_FILE = 'single'; +export const DIFF_VIEW_ALL_FILES = 'all'; + // State machine states export const STATE_IDLING = 'idle'; export const STATE_LOADING = 'loading'; @@ -98,6 +103,7 @@ export const RENAMED_DIFF_TRANSITIONS = { // MR Diffs known events export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles'; +export const EVT_VIEW_FILE_BY_FILE = 'mr:diffs:preference:fileByFile'; export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart'; export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd'; export const EVT_PERF_MARK_DIFF_FILES_START = 'mr:diffs:perf:filesStart'; diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index 4ec24d452bf..c4ac99ead91 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -16,3 +16,7 @@ export const DIFF_FILE = { autoCollapsed: __('Files with large changes are collapsed by default.'), expand: __('Expand file'), }; + +export const SETTINGS_DROPDOWN = { + fileByFile: __('Show one file at a time'), +}; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 06a138b1e13..587220488be 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -116,7 +116,7 @@ export default function initDiffsApp(store) { isFluidLayout: this.isFluidLayout, dismissEndpoint: this.dismissEndpoint, showSuggestPopover: this.showSuggestPopover, - viewDiffsFileByFile: this.viewDiffsFileByFile, + fileByFileUserPreference: this.viewDiffsFileByFile, }, }); }, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 91c4c51487f..5b410051705 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -30,13 +30,11 @@ import { OLD_LINE_KEY, NEW_LINE_KEY, TYPE_KEY, - LEFT_LINE_KEY, MAX_RENDERING_DIFF_LINES, MAX_RENDERING_BULK_ROWS, MIN_RENDERING_MS, START_RENDERING_INDEX, INLINE_DIFF_LINES_KEY, - PARALLEL_DIFF_LINES_KEY, DIFFS_PER_PAGE, DIFF_WHITESPACE_COOKIE_NAME, SHOW_WHITESPACE, @@ -46,9 +44,12 @@ import { EVT_PERF_MARK_FILE_TREE_START, EVT_PERF_MARK_FILE_TREE_END, EVT_PERF_MARK_DIFF_FILES_START, + DIFF_VIEW_FILE_BY_FILE, + DIFF_VIEW_ALL_FILES, + DIFF_FILE_BY_FILE_COOKIE_NAME, } from '../constants'; import { diffViewerModes } from '~/ide/constants'; -import { isCollapsed } from '../diff_file'; +import { isCollapsed } from '../utils/diff_file'; export const setBaseConfig = ({ commit }, options) => { const { @@ -59,6 +60,7 @@ export const setBaseConfig = ({ commit }, options) => { projectPath, dismissEndpoint, showSuggestPopover, + viewDiffsFileByFile, } = options; commit(types.SET_BASE_CONFIG, { endpoint, @@ -68,26 +70,38 @@ export const setBaseConfig = ({ commit }, options) => { projectPath, dismissEndpoint, showSuggestPopover, + viewDiffsFileByFile, }); }; export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { + const diffsGradualLoad = window.gon?.features?.diffsGradualLoad; + let perPage = DIFFS_PER_PAGE; + let increaseAmount = 1.4; + + if (diffsGradualLoad) { + perPage = state.viewDiffsFileByFile ? 1 : 5; + } + + const startPage = diffsGradualLoad ? 0 : 1; const id = window?.location?.hash; const isNoteLink = id.indexOf('#note') === 0; const urlParams = { - per_page: DIFFS_PER_PAGE, w: state.showWhitespace ? '0' : '1', - view: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType, + view: 'inline', }; + let totalLoaded = 0; commit(types.SET_BATCH_LOADING, true); commit(types.SET_RETRIEVING_BATCHES, true); eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START); - const getBatch = (page = 1) => + const getBatch = (page = startPage) => axios - .get(mergeUrlParams({ ...urlParams, page }, state.endpointBatch)) + .get(mergeUrlParams({ ...urlParams, page, per_page: perPage }, state.endpointBatch)) .then(({ data: { pagination, diff_files } }) => { + totalLoaded += diff_files.length; + commit(types.SET_DIFF_DATA_BATCH, { diff_files }); commit(types.SET_BATCH_LOADING, false); @@ -99,7 +113,11 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { dispatch('setCurrentDiffFileIdFromNote', id.split('_').pop()); } - if (!pagination.next_page) { + if ( + (diffsGradualLoad && + (totalLoaded === pagination.total_pages || pagination.total_pages === null)) || + (!diffsGradualLoad && !pagination.next_page) + ) { commit(types.SET_RETRIEVING_BATCHES, false); // We need to check that the currentDiffFileId points to a file that exists @@ -125,6 +143,16 @@ 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; } return pagination.next_page; @@ -140,7 +168,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { export const fetchDiffFilesMeta = ({ commit, state }) => { const worker = new TreeWorker(); const urlParams = { - view: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType, + view: 'inline', }; commit(types.SET_LOADING, true); @@ -157,13 +185,19 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { .get(mergeUrlParams(urlParams, state.endpointMetadata)) .then(({ data }) => { const strippedData = { ...data }; - delete strippedData.diff_files; + commit(types.SET_LOADING, false); commit(types.SET_MERGE_REQUEST_DIFFS, data.merge_request_diffs || []); - commit(types.SET_DIFF_DATA, strippedData); + commit(types.SET_DIFF_METADATA, strippedData); - worker.postMessage(prepareDiffData(data, state.diffFiles)); + worker.postMessage( + prepareDiffData({ + diff: data, + priorFiles: state.diffFiles, + meta: true, + }), + ); return data; }) @@ -401,15 +435,10 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => { export const toggleFileDiscussionWrappers = ({ commit }, diff) => { const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff); const lineCodesWithDiscussions = new Set(); - const { parallel_diff_lines: parallelLines, highlighted_diff_lines: inlineLines } = diff; - const allLines = inlineLines.concat( - parallelLines.map(line => line.left), - parallelLines.map(line => line.right), - ); const lineHasDiscussion = line => Boolean(line?.discussions.length); const registerDiscussionLine = line => lineCodesWithDiscussions.add(line.line_code); - allLines.filter(lineHasDiscussion).forEach(registerDiscussionLine); + diff[INLINE_DIFF_LINES_KEY].filter(lineHasDiscussion).forEach(registerDiscussionLine); if (lineCodesWithDiscussions.size) { Array.from(lineCodesWithDiscussions).forEach(lineCode => { @@ -454,11 +483,11 @@ export const scrollToFile = ({ state, commit }, path) => { commit(types.VIEW_DIFF_FILE, fileHash); }; -export const toggleShowTreeList = ({ commit, state }, saving = true) => { - commit(types.TOGGLE_SHOW_TREE_LIST); +export const setShowTreeList = ({ commit }, { showTreeList, saving = true }) => { + commit(types.SET_SHOW_TREE_LIST, showTreeList); if (saving) { - localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList); + localStorage.setItem(MR_TREE_SHOW_KEY, showTreeList); } }; @@ -508,61 +537,26 @@ export const receiveFullDiffError = ({ commit }, filePath) => { createFlash(s__('MergeRequest|Error loading full diff. Please try again.')); }; -export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { - const expandedDiffLines = { - highlighted_diff_lines: convertExpandLines({ - diffLines: file.highlighted_diff_lines, - typeKey: TYPE_KEY, - oldLineKey: OLD_LINE_KEY, - newLineKey: NEW_LINE_KEY, - data, - mapLine: ({ line, oldLine, newLine }) => - Object.assign(line, { - old_line: oldLine, - new_line: newLine, - line_code: `${file.file_hash}_${oldLine}_${newLine}`, - }), - }), - parallel_diff_lines: convertExpandLines({ - diffLines: file.parallel_diff_lines, - typeKey: [LEFT_LINE_KEY, TYPE_KEY], - oldLineKey: [LEFT_LINE_KEY, OLD_LINE_KEY], - newLineKey: [LEFT_LINE_KEY, NEW_LINE_KEY], - data, - mapLine: ({ line, oldLine, newLine }) => ({ - left: { - ...line, - old_line: oldLine, - line_code: `${file.file_hash}_${oldLine}_${newLine}`, - }, - right: { - ...line, - new_line: newLine, - line_code: `${file.file_hash}_${newLine}_${oldLine}`, - }, +export const setExpandedDiffLines = ({ commit }, { file, data }) => { + const expandedDiffLines = convertExpandLines({ + diffLines: file[INLINE_DIFF_LINES_KEY], + typeKey: TYPE_KEY, + oldLineKey: OLD_LINE_KEY, + newLineKey: NEW_LINE_KEY, + data, + mapLine: ({ line, oldLine, newLine }) => + Object.assign(line, { + old_line: oldLine, + new_line: newLine, + line_code: `${file.file_hash}_${oldLine}_${newLine}`, }), - }), - }; - const unifiedDiffLinesEnabled = window.gon?.features?.unifiedDiffLines; - const currentDiffLinesKey = - state.diffViewType === INLINE_DIFF_VIEW_TYPE || unifiedDiffLinesEnabled - ? INLINE_DIFF_LINES_KEY - : PARALLEL_DIFF_LINES_KEY; - const hiddenDiffLinesKey = - state.diffViewType === INLINE_DIFF_VIEW_TYPE ? PARALLEL_DIFF_LINES_KEY : INLINE_DIFF_LINES_KEY; - - if (!unifiedDiffLinesEnabled) { - commit(types.SET_HIDDEN_VIEW_DIFF_FILE_LINES, { - filePath: file.file_path, - lines: expandedDiffLines[hiddenDiffLinesKey], - }); - } + }); - if (expandedDiffLines[currentDiffLinesKey].length > MAX_RENDERING_DIFF_LINES) { + if (expandedDiffLines.length > MAX_RENDERING_DIFF_LINES) { let index = START_RENDERING_INDEX; commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: file.file_path, - lines: expandedDiffLines[currentDiffLinesKey].slice(0, index), + lines: expandedDiffLines.slice(0, index), }); commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path); @@ -571,10 +565,10 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { while ( t.timeRemaining() >= MIN_RENDERING_MS && - index !== expandedDiffLines[currentDiffLinesKey].length && + index !== expandedDiffLines.length && index - startIndex !== MAX_RENDERING_BULK_ROWS ) { - const line = expandedDiffLines[currentDiffLinesKey][index]; + const line = expandedDiffLines[index]; if (line) { commit(types.ADD_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: file.file_path, line }); @@ -582,7 +576,7 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { } } - if (index !== expandedDiffLines[currentDiffLinesKey].length) { + if (index !== expandedDiffLines.length) { idleCallback(idleCb); } else { commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path); @@ -593,7 +587,7 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { } else { commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: file.file_path, - lines: expandedDiffLines[currentDiffLinesKey], + lines: expandedDiffLines, }); } }; @@ -627,7 +621,7 @@ export const toggleFullDiff = ({ dispatch, commit, getters, state }, filePath) = } }; -export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { diffFile }) { +export function switchToFullDiffFromRenamedFile({ commit, dispatch }, { diffFile }) { return axios .get(diffFile.context_lines_path, { params: { @@ -638,7 +632,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d .then(({ data }) => { const lines = data.map((line, index) => prepareLineForRenamedFile({ - diffViewType: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType, + diffViewType: 'inline', line, diffFile, index, @@ -736,3 +730,14 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => { commit(types.VIEW_DIFF_FILE, fileHash); }; + +export const setFileByFile = ({ commit }, { fileByFile }) => { + const fileViewMode = fileByFile ? DIFF_VIEW_FILE_BY_FILE : DIFF_VIEW_ALL_FILES; + commit(types.SET_FILE_BY_FILE, fileByFile); + + Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode); + + historyPushState( + mergeUrlParams({ [DIFF_FILE_BY_FILE_COOKIE_NAME]: fileViewMode }, window.location.href), + ); +}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 9ee73998177..baf54188932 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -1,6 +1,10 @@ import { __, n__ } from '~/locale'; import { parallelizeDiffLines } from './utils'; -import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants'; +import { + PARALLEL_DIFF_VIEW_TYPE, + INLINE_DIFF_VIEW_TYPE, + INLINE_DIFF_LINES_KEY, +} from '../constants'; export * from './getters_versions_dropdowns'; @@ -54,24 +58,10 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => diff => { * @param {Object} diff * @returns {Boolean} */ -export const diffHasExpandedDiscussions = state => diff => { - const lines = { - [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [], - [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => { - if (line.left) { - acc.push(line.left); - } - - if (line.right) { - acc.push(line.right); - } - - return acc; - }, []), - }; - return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType] - .filter(l => l.discussions.length >= 1) - .some(l => l.discussionsExpanded); +export const diffHasExpandedDiscussions = () => diff => { + return diff[INLINE_DIFF_LINES_KEY].filter(l => l.discussions.length >= 1).some( + l => l.discussionsExpanded, + ); }; /** @@ -79,24 +69,8 @@ export const diffHasExpandedDiscussions = state => diff => { * @param {Boolean} diff * @returns {Boolean} */ -export const diffHasDiscussions = state => diff => { - const lines = { - [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [], - [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => { - if (line.left) { - acc.push(line.left); - } - - if (line.right) { - acc.push(line.right); - } - - return acc; - }, []), - }; - return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType].some( - l => l.discussions.length >= 1, - ); +export const diffHasDiscussions = () => diff => { + return diff[INLINE_DIFF_LINES_KEY].some(l => l.discussions.length >= 1); }; /** diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 001d9d9f594..c331e52c887 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -5,6 +5,8 @@ import { DIFF_VIEW_COOKIE_NAME, DIFF_WHITESPACE_COOKIE_NAME, } from '../../constants'; + +import { fileByFile } from '../../utils/preferences'; import { getDefaultWhitespace } from '../utils'; const viewTypeFromQueryString = getParameterValues('view')[0]; @@ -39,6 +41,7 @@ export default () => ({ highlightedRow: null, renderTreeList: true, showWhitespace: getDefaultWhitespace(whiteSpaceFromQueryString, whiteSpaceFromCookie), + viewDiffsFileByFile: fileByFile(), fileFinderVisible: false, dismissEndpoint: '', showSuggestPopover: true, diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 19a9e65edc9..30097239aaa 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -3,7 +3,7 @@ export const SET_LOADING = 'SET_LOADING'; export const SET_BATCH_LOADING = 'SET_BATCH_LOADING'; export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES'; -export const SET_DIFF_DATA = 'SET_DIFF_DATA'; +export const SET_DIFF_METADATA = 'SET_DIFF_METADATA'; export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH'; export const SET_DIFF_FILES = 'SET_DIFF_FILES'; @@ -17,7 +17,7 @@ export const RENDER_FILE = 'RENDER_FILE'; export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE'; export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE'; export const TOGGLE_FOLDER_OPEN = 'TOGGLE_FOLDER_OPEN'; -export const TOGGLE_SHOW_TREE_LIST = 'TOGGLE_SHOW_TREE_LIST'; +export const SET_SHOW_TREE_LIST = 'SET_SHOW_TREE_LIST'; export const VIEW_DIFF_FILE = 'VIEW_DIFF_FILE'; export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM'; @@ -28,6 +28,7 @@ export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW'; export const SET_TREE_DATA = 'SET_TREE_DATA'; export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST'; export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE'; +export const SET_FILE_BY_FILE = 'SET_FILE_BY_FILE'; export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE'; export const REQUEST_FULL_DIFF = 'REQUEST_FULL_DIFF'; @@ -35,7 +36,6 @@ export const RECEIVE_FULL_DIFF_SUCCESS = 'RECEIVE_FULL_DIFF_SUCCESS'; export const RECEIVE_FULL_DIFF_ERROR = 'RECEIVE_FULL_DIFF_ERROR'; export const SET_FILE_COLLAPSED = 'SET_FILE_COLLAPSED'; -export const SET_HIDDEN_VIEW_DIFF_FILE_LINES = 'SET_HIDDEN_VIEW_DIFF_FILE_LINES'; export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINES'; export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES'; export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 096c4f69439..19122c3096f 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -1,11 +1,6 @@ import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { - DIFF_FILE_MANUAL_COLLAPSE, - DIFF_FILE_AUTOMATIC_COLLAPSE, - INLINE_DIFF_VIEW_TYPE, -} from '../constants'; -import { findDiffFile, addLineReferences, removeMatchLine, @@ -14,6 +9,11 @@ import { isDiscussionApplicableToLine, updateLineInFile, } from './utils'; +import { + DIFF_FILE_MANUAL_COLLAPSE, + DIFF_FILE_AUTOMATIC_COLLAPSE, + INLINE_DIFF_LINES_KEY, +} from '../constants'; import * as types from './mutation_types'; function updateDiffFilesInState(state, files) { @@ -36,6 +36,7 @@ export default { projectPath, dismissEndpoint, showSuggestPopover, + viewDiffsFileByFile, } = options; Object.assign(state, { endpoint, @@ -45,6 +46,7 @@ export default { projectPath, dismissEndpoint, showSuggestPopover, + viewDiffsFileByFile, }); }, @@ -64,21 +66,17 @@ export default { updateDiffFilesInState(state, files); }, - [types.SET_DIFF_DATA](state, data) { - let files = state.diffFiles; - - if (window.location.search.indexOf('diff_id') !== -1 && data.diff_files) { - files = prepareDiffData(data, files); - } - + [types.SET_DIFF_METADATA](state, data) { Object.assign(state, { ...convertObjectPropsToCamelCase(data), }); - updateDiffFilesInState(state, files); }, [types.SET_DIFF_DATA_BATCH](state, data) { - const files = prepareDiffData(data, state.diffFiles); + const files = prepareDiffData({ + diff: data, + priorFiles: state.diffFiles, + }); Object.assign(state, { ...convertObjectPropsToCamelCase(data), @@ -109,25 +107,7 @@ export default { if (!diffFile) return; - if (diffFile.highlighted_diff_lines.length) { - diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm; - } - - if (diffFile.parallel_diff_lines.length) { - const line = diffFile.parallel_diff_lines.find(l => { - const { left, right } = l; - - return (left && left.line_code === lineCode) || (right && right.line_code === lineCode); - }); - - if (line.left && line.left.line_code === lineCode) { - line.left.hasForm = hasForm; - } - - if (line.right && line.right.line_code === lineCode) { - line.right.hasForm = hasForm; - } - } + diffFile[INLINE_DIFF_LINES_KEY].find(l => l.line_code === lineCode).hasForm = hasForm; }, [types.ADD_CONTEXT_LINES](state, options) { @@ -157,11 +137,7 @@ export default { }); addContextLines({ - inlineLines: diffFile.highlighted_diff_lines, - parallelLines: diffFile.parallel_diff_lines, - diffViewType: window.gon?.features?.unifiedDiffLines - ? INLINE_DIFF_VIEW_TYPE - : state.diffViewType, + inlineLines: diffFile[INLINE_DIFF_LINES_KEY], contextLines: lines, bottom, lineNumbers, @@ -170,7 +146,7 @@ export default { }, [types.ADD_COLLAPSED_DIFFS](state, { file, data }) { - const files = prepareDiffData(data); + const files = prepareDiffData({ diff: data }); const [newFileData] = files.filter(f => f.file_hash === file.file_hash); const selectedFile = state.diffFiles.find(f => f.file_hash === file.file_hash); Object.assign(selectedFile, { ...newFileData }); @@ -219,8 +195,8 @@ export default { state.diffFiles.forEach(file => { if (file.file_hash === fileHash) { - if (file.highlighted_diff_lines.length) { - file.highlighted_diff_lines.forEach(line => { + if (file[INLINE_DIFF_LINES_KEY].length) { + file[INLINE_DIFF_LINES_KEY].forEach(line => { Object.assign( line, setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), @@ -228,25 +204,7 @@ export default { }); } - if (file.parallel_diff_lines.length) { - file.parallel_diff_lines.forEach(line => { - const left = line.left && lineCheck(line.left); - const right = line.right && lineCheck(line.right); - - if (left || right) { - Object.assign(line, { - left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null, - right: line.right - ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left)) - : null, - }); - } - - return line; - }); - } - - if (!file.parallel_diff_lines.length || !file.highlighted_diff_lines.length) { + if (!file[INLINE_DIFF_LINES_KEY].length) { const newDiscussions = (file.discussions || []) .filter(d => d.id !== discussion.id) .concat(discussion); @@ -287,8 +245,8 @@ export default { [types.TOGGLE_FOLDER_OPEN](state, path) { state.treeEntries[path].opened = !state.treeEntries[path].opened; }, - [types.TOGGLE_SHOW_TREE_LIST](state) { - state.showTreeList = !state.showTreeList; + [types.SET_SHOW_TREE_LIST](state, showTreeList) { + state.showTreeList = showTreeList; }, [types.VIEW_DIFF_FILE](state, fileId) { state.currentDiffFileId = fileId; @@ -369,31 +327,15 @@ export default { renderFile(file); } }, - [types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { - const file = state.diffFiles.find(f => f.file_path === filePath); - const hiddenDiffLinesKey = - state.diffViewType === 'inline' ? 'parallel_diff_lines' : 'highlighted_diff_lines'; - - file[hiddenDiffLinesKey] = lines; - }, [types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { const file = state.diffFiles.find(f => f.file_path === filePath); - let currentDiffLinesKey; - if (window.gon?.features?.unifiedDiffLines || state.diffViewType === 'inline') { - currentDiffLinesKey = 'highlighted_diff_lines'; - } else { - currentDiffLinesKey = 'parallel_diff_lines'; - } - - file[currentDiffLinesKey] = lines; + file[INLINE_DIFF_LINES_KEY] = lines; }, [types.ADD_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, line }) { const file = state.diffFiles.find(f => f.file_path === filePath); - const currentDiffLinesKey = - state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines'; - file[currentDiffLinesKey].push(line); + file[INLINE_DIFF_LINES_KEY].push(line); }, [types.TOGGLE_DIFF_FILE_RENDERING_MORE](state, filePath) { const file = state.diffFiles.find(f => f.file_path === filePath); @@ -408,4 +350,7 @@ export default { [types.SET_SHOW_SUGGEST_POPOVER](state) { state.showSuggestPopover = false; }, + [types.SET_FILE_BY_FILE](state, fileByFile) { + state.viewDiffsFileByFile = fileByFile; + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index f87f57c32c3..1839df12c96 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -12,12 +12,11 @@ import { MATCH_LINE_TYPE, LINES_TO_BE_RENDERED_DIRECTLY, TREE_TYPE, - INLINE_DIFF_VIEW_TYPE, - PARALLEL_DIFF_VIEW_TYPE, + INLINE_DIFF_LINES_KEY, SHOW_WHITESPACE, NO_SHOW_WHITESPACE, } from '../constants'; -import { prepareRawDiffFile } from '../diff_file'; +import { prepareRawDiffFile } from '../utils/diff_file'; export const isAdded = line => ['new', 'new-nonewline'].includes(line.type); export const isRemoved = line => ['old', 'old-nonewline'].includes(line.type); @@ -48,7 +47,7 @@ export const parallelizeDiffLines = (diffLines, inline) => { for (let i = 0, diffLinesLength = diffLines.length, index = 0; i < diffLinesLength; i += 1) { const line = diffLines[i]; - if (isRemoved(line)) { + if (isRemoved(line) || inline) { lines.push({ [LINE_POSITION_LEFT]: line, [LINE_POSITION_RIGHT]: null, @@ -60,7 +59,7 @@ export const parallelizeDiffLines = (diffLines, inline) => { } index += 1; } else if (isAdded(line)) { - if (freeRightIndex !== null && !inline) { + if (freeRightIndex !== null) { // If an old line came before this without a line on the right, this // line can be put to the right of it. lines[freeRightIndex].right = line; @@ -178,43 +177,16 @@ export const findIndexInInlineLines = (lines, lineNumbers) => { ); }; -export const findIndexInParallelLines = (lines, lineNumbers) => { - const { oldLineNumber, newLineNumber } = lineNumbers; - - return lines.findIndex( - line => - line.left && - line.right && - line.left.old_line === oldLineNumber && - line.right.new_line === newLineNumber, - ); -}; - -const indexGettersByViewType = { - [INLINE_DIFF_VIEW_TYPE]: findIndexInInlineLines, - [PARALLEL_DIFF_VIEW_TYPE]: findIndexInParallelLines, -}; - export const getPreviousLineIndex = (diffViewType, file, lineNumbers) => { - const findIndex = indexGettersByViewType[diffViewType]; - const lines = { - [INLINE_DIFF_VIEW_TYPE]: file.highlighted_diff_lines, - [PARALLEL_DIFF_VIEW_TYPE]: file.parallel_diff_lines, - }; - - return findIndex && findIndex(lines[diffViewType], lineNumbers); + return findIndexInInlineLines(file[INLINE_DIFF_LINES_KEY], lineNumbers); }; export function removeMatchLine(diffFile, lineNumbers, bottom) { - const indexForInline = findIndexInInlineLines(diffFile.highlighted_diff_lines, lineNumbers); - const indexForParallel = findIndexInParallelLines(diffFile.parallel_diff_lines, lineNumbers); + const indexForInline = findIndexInInlineLines(diffFile[INLINE_DIFF_LINES_KEY], lineNumbers); const factor = bottom ? 1 : -1; if (indexForInline > -1) { - diffFile.highlighted_diff_lines.splice(indexForInline + factor, 1); - } - if (indexForParallel > -1) { - diffFile.parallel_diff_lines.splice(indexForParallel + factor, 1); + diffFile[INLINE_DIFF_LINES_KEY].splice(indexForInline + factor, 1); } } @@ -257,24 +229,6 @@ export function addLineReferences(lines, lineNumbers, bottom, isExpandDown, next return linesWithNumbers; } -function addParallelContextLines(options) { - const { parallelLines, contextLines, lineNumbers, isExpandDown } = options; - const normalizedParallelLines = contextLines.map(line => ({ - left: line, - right: line, - line_code: line.line_code, - })); - const factor = isExpandDown ? 1 : 0; - - if (!isExpandDown && options.bottom) { - parallelLines.push(...normalizedParallelLines); - } else { - const parallelIndex = findIndexInParallelLines(parallelLines, lineNumbers); - - parallelLines.splice(parallelIndex + factor, 0, ...normalizedParallelLines); - } -} - function addInlineContextLines(options) { const { inlineLines, contextLines, lineNumbers, isExpandDown } = options; const factor = isExpandDown ? 1 : 0; @@ -289,16 +243,7 @@ function addInlineContextLines(options) { } export function addContextLines(options) { - const { diffViewType } = options; - const contextLineHandlers = { - [INLINE_DIFF_VIEW_TYPE]: addInlineContextLines, - [PARALLEL_DIFF_VIEW_TYPE]: addParallelContextLines, - }; - const contextLineHandler = contextLineHandlers[diffViewType]; - - if (contextLineHandler) { - contextLineHandler(options); - } + addInlineContextLines(options); } /** @@ -324,41 +269,29 @@ export function trimFirstCharOfLineContent(line = {}) { return parsedLine; } -function getLineCode({ left, right }, index) { - if (left && left.line_code) { - return left.line_code; - } else if (right && right.line_code) { - return right.line_code; - } - return index; -} - function diffFileUniqueId(file) { return `${file.content_sha}-${file.file_hash}`; } function mergeTwoFiles(target, source) { - const originalInline = target.highlighted_diff_lines; - const originalParallel = target.parallel_diff_lines; + const originalInline = target[INLINE_DIFF_LINES_KEY]; const missingInline = !originalInline.length; - const missingParallel = !originalParallel.length; return { ...target, - highlighted_diff_lines: missingInline ? source.highlighted_diff_lines : originalInline, - parallel_diff_lines: missingParallel ? source.parallel_diff_lines : originalParallel, + [INLINE_DIFF_LINES_KEY]: missingInline ? source[INLINE_DIFF_LINES_KEY] : originalInline, + parallel_diff_lines: null, renderIt: source.renderIt, collapsed: source.collapsed, }; } function ensureBasicDiffFileLines(file) { - const missingInline = !file.highlighted_diff_lines; - const missingParallel = !file.parallel_diff_lines || window.gon?.features?.unifiedDiffLines; + const missingInline = !file[INLINE_DIFF_LINES_KEY]; Object.assign(file, { - highlighted_diff_lines: missingInline ? [] : file.highlighted_diff_lines, - parallel_diff_lines: missingParallel ? [] : file.parallel_diff_lines, + [INLINE_DIFF_LINES_KEY]: missingInline ? [] : file[INLINE_DIFF_LINES_KEY], + parallel_diff_lines: null, }); return file; @@ -382,7 +315,7 @@ function prepareLine(line, file) { } } -export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index = 0 }) { +export function prepareLineForRenamedFile({ line, diffFile, index = 0 }) { /* Renamed files are a little different than other diffs, which is why this is distinct from `prepareDiffFileLines` below. @@ -407,48 +340,23 @@ export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index prepareLine(cleanLine, diffFile); // WARNING: In-Place Mutations! - if (diffViewType === PARALLEL_DIFF_VIEW_TYPE) { - return { - left: { ...cleanLine }, - right: { ...cleanLine }, - line_code: cleanLine.line_code, - }; - } - return cleanLine; } function prepareDiffFileLines(file) { - const inlineLines = file.highlighted_diff_lines; - const parallelLines = file.parallel_diff_lines; - let parallelLinesCount = 0; + const inlineLines = file[INLINE_DIFF_LINES_KEY]; inlineLines.forEach(line => prepareLine(line, file)); // WARNING: In-Place Mutations! - parallelLines.forEach((line, index) => { - Object.assign(line, { line_code: getLineCode(line, index) }); - - if (line.left) { - parallelLinesCount += 1; - prepareLine(line.left, file); // WARNING: In-Place Mutations! - } - - if (line.right) { - parallelLinesCount += 1; - prepareLine(line.right, file); // WARNING: In-Place Mutations! - } - }); - Object.assign(file, { inlineLinesCount: inlineLines.length, - parallelLinesCount, }); return file; } function getVisibleDiffLines(file) { - return Math.max(file.inlineLinesCount, file.parallelLinesCount); + return file.inlineLinesCount; } function finalizeDiffFile(file) { @@ -478,9 +386,9 @@ function deduplicateFilesList(files) { return Object.values(dedupedFiles); } -export function prepareDiffData(diff, priorFiles = []) { +export function prepareDiffData({ diff, priorFiles = [], meta = false }) { const cleanedFiles = (diff.diff_files || []) - .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles })) + .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta })) .map(ensureBasicDiffFileLines) .map(prepareDiffFileLines) .map(finalizeDiffFile); @@ -490,43 +398,14 @@ export function prepareDiffData(diff, priorFiles = []) { export function getDiffPositionByLineCode(diffFiles) { let lines = []; - const hasInlineDiffs = diffFiles.some(file => file.highlighted_diff_lines.length > 0); - - if (hasInlineDiffs) { - // In either of these cases, we can use `highlighted_diff_lines` because - // that will include all of the parallel diff lines, too - - lines = diffFiles.reduce((acc, diffFile) => { - diffFile.highlighted_diff_lines.forEach(line => { - acc.push({ file: diffFile, line }); - }); - - return acc; - }, []); - } else { - // If we're in single diff view mode and the inline lines haven't been - // loaded yet, we need to parse the parallel lines - - lines = diffFiles.reduce((acc, diffFile) => { - diffFile.parallel_diff_lines.forEach(pair => { - // It's possible for a parallel line to have an opposite line that doesn't exist - // For example: *deleted* lines will have `null` right lines, while - // *added* lines will have `null` left lines. - // So we have to check each line before we push it onto the array so we're not - // pushing null line diffs - - if (pair.left) { - acc.push({ file: diffFile, line: pair.left }); - } - if (pair.right) { - acc.push({ file: diffFile, line: pair.right }); - } - }); + lines = diffFiles.reduce((acc, diffFile) => { + diffFile[INLINE_DIFF_LINES_KEY].forEach(line => { + acc.push({ file: diffFile, line }); + }); - return acc; - }, []); - } + return acc; + }, []); return lines.reduce((acc, { file, line }) => { if (line.line_code) { @@ -739,24 +618,10 @@ export const convertExpandLines = ({ export const idleCallback = cb => requestIdleCallback(cb); function getLinesFromFileByLineCode(file, lineCode) { - const parallelLines = file.parallel_diff_lines; - const inlineLines = file.highlighted_diff_lines; + const inlineLines = file[INLINE_DIFF_LINES_KEY]; const matchesCode = line => line.line_code === lineCode; - return [ - ...parallelLines.reduce((acc, line) => { - if (line.left) { - acc.push(line.left); - } - - if (line.right) { - acc.push(line.right); - } - - return acc; - }, []), - ...inlineLines, - ].filter(matchesCode); + return inlineLines.filter(matchesCode); } export const updateLineInFile = (selectedFile, lineCode, updateFn) => { @@ -771,12 +636,7 @@ export const allDiscussionWrappersExpanded = diff => { } }; - diff.parallel_diff_lines.forEach(line => { - changeExpandedResult(line.left); - changeExpandedResult(line.right); - }); - - diff.highlighted_diff_lines.forEach(line => { + diff[INLINE_DIFF_LINES_KEY].forEach(line => { changeExpandedResult(line); }); diff --git a/app/assets/javascripts/diffs/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js index a14a30b41a9..69d0e49e501 100644 --- a/app/assets/javascripts/diffs/diff_file.js +++ b/app/assets/javascripts/diffs/utils/diff_file.js @@ -3,7 +3,8 @@ import { DIFF_FILE_DELETED_MODE, DIFF_FILE_MANUAL_COLLAPSE, DIFF_FILE_AUTOMATIC_COLLAPSE, -} from './constants'; +} from '../constants'; +import { uuids } from './uuids'; function fileSymlinkInformation(file, fileList) { const duplicates = fileList.filter(iteratedFile => iteratedFile.file_hash === file.file_hash); @@ -32,16 +33,29 @@ function collapsed(file) { }; } -export function prepareRawDiffFile({ file, allFiles }) { - Object.assign(file, { +function identifier(file) { + return uuids({ + seeds: [file.file_identifier_hash, file.blob?.id], + })[0]; +} + +export function prepareRawDiffFile({ file, allFiles, meta = false }) { + const additionalProperties = { brokenSymlink: fileSymlinkInformation(file, allFiles), viewer: { ...file.viewer, ...collapsed(file), }, - }); + }; + + // It's possible, but not confirmed, that `content_sha` isn't available sometimes + // See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49506#note_464692057 + // We don't want duplicate IDs if that's the case, so we just don't assign an ID + if (!meta && file.blob?.id) { + additionalProperties.id = identifier(file); + } - return file; + return Object.assign(file, additionalProperties); } export function collapsedType(file) { diff --git a/app/assets/javascripts/diffs/utils/preferences.js b/app/assets/javascripts/diffs/utils/preferences.js new file mode 100644 index 00000000000..e440de3350a --- /dev/null +++ b/app/assets/javascripts/diffs/utils/preferences.js @@ -0,0 +1,22 @@ +import Cookies from 'js-cookie'; +import { getParameterValues } from '~/lib/utils/url_utility'; + +import { DIFF_FILE_BY_FILE_COOKIE_NAME, DIFF_VIEW_FILE_BY_FILE } from '../constants'; + +export function fileByFile(pref = false) { + const search = getParameterValues(DIFF_FILE_BY_FILE_COOKIE_NAME)?.[0]; + const cookie = Cookies.get(DIFF_FILE_BY_FILE_COOKIE_NAME); + let viewFileByFile = pref; + + // use the cookie first, if it exists + if (cookie) { + viewFileByFile = cookie === DIFF_VIEW_FILE_BY_FILE; + } + + // the search parameter of the URL should override, if it exists + if (search) { + viewFileByFile = search === DIFF_VIEW_FILE_BY_FILE; + } + + return viewFileByFile; +} diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 5674cc8495d..ffb5232ca75 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -119,20 +119,18 @@ class DueDateSelect { } updateIssueBoardIssue() { - // eslint-disable-next-line no-jquery/no-fade - this.$loading.fadeIn(); + this.$loading.removeClass('gl-display-none'); this.$dropdown.trigger('loading.gl.dropdown'); this.$selectbox.hide(); this.$value.css('display', ''); - const fadeOutLoader = () => { - // eslint-disable-next-line no-jquery/no-fade - this.$loading.fadeOut(); + const hideLoader = () => { + this.$loading.addClass('gl-display-none'); }; boardsStore.detail.issue .update(this.$dropdown.attr('data-issue-update')) - .then(fadeOutLoader) - .catch(fadeOutLoader); + .then(hideLoader) + .catch(hideLoader); } submitSelectedDate(isDropdown) { @@ -140,8 +138,7 @@ class DueDateSelect { const hasDueDate = this.displayedDate !== __('None'); const displayedDateStyle = hasDueDate ? 'bold' : 'no-value'; - // eslint-disable-next-line no-jquery/no-fade - this.$loading.removeClass('hidden').fadeIn(); + this.$loading.removeClass('gl-display-none'); if (isDropdown) { this.$dropdown.trigger('loading.gl.dropdown'); @@ -164,8 +161,7 @@ class DueDateSelect { } this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); - // eslint-disable-next-line no-jquery/no-fade - return this.$loading.fadeOut(); + return this.$loading.addClass('gl-display-none'); }); } } @@ -211,7 +207,8 @@ export default class DueDateSelectors { initIssuableSelect() { const $loading = $('.js-issuable-update .due_date') .find('.block-loading') - .hide(); + .removeClass('hidden') + .addClass('gl-display-none'); $('.js-due-date-select').each((i, dropdown) => { const $dropdown = $(dropdown); diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index b02eb37206a..d6f87872bde 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -6,3 +6,7 @@ export const EDITOR_LITE_INSTANCE_ERROR_NO_EL = __( export const URI_PREFIX = 'gitlab'; export const CONTENT_UPDATE_DEBOUNCE = 250; + +export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __( + 'Editor Lite instance is required to set up an extension.', +); diff --git a/app/assets/javascripts/editor/editor_file_template_ext.js b/app/assets/javascripts/editor/editor_file_template_ext.js index 343908b831d..f5474318447 100644 --- a/app/assets/javascripts/editor/editor_file_template_ext.js +++ b/app/assets/javascripts/editor/editor_file_template_ext.js @@ -1,7 +1,8 @@ import { Position } from 'monaco-editor'; +import { EditorLiteExtension } from './editor_lite_extension_base'; -export default { +export class FileTemplateExtension extends EditorLiteExtension { navigateFileStart() { this.setPosition(new Position(1, 1)); - }, -}; + } +} diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js index e7535c211db..2bd1cdc84d0 100644 --- a/app/assets/javascripts/editor/editor_lite.js +++ b/app/assets/javascripts/editor/editor_lite.js @@ -8,7 +8,7 @@ import { clearDomElement } from './utils'; import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from './constants'; import { uuids } from '~/diffs/utils/uuids'; -export default class Editor { +export default class EditorLite { constructor(options = {}) { this.instances = []; this.options = { @@ -17,7 +17,7 @@ export default class Editor { ...options, }; - Editor.setupMonacoTheme(); + EditorLite.setupMonacoTheme(); registerLanguages(...languages); } @@ -54,12 +54,25 @@ export default class Editor { extensionsArray.forEach(ext => { const prefix = ext.includes('/') ? '' : 'editor/'; const trimmedExt = ext.replace(/^\//, '').trim(); - Editor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`); + EditorLite.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`); }); return Promise.all(promises); } + static mixIntoInstance(source, inst) { + if (!inst) { + return; + } + const isClassInstance = source.constructor.prototype !== Object.prototype; + const sanitizedSource = isClassInstance ? source.constructor.prototype : source; + Object.getOwnPropertyNames(sanitizedSource).forEach(prop => { + if (prop !== 'constructor') { + Object.assign(inst, { [prop]: source[prop] }); + } + }); + } + /** * Creates a monaco instance with the given options. * @@ -101,10 +114,10 @@ export default class Editor { this.instances.splice(index, 1); model.dispose(); }); - instance.updateModelLanguage = path => Editor.updateModelLanguage(path, instance); + instance.updateModelLanguage = path => EditorLite.updateModelLanguage(path, instance); instance.use = args => this.use(args, instance); - Editor.loadExtensions(extensions, instance) + EditorLite.loadExtensions(extensions, instance) .then(modules => { if (modules) { modules.forEach(module => { @@ -129,10 +142,17 @@ export default class Editor { use(exts = [], instance = null) { const extensions = Array.isArray(exts) ? exts : [exts]; + const initExtensions = inst => { + extensions.forEach(extension => { + EditorLite.mixIntoInstance(extension, inst); + }); + }; if (instance) { - Object.assign(instance, ...extensions); + initExtensions(instance); } else { - this.instances.forEach(inst => Object.assign(inst, ...extensions)); + this.instances.forEach(inst => { + initExtensions(inst); + }); } } } diff --git a/app/assets/javascripts/editor/editor_lite_extension_base.js b/app/assets/javascripts/editor/editor_lite_extension_base.js new file mode 100644 index 00000000000..b8d87fa4969 --- /dev/null +++ b/app/assets/javascripts/editor/editor_lite_extension_base.js @@ -0,0 +1,11 @@ +import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from './constants'; + +export class EditorLiteExtension { + constructor({ instance, ...options } = {}) { + if (instance) { + Object.assign(instance, options); + } else if (Object.entries(options).length) { + throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); + } + } +} diff --git a/app/assets/javascripts/editor/editor_markdown_ext.js b/app/assets/javascripts/editor/editor_markdown_ext.js index c46f5736912..19e0037c175 100644 --- a/app/assets/javascripts/editor/editor_markdown_ext.js +++ b/app/assets/javascripts/editor/editor_markdown_ext.js @@ -1,4 +1,6 @@ -export default { +import { EditorLiteExtension } from './editor_lite_extension_base'; + +export class EditorMarkdownExtension extends EditorLiteExtension { getSelectedText(selection = this.getSelection()) { const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; const valArray = this.getValue().split('\n'); @@ -18,19 +20,19 @@ export default { : [startLineText, endLineText].join('\n'); } return text; - }, + } replaceSelectedText(text, select = undefined) { const forceMoveMarkers = !select; this.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]); - }, + } moveCursor(dx = 0, dy = 0) { const pos = this.getPosition(); pos.column += dx; pos.lineNumber += dy; this.setPosition(pos); - }, + } /** * Adjust existing selection to select text within the original selection. @@ -91,5 +93,5 @@ export default { .setEndPosition(newEndLineNumber, newEndColumn); this.setSelection(newSelection); - }, -}; + } +} diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index bc35a07fe4a..2192d456861 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import { formatTime } from '~/lib/utils/datetime_utility'; import eventHub from '../event_hub'; @@ -9,7 +9,8 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - GlButton, + GlDropdown, + GlDropdownItem, GlIcon, GlLoadingIcon, }, @@ -35,7 +36,7 @@ export default { if (action.scheduledAt) { const confirmationMessage = sprintf( s__( - "DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.", + 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.', ), { jobName: action.name }, ); @@ -67,40 +68,32 @@ export default { }; </script> <template> - <div class="btn-group" role="group"> - <gl-button - v-gl-tooltip - :title="title" - :aria-label="title" - :disabled="isLoading" - class="dropdown dropdown-new js-environment-actions-dropdown" - data-container="body" - data-toggle="dropdown" - data-testid="environment-actions-button" + <gl-dropdown + v-gl-tooltip + :title="title" + :aria-label="title" + :disabled="isLoading" + right + data-container="body" + data-testid="environment-actions-button" + > + <template #button-content> + <gl-icon name="play" /> + <gl-icon name="chevron-down" /> + <gl-loading-icon v-if="isLoading" /> + </template> + <gl-dropdown-item + v-for="(action, i) in actions" + :key="i" + :disabled="isActionDisabled(action)" + data-testid="manual-action-link" + @click="onClickAction(action)" > - <span> - <gl-icon name="play" /> - <gl-icon name="chevron-down" /> - <gl-loading-icon v-if="isLoading" /> + <span class="gl-flex-fill-1">{{ action.name }}</span> + <span v-if="action.scheduledAt" class="gl-text-gray-500 float-right"> + <gl-icon name="clock" /> + {{ remainingTime(action) }} </span> - </gl-button> - - <ul class="dropdown-menu dropdown-menu-right"> - <li v-for="(action, i) in actions" :key="i" class="gl-display-flex"> - <gl-button - :class="{ disabled: isActionDisabled(action) }" - :disabled="isActionDisabled(action)" - variant="link" - class="js-manual-action-link gl-flex-fill-1" - @click="onClickAction(action)" - > - <span class="gl-flex-fill-1">{{ action.name }}</span> - <span v-if="action.scheduledAt" class="text-secondary float-right"> - <gl-icon name="clock" /> - {{ remainingTime(action) }} - </span> - </gl-button> - </li> - </ul> - </div> + </gl-dropdown-item> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 48e81b168ec..347828888dc 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,13 +1,14 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { isEmpty } from 'lodash'; -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlTooltipDirective, GlIcon, GlLink } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import CommitComponent from '~/vue_shared/components/commit.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import eventHub from '../event_hub'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; @@ -30,6 +31,7 @@ export default { CommitComponent, ExternalUrlComponent, GlIcon, + GlLink, MonitoringButtonComponent, PinComponent, DeleteComponent, @@ -38,6 +40,7 @@ export default { TerminalButtonComponent, TooltipOnTruncate, UserAvatarLink, + CiIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -81,6 +84,24 @@ export default { }, /** + * @returns {Object|Undefined} The `upcoming_deployment` object if it exists. + * Otherwise, `undefined`. + */ + upcomingDeployment() { + return this.model?.upcoming_deployment; + }, + + /** + * @returns {String} Text that will be shown in the tooltip when + * the user hovers over the upcoming deployment's status icon. + */ + upcomingDeploymentTooltipText() { + return sprintf(s__('Environments|Deployment %{status}'), { + status: this.upcomingDeployment.deployable.status.text, + }); + }, + + /** * Checkes whether the row displayed is a folder. * * @returns {Boolean} @@ -235,6 +256,18 @@ export default { }, /** + * Same as `userImageAltDescription`, but for the + * upcoming deployment's user + * + * @returns {String} + */ + upcomingDeploymentUserImageAltDescription() { + return sprintf(__("%{username}'s avatar"), { + username: this.upcomingDeployment.user.username, + }); + }, + + /** * If provided, returns the commit tag. * * @returns {String|Undefined} @@ -382,6 +415,15 @@ export default { }, /** + * Same as `deploymentInternalId`, but for the upcoming deployment + * + * @returns {String} + */ + upcomingDeploymentInternalId() { + return `#${this.upcomingDeployment.iid}`; + }, + + /** * Verifies if the user object is present under last_deployment object. * * @returns {Boolean} @@ -503,6 +545,13 @@ export default { folderIconName() { return this.model.isOpen ? 'chevron-down' : 'chevron-right'; }, + + upcomingDeploymentCellClasses() { + return [ + this.tableData.upcoming.spacing, + { 'gl-display-none gl-display-md-block': !this.upcomingDeployment }, + ]; + }, }, methods: { @@ -512,6 +561,19 @@ export default { onClickFolder() { eventHub.$emit('toggleFolder', this.model); }, + + /** + * Returns the field title that will be shown in the field's row + * in the mobile view. + * + * @returns `field.mobileTitle` if present; + * if not, falls back to `field.title`. + */ + getMobileViewTitleForField(fieldName) { + const field = this.tableData[fieldName]; + + return field.mobileTitle || field.title; + }, }, }; </script> @@ -530,7 +592,7 @@ export default { role="gridcell" > <div v-if="!isFolder" class="table-mobile-header" role="rowheader"> - {{ tableData.name.title }} + {{ getMobileViewTitleForField('name') }} </div> <span v-if="shouldRenderDeployBoard" class="deploy-board-icon" @click="toggleDeployBoard"> @@ -609,7 +671,9 @@ export default { </div> <div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell"> - <div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div> + <div role="rowheader" class="table-mobile-header"> + {{ getMobileViewTitleForField('commit') }} + </div> <div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content"> <commit-component :tag="commitTag" @@ -623,7 +687,9 @@ export default { </div> <div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell"> - <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> + <div role="rowheader" class="table-mobile-header"> + {{ getMobileViewTitleForField('date') }} + </div> <span v-if="canShowDeploymentDate" v-gl-tooltip @@ -636,8 +702,51 @@ export default { </span> </div> + <div + v-if="!isFolder" + class="table-section" + :class="upcomingDeploymentCellClasses" + role="gridcell" + data-testid="upcoming-deployment" + > + <div role="rowheader" class="table-mobile-header"> + {{ getMobileViewTitleForField('upcoming') }} + </div> + <div + v-if="upcomingDeployment" + class="gl-w-full gl-display-flex gl-flex-direction-row gl-md-flex-direction-column! gl-justify-content-end" + data-testid="upcoming-deployment-content" + > + <div class="gl-display-flex gl-align-items-center"> + <span class="gl-mr-2">{{ upcomingDeploymentInternalId }}</span> + <gl-link + v-if="upcomingDeployment.deployable" + v-gl-tooltip + :href="upcomingDeployment.deployable.build_path" + :title="upcomingDeploymentTooltipText" + data-testid="upcoming-deployment-status-link" + > + <ci-icon class="gl-mr-2" :status="upcomingDeployment.deployable.status" /> + </gl-link> + </div> + <div class="gl-display-flex"> + <span v-if="upcomingDeployment.user" class="text-break-word"> + by + <user-avatar-link + :link-href="upcomingDeployment.user.web_url" + :img-src="upcomingDeployment.user.avatar_url" + :img-alt="upcomingDeploymentUserImageAltDescription" + :tooltip-text="upcomingDeployment.user.username" + /> + </span> + </div> + </div> + </div> + <div v-if="!isFolder" class="table-section" :class="tableData.autoStop.spacing" role="gridcell"> - <div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div> + <div role="rowheader" class="table-mobile-header"> + {{ getMobileViewTitleForField('autoStop') }} + </div> <span v-if="canShowAutoStopDate" v-gl-tooltip diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index c1b9ba755a6..b6a7cce36e9 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -93,7 +93,9 @@ export default { }, beforeDestroy() { + // eslint-disable-next-line @gitlab/no-global-event-off eventHub.$off('toggleFolder'); + // eslint-disable-next-line @gitlab/no-global-event-off eventHub.$off('toggleDeployBoard'); }, @@ -141,13 +143,7 @@ export default { <confirm-rollback-modal :environment="environmentInRollbackModal" /> <div class="gl-w-full"> - <div - class=" - gl-display-flex - gl-flex-direction-column - gl-mt-3 - gl-display-md-none!" - > + <div class="gl-display-flex gl-flex-direction-column gl-mt-3 gl-display-md-none!"> <gl-button v-if="state.reviewAppDetails.can_setup_review_app" v-gl-modal="$options.modal.id" @@ -156,18 +152,16 @@ export default { category="secondary" type="button" class="gl-mb-3 gl-flex-fill-1" + >{{ $options.i18n.reviewAppButtonLabel }}</gl-button > - {{ $options.i18n.reviewAppButtonLabel }} - </gl-button> <gl-button v-if="canCreateEnvironment" :href="newEnvironmentPath" data-testid="new-environment" category="primary" variant="success" + >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button > - {{ $options.i18n.newEnvironmentButtonLabel }} - </gl-button> </div> <gl-tabs content-class="gl-display-none"> <gl-tab @@ -183,14 +177,7 @@ export default { </gl-tab> <template #tabs-end> <div - class=" - gl-display-none - gl-display-md-flex - gl-lg-align-items-center - gl-lg-flex-direction-row - gl-lg-flex-fill-1 - gl-lg-justify-content-end - gl-lg-mt-0" + class="gl-display-none gl-display-md-flex gl-lg-align-items-center gl-lg-flex-direction-row gl-lg-flex-fill-1 gl-lg-justify-content-end gl-lg-mt-0" > <gl-button v-if="state.reviewAppDetails.can_setup_review_app" @@ -200,18 +187,16 @@ export default { category="secondary" type="button" class="gl-mb-3 gl-lg-mr-3 gl-lg-mb-0" + >{{ $options.i18n.reviewAppButtonLabel }}</gl-button > - {{ $options.i18n.reviewAppButtonLabel }} - </gl-button> <gl-button v-if="canCreateEnvironment" :href="newEnvironmentPath" data-testid="new-environment" category="primary" variant="success" + >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button > - {{ $options.i18n.newEnvironmentButtonLabel }} - </gl-button> </div> </template> </gl-tabs> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index c1b3eabec16..d13c7204285 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -15,6 +15,7 @@ export default { CanaryDeploymentCallout: () => import('ee_component/environments/components/canary_deployment_callout.vue'), EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'), + CanaryUpdateModal: () => import('ee_component/environments/components/canary_update_modal.vue'), }, props: { environments: { @@ -58,6 +59,12 @@ export default { default: '', }, }, + data() { + return { + canaryWeight: 0, + environmentToChange: null, + }; + }, computed: { sortedEnvironments() { return this.sortEnvironments(this.environments).map(env => @@ -71,7 +78,7 @@ export default { // percent spacing for cols, should add up to 100 name: { title: s__('Environments|Environment'), - spacing: 'section-15', + spacing: 'section-10', }, deploy: { title: s__('Environments|Deployment'), @@ -83,18 +90,23 @@ export default { }, commit: { title: s__('Environments|Commit'), - spacing: 'section-20', + spacing: 'section-15', }, date: { title: s__('Environments|Updated'), spacing: 'section-10', }, + upcoming: { + title: s__('Environments|Upcoming'), + mobileTitle: s__('Environments|Upcoming deployment'), + spacing: 'section-10', + }, autoStop: { title: s__('Environments|Auto stop in'), - spacing: 'section-5', + spacing: 'section-10', }, actions: { - spacing: 'section-25', + spacing: 'section-20', }, }; }, @@ -139,11 +151,16 @@ export default { sortBy(env => (env.isFolder ? -1 : 1)), )(environments); }, + changeCanaryWeight(model, weight) { + this.environmentToChange = model; + this.canaryWeight = weight; + }, }, }; </script> <template> <div class="ci-table" role="grid"> + <canary-update-modal :environment="environmentToChange" :weight="canaryWeight" /> <div class="gl-responsive-table-row table-row-header" role="row"> <div class="table-section" :class="tableData.name.spacing" role="columnheader"> {{ tableData.name.title }} @@ -160,6 +177,9 @@ export default { <div class="table-section" :class="tableData.date.spacing" role="columnheader"> {{ tableData.date.title }} </div> + <div class="table-section" :class="tableData.upcoming.spacing" role="columnheader"> + {{ tableData.upcoming.title }} + </div> <div class="table-section" :class="tableData.autoStop.spacing" role="columnheader"> {{ tableData.autoStop.title }} </div> @@ -171,6 +191,7 @@ export default { :model="model" :can-read-environment="canReadEnvironment" :table-data="tableData" + data-qa-selector="environment_item" /> <div @@ -185,6 +206,7 @@ export default { :is-loading="model.isLoadingDeployBoard" :is-empty="model.isEmptyDeployBoard" :logs-path="model.logs_path" + @changeCanaryWeight="changeCanaryWeight(model, $event)" /> </div> </div> @@ -207,6 +229,7 @@ export default { :model="children" :can-read-environment="canReadEnvironment" :table-data="tableData" + data-qa-selector="environment_item" /> <div :key="`sub-div-${i}`"> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index cd4bb476b6e..c3471346a63 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -80,7 +80,7 @@ export default { <div ref="header" class="file-title file-title-flex-parent"> <div class="file-header-content d-flex align-content-center"> <div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()"> - <gl-icon :name="collapseIcon" :size="16" aria-hidden="true" class="gl-mr-2" /> + <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" /> <strong 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 bf47d7cf7c0..5953a4fbad8 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 @@ -9,10 +9,10 @@ import { GlSprintf, GlLink, GlIcon, + GlAlert, } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; -import Callout from '~/vue_shared/components/callout.vue'; export default { components: { @@ -22,10 +22,10 @@ export default { GlModal, ModalCopyButton, GlIcon, - Callout, GlLoadingIcon, GlSprintf, GlLink, + GlAlert, }, directives: { @@ -153,8 +153,7 @@ export default { </template> </gl-sprintf> </p> - - <callout category="warning"> + <gl-alert variant="warning" class="gl-mb-5" :dismissible="false"> <gl-sprintf :message=" s__( @@ -168,7 +167,7 @@ export default { }}</gl-link> </template> </gl-sprintf> - </callout> + </gl-alert> <gl-form-group :label="$options.translations.apiUrlLabelText" label-for="api-url"> <gl-form-input-group id="api-url" :value="unleashApiUrl" readonly type="text" name="api-url"> <template #append> @@ -212,11 +211,9 @@ export default { <gl-icon name="warning" class="gl-mr-2" /> <span>{{ $options.translations.instanceIdRegenerateError }}</span> </div> - <callout - v-if="canUserRotateToken" - category="danger" - :message="$options.translations.instanceIdRegenerateText" - /> + <gl-alert v-if="canUserRotateToken" variant="danger" class="gl-mb-5" :dismissible="false"> + {{ $options.translations.instanceIdRegenerateText }} + </gl-alert> <p v-if="canUserRotateToken" data-testid="prevent-accident-text"> <gl-sprintf :message=" 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 9ec65bb0b43..b89e9723606 100644 --- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue @@ -4,7 +4,7 @@ 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, NEW_FLAG_ALERT } from '../constants'; +import { LEGACY_FLAG } from '../constants'; import FeatureFlagForm from './form.vue'; export default { @@ -36,7 +36,6 @@ export default { 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.', ), - newFlagAlert: NEW_FLAG_ALERT, }, computed: { ...mapState([ @@ -58,7 +57,7 @@ export default { : sprintf(s__('Edit %{name}'), { name: this.name }); }, deprecated() { - return this.hasNewVersionFlags && this.version === LEGACY_FLAG; + return this.version === LEGACY_FLAG; }, deprecatedAndEditable() { return this.deprecated && !this.hasLegacyReadOnlyFlags; @@ -66,18 +65,12 @@ export default { deprecatedAndReadOnly() { return this.deprecated && this.hasLegacyReadOnlyFlags; }, - hasNewVersionFlags() { - return this.glFeatures.featureFlagsNewVersion; - }, hasLegacyReadOnlyFlags() { return ( this.glFeatures.featureFlagsLegacyReadOnly && !this.glFeatures.featureFlagsLegacyReadOnlyOverride ); }, - shouldShowNewFlagAlert() { - return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert; - }, }, created() { return this.fetchFeatureFlag(); @@ -95,14 +88,6 @@ export default { </script> <template> <div> - <gl-alert - v-if="shouldShowNewFlagAlert" - variant="warning" - class="gl-my-5" - @dismiss="dismissNewVersionFlagAlert" - > - {{ $options.translations.newFlagAlert }} - </gl-alert> <gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-7" /> <template v-else-if="!isLoading && !hasError"> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index 340cf68793f..fe327a98605 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -7,7 +7,6 @@ import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants'; import FeatureFlagsTab from './feature_flags_tab.vue'; import FeatureFlagsTable from './feature_flags_table.vue'; import UserListsTable from './user_lists_table.vue'; -import { s__ } from '~/locale'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import { buildUrlWithCurrentLocation, @@ -96,9 +95,6 @@ export default { hasNewPath() { return !isEmpty(this.newFeatureFlagPath); }, - emptyStateTitle() { - return s__('FeatureFlags|Get started with feature flags'); - }, }, created() { this.setFeatureFlagsOptions({ scope: this.scope, page: this.page }); @@ -246,7 +242,12 @@ export default { :error-state="shouldRenderErrorState" :error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)" :empty-state="shouldShowEmptyState" - :empty-title="emptyStateTitle" + :empty-title="s__('FeatureFlags|Get started with feature flags')" + :empty-description=" + s__( + 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', + ) + " data-testid="feature-flags-tab" @dismissAlert="clearAlert" @changeTab="onFeatureFlagsTab" @@ -266,7 +267,12 @@ export default { :error-state="shouldRenderErrorState" :error-title="s__(`FeatureFlags|There was an error fetching the user lists.`)" :empty-state="shouldShowEmptyState" - :empty-title="emptyStateTitle" + :empty-title="s__('FeatureFlags|Get started with user lists')" + :empty-description=" + s__( + 'FeatureFlags|User lists allow you to define a set of users to use with Feature Flags.', + ) + " data-testid="user-lists-tab" @dismissAlert="clearAlert" @changeTab="onUserListsTab" diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue index 5c35aa33e14..0539b5ff832 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue @@ -41,6 +41,10 @@ export default { required: true, type: String, }, + emptyDescription: { + required: true, + type: String, + }, }, inject: ['errorStateSvgPath', 'featureFlagsHelpPagePath'], computed: { @@ -92,11 +96,7 @@ export default { data-testid="empty-state" > <template #description> - {{ - s__( - 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', - ) - }} + {{ emptyDescription }} <gl-link :href="featureFlagsHelpPagePath" target="_blank"> {{ s__('FeatureFlags|More information') }} </gl-link> 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 54d038606f4..ba46bab2df0 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -38,9 +38,6 @@ export default { permissions() { return this.glFeatures.featureFlagPermissions; }, - isNewVersionFlagsEnabled() { - return this.glFeatures.featureFlagsNewVersion; - }, isLegacyReadOnlyFlagsEnabled() { return ( this.glFeatures.featureFlagsLegacyReadOnly && @@ -68,7 +65,7 @@ export default { }, methods: { isLegacyFlag(flag) { - return !this.isNewVersionFlagsEnabled || flag.version !== NEW_VERSION_FLAG; + return flag.version !== NEW_VERSION_FLAG; }, statusToggleDisabled(flag) { return this.isLegacyReadOnlyFlagsEnabled && flag.version === LEGACY_FLAG; diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue index 36ebf893486..12856b79f63 100644 --- a/app/assets/javascripts/feature_flags/components/form.vue +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -137,14 +137,13 @@ export default { return this.glFeatures.featureFlagPermissions; }, supportsStrategies() { - return this.glFeatures.featureFlagsNewVersion && this.version === NEW_VERSION_FLAG; + return this.version === NEW_VERSION_FLAG; }, showRelatedIssues() { return this.featureFlagIssuesEndpoint.length > 0; }, readOnly() { return ( - this.glFeatures.featureFlagsNewVersion && this.glFeatures.featureFlagsLegacyReadOnly && !this.glFeatures.featureFlagsLegacyReadOnlyOverride && this.version === LEGACY_FLAG 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 9472eddf336..e6949d8028b 100644 --- a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue @@ -1,21 +1,14 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlAlert } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import FeatureFlagForm from './form.vue'; -import { - LEGACY_FLAG, - NEW_VERSION_FLAG, - NEW_FLAG_ALERT, - ROLLOUT_STRATEGY_ALL_USERS, -} from '../constants'; +import { NEW_VERSION_FLAG, ROLLOUT_STRATEGY_ALL_USERS } from '../constants'; import { createNewEnvironmentScope } from '../store/helpers'; import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { - GlAlert, FeatureFlagForm, }, mixins: [featureFlagsMixin()], @@ -33,9 +26,6 @@ export default { userShouldSeeNewFlagAlert: this.showUserCallout, }; }, - translations: { - newFlagAlert: NEW_FLAG_ALERT, - }, computed: { ...mapState(['error', 'path']), scopes() { @@ -50,13 +40,7 @@ export default { ]; }, version() { - return this.hasNewVersionFlags ? NEW_VERSION_FLAG : LEGACY_FLAG; - }, - hasNewVersionFlags() { - return this.glFeatures.featureFlagsNewVersion; - }, - shouldShowNewFlagAlert() { - return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert; + return NEW_VERSION_FLAG; }, strategies() { return [{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }]; @@ -75,14 +59,6 @@ export default { </script> <template> <div> - <gl-alert - v-if="shouldShowNewFlagAlert" - variant="warning" - class="gl-my-5" - @dismiss="dismissNewVersionFlagAlert" - > - {{ $options.translations.newFlagAlert }} - </gl-alert> <h3 class="page-title">{{ s__('FeatureFlags|New feature flag') }}</h3> <div v-if="error.length" class="alert alert-danger"> diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue index 9c41dde62e4..ce03248381c 100644 --- a/app/assets/javascripts/feature_flags/components/strategy.vue +++ b/app/assets/javascripts/feature_flags/components/strategy.vue @@ -183,11 +183,11 @@ export default { <span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3"> {{ $options.i18n.allEnvironments }} </span> - <div v-else class="gl-display-flex gl-align-items-center"> + <div v-else class="gl-display-flex gl-align-items-center gl-flex-wrap"> <gl-token v-for="environment in filteredEnvironments" :key="environment.id" - class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill" + class="gl-mt-3 gl-mr-3 gl-mb-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill" @close="removeScope(environment)" > {{ environment.environmentScope }} diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js index 4843eca149a..658984456a5 100644 --- a/app/assets/javascripts/feature_flags/constants.js +++ b/app/assets/javascripts/feature_flags/constants.js @@ -21,10 +21,6 @@ export const fetchUserIdParams = property(['parameters', 'userIds']); export const NEW_VERSION_FLAG = 'new_version_flag'; export const LEGACY_FLAG = 'legacy_flag'; -export const NEW_FLAG_ALERT = s__( - 'FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow.', -); - export const FEATURE_FLAG_SCOPE = 'featureFlags'; export const USER_LIST_SCOPE = 'userLists'; diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index 4aad54bed55..eabf3b0846e 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -64,8 +64,7 @@ export default class FilterableList { return false; } - // eslint-disable-next-line no-jquery/no-fade - $(this.listHolderElement).fadeTo(250, 0.5); + $(this.listHolderElement).addClass('gl-opacity-5'); this.isBusy = true; @@ -99,7 +98,6 @@ export default class FilterableList { onFilterComplete() { this.isBusy = false; - // eslint-disable-next-line no-jquery/no-fade - $(this.listHolderElement).fadeTo(250, 1); + $(this.listHolderElement).removeClass('gl-opacity-5'); } } 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 7d4df25816b..38a5bdd4a71 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 @@ -1,6 +1,18 @@ import { __ } from '~/locale'; export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { + const reviewerToken = { + formattedKey: __('Reviewer'), + key: 'reviewer', + type: 'string', + param: 'username', + symbol: '@', + icon: 'user', + tag: '@reviewer', + }; + IssuableTokenKeys.tokenKeys.splice(2, 0, reviewerToken); + IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, reviewerToken); + const draftToken = { token: { formattedKey: __('Draft'), @@ -91,7 +103,7 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { ], }; - const tokenPosition = 2; + const tokenPosition = 3; IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]); IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]); IssuableTokenKeys.conditions.push(...approvedBy.condition); diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index d7645f96406..77491d1556b 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -71,6 +71,11 @@ export default class AvailableDropdownMappings { gl: DropdownUser, element: this.container.querySelector('#js-dropdown-assignee'), }, + reviewer: { + reference: null, + gl: DropdownUser, + element: this.container.querySelector('#js-dropdown-reviewer'), + }, 'approved-by': { reference: null, gl: DropdownUser, diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index 6cd6f9c9906..08736b09407 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -1,4 +1,4 @@ -export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by']; +export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by', 'reviewer']; export const DROPDOWN_TYPE = { hint: 'hint', diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index d2ac80fa190..f9388e9c5d8 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -86,6 +86,16 @@ export const conditions = flattenDeep( value: __('Any'), }, { + url: 'reviewer_id=None', + tokenKey: 'reviewer', + value: __('None'), + }, + { + url: 'reviewer_id=Any', + tokenKey: 'reviewer', + value: __('Any'), + }, + { url: 'author_username=support-bot', tokenKey: 'author', value: 'support-bot', diff --git a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js index 7e9b809e9b2..54d49821d92 100644 --- a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js +++ b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js @@ -1,4 +1,6 @@ export default { issues: 'issue-recent-searches', merge_requests: 'merge-request-recent-searches', + group_members: 'group-members-recent-searches', + group_invited_members: 'group-invited-members-recent-searches', }; diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index 61080fb5487..c4f61b839e4 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import AccessorUtilities from '~/lib/utils/accessor'; import eventHub from '../event_hub'; -import store from '../store'; import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils'; import FrequentItemsSearchInput from './frequent_items_search_input.vue'; @@ -11,7 +10,6 @@ import FrequentItemsList from './frequent_items_list.vue'; import frequentItemsMixin from './frequent_items_mixin'; export default { - store, components: { FrequentItemsSearchInput, FrequentItemsList, 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 1203f389931..3260d768fd9 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,13 +1,18 @@ <script> /* eslint-disable vue/require-default-prop, vue/no-v-html */ +import { mapState } from 'vuex'; import Identicon from '~/vue_shared/components/identicon.vue'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; +import Tracking from '~/tracking'; + +const trackingMixin = Tracking.mixin(); export default { components: { Identicon, }, + mixins: [trackingMixin], props: { matcher: { type: String, @@ -37,6 +42,7 @@ export default { }, }, computed: { + ...mapState(['dropdownType']), truncatedNamespace() { return truncateNamespace(this.namespace); }, @@ -49,7 +55,11 @@ export default { <template> <li class="frequent-items-list-item-container"> - <a :href="webUrl" class="clearfix"> + <a + :href="webUrl" + class="clearfix" + @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" diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue index 19cb09f0dcc..8042e8c7bc9 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue @@ -1,27 +1,34 @@ <script> import { debounce } from 'lodash'; -import { mapActions } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import { GlIcon } from '@gitlab/ui'; import eventHub from '../event_hub'; import frequentItemsMixin from './frequent_items_mixin'; +import Tracking from '~/tracking'; + +const trackingMixin = Tracking.mixin(); export default { components: { GlIcon, }, - mixins: [frequentItemsMixin], + mixins: [frequentItemsMixin, trackingMixin], data() { return { searchQuery: '', }; }, computed: { + ...mapState(['dropdownType']), translations() { return this.getTranslations(['searchInputPlaceholder']); }, }, watch: { searchQuery: debounce(function debounceSearchQuery() { + this.track('type_search_query', { + label: `${this.dropdownType}_dropdown_frequent_items_search_input`, + }); this.setSearchQuery(this.searchQuery); }, 500), }, diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js index 1998bf4358a..639562bf961 100644 --- a/app/assets/javascripts/frequent_items/index.js +++ b/app/assets/javascripts/frequent_items/index.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import Vue from 'vue'; import Translate from '~/vue_shared/translate'; import eventHub from './event_hub'; +import { createStore } from '~/frequent_items/store'; Vue.use(Translate); @@ -28,11 +29,15 @@ export default function initFrequentItemDropdowns() { return; } + const dropdownType = namespace; + const store = createStore({ dropdownType }); + import('./components/app.vue') .then(({ default: FrequentItems }) => { // eslint-disable-next-line no-new new Vue({ el, + store, data() { const { dataset } = this.$options.el; const item = { diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js index ece9e6419dd..83176d69802 100644 --- a/app/assets/javascripts/frequent_items/store/index.js +++ b/app/assets/javascripts/frequent_items/store/index.js @@ -7,10 +7,11 @@ import state from './state'; Vue.use(Vuex); -export default () => - new Vuex.Store({ +export const createStore = (initState = {}) => { + return new Vuex.Store({ actions, getters, mutations, - state: state(), + state: state(initState), }); +}; diff --git a/app/assets/javascripts/frequent_items/store/state.js b/app/assets/javascripts/frequent_items/store/state.js index 75b04febee4..c5c0b25fdf2 100644 --- a/app/assets/javascripts/frequent_items/store/state.js +++ b/app/assets/javascripts/frequent_items/store/state.js @@ -1,5 +1,6 @@ -export default () => ({ +export default ({ dropdownType = '' } = {}) => ({ namespace: '', + dropdownType, storageKey: '', searchQuery: '', isLoadingItems: false, diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 14538ad7237..dcb27434a07 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -78,6 +78,7 @@ class GfmAutoComplete { this.input.each((i, input) => { const $input = $(input); if (!$input.hasClass('js-gfm-input-initialized')) { + // eslint-disable-next-line @gitlab/no-global-event-off $input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input)); $input.on('change.atwho', () => input.dispatchEvent(new Event('input'))); // This triggers at.js again diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js index ac4c8d28ee4..60f1b7f5aa4 100644 --- a/app/assets/javascripts/gl_field_error.js +++ b/app/assets/javascripts/gl_field_error.js @@ -80,6 +80,7 @@ export default class GlFieldError { // hidden when injected into DOM errorAnchor.after(this.fieldErrorElement); + // eslint-disable-next-line @gitlab/no-global-event-off this.inputElement.off('invalid').on('invalid', this.handleInvalidSubmit.bind(this)); this.scopedSiblings = this.safelySelectSiblings(); } @@ -117,6 +118,7 @@ export default class GlFieldError { this.form.focusInvalid.apply(this.form); // For UX, wait til after first invalid submission to check each keyup + // eslint-disable-next-line @gitlab/no-global-event-off this.inputElement .off('keyup.fieldValidator') .on('keyup.fieldValidator', this.updateValidity.bind(this)); diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 6958cf4c173..4a3755f39cc 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -70,8 +70,10 @@ export default class GLForm { } setupAutosize() { + // eslint-disable-next-line @gitlab/no-global-event-off this.textarea.off('autosize:resized').on('autosize:resized', this.setHeightData.bind(this)); + // eslint-disable-next-line @gitlab/no-global-event-off this.textarea.off('mouseup.autosize').on('mouseup.autosize', this.destroyAutosize.bind(this)); setTimeout(() => { @@ -97,7 +99,9 @@ export default class GLForm { } clearEventListeners() { + // eslint-disable-next-line @gitlab/no-global-event-off this.textarea.off('focus'); + // eslint-disable-next-line @gitlab/no-global-event-off this.textarea.off('blur'); removeMarkdownListeners(this.form); } diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql new file mode 100644 index 00000000000..b64ceb8e2c9 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -0,0 +1,9 @@ +#import "../fragments/user.fragment.graphql" + +query usersSearch($search: String!) { + users(search: $search) { + nodes { + ...User + } + } +} diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index 5487aeb9391..813e21b6ce9 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -14,3 +14,41 @@ export const MutationOperationMode = { Remove: 'REMOVE', Replace: 'REPLACE', }; + +/** + * 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. + * + * @param {String} type The entity type + * @param {String|Number} id The id value + * @returns {String} + */ +export const convertToGraphQLId = (type, id) => { + if (typeof type !== 'string') { + throw new TypeError(`type must be a string; got ${typeof type}`); + } + + if (!['number', 'string'].includes(typeof id)) { + throw new TypeError(`id must be a number or string; got ${typeof id}`); + } + + return `gid://gitlab/${type}/${id}`; +}; + +/** + * Ids generated by GraphQL endpoints are usually in the format + * gid://gitlab/Groups/123. This method takes a type and an + * array of ids and tranforms the array values into the expected + * GraphQL ID format. + * + * @param {String} type The entity type + * @param {Array} ids An array of id values + * @returns {Array} + */ +export const convertToGraphQLIds = (type, ids) => ids.map(id => convertToGraphQLId(type, id)); diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue index d2a613bed4f..5f169832ee4 100644 --- a/app/assets/javascripts/groups/components/group_folder.vue +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -49,7 +49,7 @@ export default { /> <li v-if="hasMoreChildren" class="group-row"> <a :href="parentGroup.relativePath" class="group-row-contents has-more-items py-2"> - <gl-icon name="external-link" aria-hidden="true" /> {{ moreChildrenStats }} + <gl-icon name="external-link" /> {{ moreChildrenStats }} </a> </li> </ul> diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 6e99b6ad4fa..ef58b93c049 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -74,6 +74,9 @@ export default { visibilityTooltip() { return GROUP_VISIBILITY_TYPE[this.group.visibility]; }, + microdata() { + return this.group.microdata || {}; + }, }, mounted() { if (this.group.name === 'Learn GitLab') { @@ -99,7 +102,15 @@ export default { </script> <template> - <li :id="groupDomId" :class="rowClass" class="group-row" @click.stop="onClickRowGroup"> + <li + :id="groupDomId" + :class="rowClass" + class="group-row" + :itemprop="microdata.itemprop" + :itemtype="microdata.itemtype" + :itemscope="microdata.itemscope" + @click.stop="onClickRowGroup" + > <div :class="{ 'project-row-contents': !isGroup }" class="group-row-contents d-flex align-items-center py-2 pr-3" @@ -118,7 +129,13 @@ export default { class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 " > <a :href="group.relativePath" class="no-expand"> - <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s40" /> + <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> @@ -127,9 +144,11 @@ export default { <div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3"> <a v-gl-tooltip.bottom + data-testid="group-name" :href="group.relativePath" :title="group.fullName" class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!" + :itemprop="microdata.nameItemprop" >{{ // ending bracket must be by closing tag to prevent // link hover text-decoration from over-extending @@ -146,7 +165,12 @@ export default { </span> </div> <div v-if="group.description" class="description"> - <span v-html="group.description"> </span> + <span + :itemprop="microdata.descriptionItemprop" + data-testid="group-description" + v-html="group.description" + > + </span> </div> </div> <div v-if="isGroupPendingRemoval"> diff --git a/app/assets/javascripts/groups/components/visibility_level_dropdown.vue b/app/assets/javascripts/groups/components/visibility_level_dropdown.vue new file mode 100644 index 00000000000..ff0f8c3ff46 --- /dev/null +++ b/app/assets/javascripts/groups/components/visibility_level_dropdown.vue @@ -0,0 +1,48 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + visibilityLevelOptions: { + type: Array, + required: true, + }, + defaultLevel: { + type: Number, + required: true, + }, + }, + data() { + return { + selectedOption: this.getDefaultOption(), + }; + }, + methods: { + getDefaultOption() { + return this.visibilityLevelOptions.find(option => option.level === this.defaultLevel); + }, + onClick(option) { + this.selectedOption = option; + }, + }, +}; +</script> +<template> + <div> + <input type="hidden" name="group[visibility_level]" :value="selectedOption.level" /> + <gl-dropdown :text="selectedOption.label" class="gl-w-full" menu-class="gl-w-full! gl-mb-0"> + <gl-dropdown-item + v-for="option in visibilityLevelOptions" + :key="option.level" + :secondary-text="option.description" + @click="onClick(option)" + > + <div class="gl-font-weight-bold gl-mb-1">{{ option.label }}</div> + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index 522f1d16df2..e11c3aaf984 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -47,8 +47,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { data() { const { dataset } = dataEl || this.$options.el; const hideProjects = parseBoolean(dataset.hideProjects); + const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup); const service = new GroupsService(endpoint || dataset.endpoint); - const store = new GroupsStore(hideProjects); + const store = new GroupsStore({ hideProjects, showSchemaMarkup }); return { action, diff --git a/app/assets/javascripts/groups/members/components/app.vue b/app/assets/javascripts/groups/members/components/app.vue index 2e6dd4a0bad..f6f3a955813 100644 --- a/app/assets/javascripts/groups/members/components/app.vue +++ b/app/assets/javascripts/groups/members/components/app.vue @@ -1,13 +1,16 @@ <script> import { mapState, mapMutations } from 'vuex'; import { GlAlert } from '@gitlab/ui'; -import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; +import MembersTable from '~/members/components/table/members_table.vue'; +import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; -import { HIDE_ERROR } from '~/vuex_shared/modules/members/mutation_types'; +import { HIDE_ERROR } from '~/members/store/mutation_types'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'GroupMembersApp', - components: { MembersTable, GlAlert }, + components: { MembersTable, FilterSortContainer, GlAlert }, + mixins: [glFeatureFlagsMixin()], computed: { ...mapState(['showError', 'errorMessage']), }, @@ -33,6 +36,7 @@ export default { <gl-alert v-if="showError" ref="errorAlert" variant="danger" @dismiss="hideError">{{ errorMessage }}</gl-alert> + <filter-sort-container v-if="glFeatures.groupMembersFilteredSearch" /> <members-table /> </div> </template> diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js index cb28fb057c9..9ce0e3c1179 100644 --- a/app/assets/javascripts/groups/members/index.js +++ b/app/assets/javascripts/groups/members/index.js @@ -3,9 +3,18 @@ import Vuex from 'vuex'; import { GlToast } from '@gitlab/ui'; import { parseDataAttributes } from 'ee_else_ce/groups/members/utils'; import App from './components/app.vue'; -import membersModule from '~/vuex_shared/modules/members'; +import membersStore from '~/members/store'; -export const initGroupMembersApp = (el, tableFields, tableAttrs, requestFormatter) => { +export const initGroupMembersApp = ( + el, + { + tableFields = [], + tableAttrs = {}, + tableSortableFields = [], + requestFormatter = () => {}, + filteredSearchBar = { show: false }, + }, +) => { if (!el) { return () => {}; } @@ -13,15 +22,17 @@ export const initGroupMembersApp = (el, tableFields, tableAttrs, requestFormatte Vue.use(Vuex); Vue.use(GlToast); - const store = new Vuex.Store({ - ...membersModule({ + const store = new Vuex.Store( + membersStore({ ...parseDataAttributes(el), currentUserId: gon.current_user_id || null, tableFields, tableAttrs, + tableSortableFields, requestFormatter, + filteredSearchBar, }), - }); + ); return new Vue({ el, diff --git a/app/assets/javascripts/groups/members/utils.js b/app/assets/javascripts/groups/members/utils.js index 662eecc4e38..2d584556bbc 100644 --- a/app/assets/javascripts/groups/members/utils.js +++ b/app/assets/javascripts/groups/members/utils.js @@ -1,5 +1,5 @@ import { isUndefined } from 'lodash'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { GROUP_MEMBER_BASE_PROPERTY_NAME, GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME, @@ -8,12 +8,13 @@ import { } from './constants'; export const parseDataAttributes = el => { - const { members, groupId, memberPath } = el.dataset; + const { members, groupId, memberPath, canManageMembers } = el.dataset; return { members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }), sourceId: parseInt(groupId, 10), memberPath, + canManageMembers: parseBoolean(canManageMembers), }; }; diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js index 6a1197fa163..b6cea38e87f 100644 --- a/app/assets/javascripts/groups/store/groups_store.js +++ b/app/assets/javascripts/groups/store/groups_store.js @@ -1,11 +1,13 @@ import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils'; +import { getGroupItemMicrodata } from './utils'; export default class GroupsStore { - constructor(hideProjects) { + constructor({ hideProjects = false, showSchemaMarkup = false } = {}) { this.state = {}; this.state.groups = []; this.state.pageInfo = {}; this.hideProjects = hideProjects; + this.showSchemaMarkup = showSchemaMarkup; } setGroups(rawGroups) { @@ -94,6 +96,7 @@ export default class GroupsStore { starCount: rawGroupItem.star_count, updatedAt: rawGroupItem.updated_at, pendingRemoval: rawGroupItem.marked_for_deletion, + microdata: this.showSchemaMarkup ? getGroupItemMicrodata(rawGroupItem) : {}, }; } diff --git a/app/assets/javascripts/groups/store/utils.js b/app/assets/javascripts/groups/store/utils.js new file mode 100644 index 00000000000..371b3aa9d52 --- /dev/null +++ b/app/assets/javascripts/groups/store/utils.js @@ -0,0 +1,27 @@ +export const getGroupItemMicrodata = ({ type }) => { + const defaultMicrodata = { + itemscope: true, + itemtype: 'https://schema.org/Thing', + itemprop: 'owns', + imageItemprop: 'image', + nameItemprop: 'name', + descriptionItemprop: 'description', + }; + + switch (type) { + case 'group': + return { + ...defaultMicrodata, + itemtype: 'https://schema.org/Organization', + itemprop: 'subOrganization', + imageItemprop: 'logo', + }; + case 'project': + return { + ...defaultMicrodata, + itemtype: 'https://schema.org/SoftwareSourceCode', + }; + default: + return defaultMicrodata; + } +}; diff --git a/app/assets/javascripts/groups/visibility_level.js b/app/assets/javascripts/groups/visibility_level.js new file mode 100644 index 00000000000..d570b5e65ac --- /dev/null +++ b/app/assets/javascripts/groups/visibility_level.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import VisibilityLevelDropdown from './components/visibility_level_dropdown.vue'; + +export default () => { + const el = document.querySelector('.js-visibility-level-dropdown'); + + if (!el) { + return null; + } + + const { visibilityLevelOptions, defaultLevel } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(VisibilityLevelDropdown, { + props: { + visibilityLevelOptions: JSON.parse(visibilityLevelOptions), + defaultLevel: Number(defaultLevel), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index aac23db8fd6..29af8c77d25 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -4,97 +4,107 @@ import axios from './lib/utils/axios_utils'; import Api from './api'; import { normalizeHeaders } from './lib/utils/common_utils'; import { __ } from '~/locale'; +import { loadCSSFile } from './lib/utils/css_utils'; + +const fetchGroups = params => { + axios[params.type.toLowerCase()](params.url, { + params: params.data, + }) + .then(res => { + const results = res.data || []; + const headers = normalizeHeaders(res.headers); + const currentPage = parseInt(headers['X-PAGE'], 10) || 0; + const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; + const more = currentPage < totalPages; + + params.success({ + results, + pagination: { + more, + }, + }); + }) + .catch(params.error); +}; const groupsSelect = () => { - // Needs to be accessible in rspec - window.GROUP_SELECT_PER_PAGE = 20; - $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { - const $select = $(this); - const allAvailable = $select.data('allAvailable'); - const skipGroups = $select.data('skipGroups') || []; - const parentGroupID = $select.data('parentId'); - const groupsPath = parentGroupID - ? Api.subgroupsPath.replace(':id', parentGroupID) - : Api.groupsPath; + loadCSSFile(gon.select2_css_path) + .then(() => { + // Needs to be accessible in rspec + window.GROUP_SELECT_PER_PAGE = 20; - $select.select2({ - placeholder: __('Search for a group'), - allowClear: $select.hasClass('allowClear'), - multiple: $select.hasClass('multiselect'), - minimumInputLength: 0, - ajax: { - url: Api.buildUrl(groupsPath), - dataType: 'json', - quietMillis: 250, - transport(params) { - axios[params.type.toLowerCase()](params.url, { - params: params.data, - }) - .then(res => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const currentPage = parseInt(headers['X-PAGE'], 10) || 0; - const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; - const more = currentPage < totalPages; + $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { + const $select = $(this); + const allAvailable = $select.data('allAvailable'); + const skipGroups = $select.data('skipGroups') || []; + const parentGroupID = $select.data('parentId'); + const groupsPath = parentGroupID + ? Api.subgroupsPath.replace(':id', parentGroupID) + : Api.groupsPath; - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); - }, - data(search, page) { - return { - search, - page, - per_page: window.GROUP_SELECT_PER_PAGE, - all_available: allAvailable, - }; - }, - results(data, page) { - if (data.length) return { results: [] }; + $select.select2({ + placeholder: __('Search for a group'), + allowClear: $select.hasClass('allowClear'), + multiple: $select.hasClass('multiselect'), + minimumInputLength: 0, + ajax: { + url: Api.buildUrl(groupsPath), + dataType: 'json', + quietMillis: 250, + transport(params) { + fetchGroups(params); + }, + data(search, page) { + return { + search, + page, + per_page: window.GROUP_SELECT_PER_PAGE, + all_available: allAvailable, + }; + }, + results(data, page) { + if (data.length) return { results: [] }; - const groups = data.length ? data : data.results || []; - const more = data.pagination ? data.pagination.more : false; - const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); + const groups = data.length ? data : data.results || []; + const more = data.pagination ? data.pagination.more : false; + const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); - return { - results, - page, - more, - }; - }, - }, - // eslint-disable-next-line consistent-return - initSelection(element, callback) { - const id = $(element).val(); - if (id !== '') { - return Api.group(id, callback); - } - }, - formatResult(object) { - return `<div class='group-result'> <div class='group-name'>${escape( - object.full_name, - )}</div> <div class='group-path'>${object.full_path}</div> </div>`; - }, - formatSelection(object) { - return escape(object.full_name); - }, - dropdownCssClass: 'ajax-groups-dropdown select2-infinite', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, - }); + return { + results, + page, + more, + }; + }, + }, + // eslint-disable-next-line consistent-return + initSelection(element, callback) { + const id = $(element).val(); + if (id !== '') { + return Api.group(id, callback); + } + }, + formatResult(object) { + return `<div class='group-result'> <div class='group-name'>${escape( + object.full_name, + )}</div> <div class='group-path'>${object.full_path}</div> </div>`; + }, + formatSelection(object) { + return escape(object.full_name); + }, + dropdownCssClass: 'ajax-groups-dropdown select2-infinite', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, + }); - $select.on('select2-loaded', () => { - const dropdown = document.querySelector('.select2-infinite .select2-results'); - dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; - }); - }); + $select.on('select2-loaded', () => { + const dropdown = document.querySelector('.select2-infinite .select2-results'); + dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; + }); + }); + }) + .catch(() => {}); }; export default () => { diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 1cedb557d46..9f9708bf879 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import Vue from 'vue'; -import { GlToast } from '@gitlab/ui'; import Translate from '~/vue_shared/translate'; import { highCountTrim } from '~/lib/utils/text_utility'; import Tracking from '~/tracking'; @@ -35,7 +34,6 @@ function initStatusTriggers() { const statusModalElement = document.createElement('div'); setStatusModalWrapperEl.appendChild(statusModalElement); - Vue.use(GlToast); Vue.use(Translate); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/helpers/issuables_helper.js b/app/assets/javascripts/helpers/issuables_helper.js deleted file mode 100644 index 52d0f7e43fc..00000000000 --- a/app/assets/javascripts/helpers/issuables_helper.js +++ /dev/null @@ -1,27 +0,0 @@ -import CloseReopenReportToggle from '../close_reopen_report_toggle'; - -function initCloseReopenReport() { - const container = document.querySelector('.js-issuable-close-dropdown'); - - if (!container) return undefined; - - const dropdownTrigger = container.querySelector('.js-issuable-close-toggle'); - const dropdownList = container.querySelector('.js-issuable-close-menu'); - const button = container.querySelector('.js-issuable-close-button'); - - const closeReopenReportToggle = new CloseReopenReportToggle({ - dropdownTrigger, - dropdownList, - button, - }); - - closeReopenReportToggle.initDroplab(); - - return closeReopenReportToggle; -} - -const IssuablesHelper = { - initCloseReopenReport, -}; - -export default IssuablesHelper; diff --git a/app/assets/javascripts/how_to_merge.js b/app/assets/javascripts/how_to_merge.js deleted file mode 100644 index bb734246584..00000000000 --- a/app/assets/javascripts/how_to_merge.js +++ /dev/null @@ -1,15 +0,0 @@ -import $ from 'jquery'; - -export default () => { - const modal = $('#modal_merge_info'); - - if (modal) { - modal.modal({ - modal: true, - show: false, - }); - - $('.how_to_merge_link').on('click', modal.show); - $('.modal-header .close').on('click', modal.hide); - } -}; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 9d2deb1d4d0..7c3e522a488 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -134,15 +134,17 @@ export default { @after-enter="afterEndTransition" > <div v-if="isCompact" ref="compactEl" class="commit-form-compact"> - <button + <gl-button :disabled="!someUncommittedChanges" - type="button" - class="btn btn-primary btn-sm btn-block qa-begin-commit-button" + category="primary" + variant="info" + block + class="qa-begin-commit-button" data-testid="begin-commit-button" @click="beginCommit" > {{ __('Commit…') }} - </button> + </gl-button> <p class="text-center bold">{{ overviewText }}</p> </div> <form v-else ref="formEl" @submit.prevent.stop="commit"> @@ -158,28 +160,21 @@ export default { <gl-button :loading="submitCommitLoading" class="float-left qa-commit-button" - size="small" category="primary" variant="success" @click="commit" > {{ __('Commit') }} </gl-button> - <button - v-if="!discardDraftButtonDisabled" - type="button" - class="btn btn-default btn-sm float-right" - @click="discardDraft" - > + <gl-button v-if="!discardDraftButtonDisabled" class="float-right" @click="discardDraft"> {{ __('Discard draft') }} - </button> + </gl-button> <gl-button v-else type="button" class="float-right" category="secondary" variant="default" - size="small" @click="toggleIsCompact" > {{ __('Collapse') }} diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 7d08815b033..8f0e5aef456 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -1,13 +1,9 @@ <script> import { GlIcon, GlPopover } from '@gitlab/ui'; import { __, sprintf } from '../../../locale'; -import popover from '../../../vue_shared/directives/popover'; import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants'; export default { - directives: { - popover, - }, components: { GlIcon, GlPopover, diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index dec8aa61838..52593aabfea 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -1,11 +1,12 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { viewerTypes } from '../constants'; export default { components: { - GlButton, + GlDropdown, + GlDropdownItem, }, props: { viewer: { @@ -18,10 +19,21 @@ export default { }, }, computed: { - mergeReviewLine() { - return sprintf(__('Reviewing (merge request !%{mergeRequestId})'), { - mergeRequestId: this.mergeRequestId, - }); + modeDropdownItems() { + return [ + { + viewerType: this.$options.viewerTypes.mr, + title: sprintf(__('Reviewing (merge request !%{mergeRequestId})'), { + mergeRequestId: this.mergeRequestId, + }), + content: __('Compare changes with the merge request target branch'), + }, + { + viewerType: this.$options.viewerTypes.diff, + title: __('Reviewing'), + content: __('Compare changes with the last commit'), + }, + ]; }, }, methods: { @@ -34,39 +46,16 @@ export default { </script> <template> - <div class="dropdown"> - <gl-button variant="link" data-toggle="dropdown">{{ __('Edit') }}</gl-button> - <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> - <ul> - <li> - <a - :class="{ - 'is-active': viewer === $options.viewerTypes.mr, - }" - href="#" - @click.prevent="changeMode($options.viewerTypes.mr)" - > - <strong class="dropdown-menu-inner-title"> {{ mergeReviewLine }} </strong> - <span class="dropdown-menu-inner-content"> - {{ __('Compare changes with the merge request target branch') }} - </span> - </a> - </li> - <li> - <a - :class="{ - 'is-active': viewer === $options.viewerTypes.diff, - }" - href="#" - @click.prevent="changeMode($options.viewerTypes.diff)" - > - <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong> - <span class="dropdown-menu-inner-content"> - {{ __('Compare changes with the last commit') }} - </span> - </a> - </li> - </ul> - </div> - </div> + <gl-dropdown :text="__('Edit')" size="small"> + <gl-dropdown-item + v-for="mode in modeDropdownItems" + :key="mode.viewerType" + :is-check-item="true" + :is-checked="viewer === mode.viewerType" + @click="changeMode(mode.viewerType)" + > + <strong class="dropdown-menu-inner-title"> {{ mode.title }} </strong> + <span class="dropdown-menu-inner-content"> {{ mode.content }} </span> + </gl-dropdown-item> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue index cfd2555b769..5d5b66a6444 100644 --- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -86,7 +86,7 @@ export default { type="search" class="dropdown-input-field qa-dropdown-filter-input" /> - <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" /> + <gl-icon name="search" class="dropdown-input-search" /> </div> <div class="dropdown-content"> <gl-loading-icon v-if="showLoading" size="lg" /> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index e1d2895831a..f8568f46cd6 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,14 +1,13 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { WEBIDE_MARK_APP_START, WEBIDE_MARK_FILE_FINISH, WEBIDE_MARK_FILE_CLICKED, - WEBIDE_MARK_TREE_FINISH, - WEBIDE_MEASURE_TREE_FROM_REQUEST, - WEBIDE_MEASURE_FILE_FROM_REQUEST, WEBIDE_MEASURE_FILE_AFTER_INTERACTION, + WEBIDE_MEASURE_BEFORE_VUE, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import { modalTypes } from '../constants'; @@ -19,12 +18,6 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { measurePerformance } from '../utils'; -eventHub.$on(WEBIDE_MEASURE_TREE_FROM_REQUEST, () => - measurePerformance(WEBIDE_MARK_TREE_FINISH, WEBIDE_MEASURE_TREE_FROM_REQUEST), -); -eventHub.$on(WEBIDE_MEASURE_FILE_FROM_REQUEST, () => - measurePerformance(WEBIDE_MARK_FILE_FINISH, WEBIDE_MEASURE_FILE_FROM_REQUEST), -); eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () => measurePerformance( WEBIDE_MARK_FILE_FINISH, @@ -37,15 +30,17 @@ export default { components: { IdeSidebar, RepoEditor, - 'error-message': () => import('./error_message.vue'), - 'gl-button': () => import('@gitlab/ui/src/components/base/button/button.vue'), - 'gl-loading-icon': () => import('@gitlab/ui/src/components/base/loading_icon/loading_icon.vue'), - 'commit-editor-header': () => import('./commit_sidebar/editor_header.vue'), - 'repo-tabs': () => import('./repo_tabs.vue'), - 'ide-status-bar': () => import('./ide_status_bar.vue'), - 'find-file': () => import('~/vue_shared/components/file_finder/index.vue'), - 'right-pane': () => import('./panes/right.vue'), - 'new-modal': () => import('./new_dropdown/modal.vue'), + GlButton, + GlLoadingIcon, + ErrorMessage: () => import(/* webpackChunkName: 'ide_runtime' */ './error_message.vue'), + CommitEditorHeader: () => + import(/* webpackChunkName: 'ide_runtime' */ './commit_sidebar/editor_header.vue'), + RepoTabs: () => import(/* webpackChunkName: 'ide_runtime' */ './repo_tabs.vue'), + IdeStatusBar: () => import(/* webpackChunkName: 'ide_runtime' */ './ide_status_bar.vue'), + FindFile: () => + import(/* webpackChunkName: 'ide_runtime' */ '~/vue_shared/components/file_finder/index.vue'), + RightPane: () => import(/* webpackChunkName: 'ide_runtime' */ './panes/right.vue'), + NewModal: () => import(/* webpackChunkName: 'ide_runtime' */ './new_dropdown/modal.vue'), }, mixins: [glFeatureFlagsMixin()], data() { @@ -84,7 +79,14 @@ export default { document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`); }, beforeCreate() { - performanceMarkAndMeasure({ mark: WEBIDE_MARK_APP_START }); + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_APP_START, + measures: [ + { + name: WEBIDE_MEASURE_BEFORE_VUE, + }, + ], + }); }, methods: { ...mapActions(['toggleFileFinder']), diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 99215d6c3f1..135b28685ed 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -14,8 +14,10 @@ export default { ResizablePanel, ActivityBar, IdeTree, - [leftSidebarViews.review.name]: () => import('./ide_review.vue'), - [leftSidebarViews.commit.name]: () => import('./repo_commit_section.vue'), + [leftSidebarViews.review.name]: () => + import(/* webpackChunkName: 'ide_runtime' */ './ide_review.vue'), + [leftSidebarViews.commit.name]: () => + import(/* webpackChunkName: 'ide_runtime' */ './repo_commit_section.vue'), CommitForm, IdeProjectHeader, }, diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index e7e94f5b5da..b67881b14f4 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -2,17 +2,13 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import FileTree from '~/vue_shared/components/file_tree.vue'; -import { - WEBIDE_MARK_TREE_START, - WEBIDE_MEASURE_TREE_FROM_REQUEST, - WEBIDE_MARK_FILE_CLICKED, -} from '~/performance/constants'; +import { WEBIDE_MARK_FILE_CLICKED } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; -import eventHub from '../eventhub'; import IdeFileRow from './ide_file_row.vue'; import NavDropdown from './nav_dropdown.vue'; export default { + name: 'IdeTreeList', components: { GlSkeletonLoading, NavDropdown, @@ -39,14 +35,6 @@ export default { } }, }, - beforeCreate() { - performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START }); - }, - updated() { - if (this.currentTree?.tree?.length) { - eventHub.$emit(WEBIDE_MEASURE_TREE_FROM_REQUEST); - } - }, methods: { ...mapActions(['toggleTreeOpen']), clickedFile() { diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index d65304034c2..7f07a5dbe43 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -92,7 +92,7 @@ export default { class="controllers-buttons" target="_blank" > - <gl-icon name="doc-text" aria-hidden="true" /> + <gl-icon name="doc-text" /> </a> <scroll-button :disabled="isScrolledToTop" direction="up" @click="scrollUp" /> <scroll-button :disabled="isScrolledToBottom" direction="down" @click="scrollDown" /> diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue index 2307efd1d24..8cea8655461 100644 --- a/app/assets/javascripts/ide/components/nav_dropdown.vue +++ b/app/assets/javascripts/ide/components/nav_dropdown.vue @@ -30,6 +30,7 @@ export default { .on('hide.bs.dropdown', () => this.hideDropdown()); }, removeDropdownListeners() { + // eslint-disable-next-line @gitlab/no-global-event-off $(this.$refs.dropdown) .off('show.bs.dropdown') .off('hide.bs.dropdown'); @@ -45,7 +46,7 @@ export default { </script> <template> - <div ref="dropdown" class="btn-group ide-nav-dropdown dropdown"> + <div ref="dropdown" class="btn-group ide-nav-dropdown dropdown" data-testid="ide-nav-dropdown"> <nav-dropdown-button :show-merge-requests="canReadMergeRequests" /> <div class="dropdown-menu dropdown-menu-left p-0"> <nav-form v-if="isVisibleDropdown" :show-merge-requests="canReadMergeRequests" /> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 5ad836f346a..22eefb6634f 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -137,6 +137,7 @@ export default { ref="modal" modal-id="ide-new-entry" data-qa-selector="new_file_modal" + data-testid="ide-new-entry" :title="modalTitle" :ok-title="buttonLabel" ok-variant="success" diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 6f15773c9ab..a4a13389fbf 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -8,6 +8,7 @@ import { GlTabs, GlTab, GlBadge, + GlAlert, } from '@gitlab/ui'; import { sprintf, __ } from '../../../locale'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; @@ -26,6 +27,7 @@ export default { GlTabs, GlTab, GlBadge, + GlAlert, }, directives: { SafeHtml, @@ -89,11 +91,16 @@ export default { :can-set-ci="true" class="mb-auto mt-auto" /> - <div v-else-if="latestPipeline.yamlError" class="bs-callout bs-callout-danger"> + <gl-alert + v-else-if="latestPipeline.yamlError" + variant="danger" + :dismissible="false" + class="gl-mt-5" + > <p class="gl-mb-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p> <p class="gl-mb-0 break-word">{{ latestPipeline.yamlError }}</p> <p v-safe-html="ciLintText" class="gl-mb-0"></p> - </div> + </gl-alert> <gl-tabs v-else> <gl-tab :active="!pipelineFailed"> <template #title> diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index 3852f2fdfa4..f65b1201d94 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -152,7 +152,7 @@ export default { </script> <template> - <div class="preview h-100 w-100 d-flex flex-column"> + <div class="preview h-100 w-100 d-flex flex-column gl-bg-white"> <template v-if="showPreview"> <navigator :manager="manager" /> <div id="ide-preview"></div> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index c8a825065f1..1f029612c29 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -6,9 +6,10 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { WEBIDE_MARK_FILE_CLICKED, - WEBIDE_MARK_FILE_START, + WEBIDE_MARK_REPO_EDITOR_START, + WEBIDE_MARK_REPO_EDITOR_FINISH, + WEBIDE_MEASURE_REPO_EDITOR, WEBIDE_MEASURE_FILE_AFTER_INTERACTION, - WEBIDE_MEASURE_FILE_FROM_REQUEST, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import eventHub from '../eventhub'; @@ -28,6 +29,7 @@ import { getRulesWithTraversal } from '../lib/editorconfig/parser'; import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; export default { + name: 'RepoEditor', components: { ContentViewer, DiffViewer, @@ -175,9 +177,6 @@ export default { } }, }, - beforeCreate() { - performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_START }); - }, beforeDestroy() { this.editor.dispose(); }, @@ -204,6 +203,7 @@ export default { ]), ...mapActions('editor', ['updateFileEditor']), initEditor() { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_REPO_EDITOR_START }); if (this.shouldHideEditor && (this.file.content || this.file.raw)) { return; } @@ -305,7 +305,15 @@ export default { if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) { eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION); } else { - eventHub.$emit(WEBIDE_MEASURE_FILE_FROM_REQUEST); + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_REPO_EDITOR_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_REPO_EDITOR, + start: WEBIDE_MARK_REPO_EDITOR_START, + }, + ], + }); } }, refreshEditorDimensions() { diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue index a8fe9ea6866..0e67a2ab45f 100644 --- a/app/assets/javascripts/ide/components/terminal/session.vue +++ b/app/assets/javascripts/ide/components/terminal/session.vue @@ -1,5 +1,6 @@ <script> import { mapActions, mapState } from 'vuex'; +import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; import Terminal from './terminal.vue'; import { isEndingStatus } from '../../stores/modules/terminal/utils'; @@ -7,6 +8,7 @@ import { isEndingStatus } from '../../stores/modules/terminal/utils'; export default { components: { Terminal, + GlButton, }, computed: { ...mapState('terminal', ['session']), @@ -14,15 +16,17 @@ export default { if (isEndingStatus(this.session.status)) { return { action: () => this.restartSession(), + variant: 'info', + category: 'primary', text: __('Restart Terminal'), - class: 'btn-primary', }; } return { action: () => this.stopSession(), + variant: 'danger', + category: 'secondary', text: __('Stop Terminal'), - class: 'btn-inverted btn-remove', }; }, }, @@ -37,15 +41,13 @@ export default { <header class="ide-job-header d-flex align-items-center"> <h5>{{ __('Web Terminal') }}</h5> <div class="ml-auto align-self-center"> - <button + <gl-button v-if="actionButton" - type="button" - class="btn btn-sm" - :class="actionButton.class" + :variant="actionButton.variant" + :category="actionButton.category" @click="actionButton.action" + >{{ actionButton.text }}</gl-button > - {{ actionButton.text }} - </button> </div> </header> <terminal :terminal-path="session.terminalPath" :status="session.status" /> diff --git a/app/assets/javascripts/ide/components/terminal/view.vue b/app/assets/javascripts/ide/components/terminal/view.vue index db97e95eed9..fcf23eb1f73 100644 --- a/app/assets/javascripts/ide/components/terminal/view.vue +++ b/app/assets/javascripts/ide/components/terminal/view.vue @@ -1,12 +1,11 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import EmptyState from './empty_state.vue'; -import TerminalSession from './session.vue'; export default { components: { EmptyState, - TerminalSession, + TerminalSession: () => import(/* webpackChunkName: 'ide_terminal' */ './session.vue'), }, computed: { ...mapState('terminal', ['isShowSplash', 'paths']), diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 396aedbfa10..b9ebacef7e1 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -3,6 +3,12 @@ import IdeRouter from '~/ide/ide_router_extension'; import { joinPaths } from '~/lib/utils/url_utility'; import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { + WEBIDE_MARK_FETCH_PROJECT_DATA_START, + WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH, + WEBIDE_MEASURE_FETCH_PROJECT_DATA, +} from '~/performance/constants'; import { syncRouterAndStore } from './sync_router_and_store'; Vue.use(IdeRouter); @@ -69,6 +75,7 @@ export const createRouter = store => { router.beforeEach((to, from, next) => { if (to.params.namespace && to.params.project) { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_START }); store .dispatch('getProjectData', { namespace: to.params.namespace, @@ -81,6 +88,15 @@ export const createRouter = store => { const mergeRequestId = to.params.mrid; if (branchId) { + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FETCH_PROJECT_DATA, + start: WEBIDE_MARK_FETCH_PROJECT_DATA_START, + }, + ], + }); store.dispatch('openBranch', { projectId, branchId, diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 56d48e87c18..62f49ba56b1 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import { mapActions } from 'vuex'; import { identity } from 'lodash'; import Translate from '~/vue_shared/translate'; +import PerformancePlugin from '~/performance/vue_performance_plugin'; import ide from './components/ide.vue'; import { createStore } from './stores'; import { createRouter } from './ide_router'; @@ -11,6 +12,10 @@ import { DEFAULT_THEME } from './lib/themes'; Vue.use(Translate); +Vue.use(PerformancePlugin, { + components: ['FileTree'], +}); + /** * Function that receives the default store and returns an extended one. * @callback extendStoreCallback diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index c5bb00c3dee..2471b3627ce 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -1,7 +1,8 @@ import { editor as monacoEditor, Uri } from 'monaco-editor'; import Disposable from './disposable'; import eventHub from '../../eventhub'; -import { trimTrailingWhitespace, insertFinalNewline } from '../../utils'; +import { trimTrailingWhitespace } from '../../utils'; +import { insertFinalNewline } from '~/lib/utils/text_utility'; import { defaultModelOptions } from '../editor_options'; export default class Model { diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 1496170447d..710256b6377 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -3,6 +3,12 @@ import { escape } from 'lodash'; import { __, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import { deprecatedCreateFlash as flash } from '~/flash'; +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { + WEBIDE_MARK_FETCH_BRANCH_DATA_START, + WEBIDE_MARK_FETCH_BRANCH_DATA_FINISH, + WEBIDE_MEASURE_FETCH_BRANCH_DATA, +} from '~/performance/constants'; import * as types from './mutation_types'; import { decorateFiles } from '../lib/files'; import { stageKeys, commitActionTypes } from '../constants'; @@ -245,13 +251,23 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name, dispatch('triggerFilesChange', { type: commitActionTypes.move, path, newPath }); }; -export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) => - new Promise((resolve, reject) => { +export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) => { + return new Promise((resolve, reject) => { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_BRANCH_DATA_START }); const currentProject = state.projects[projectId]; if (!currentProject || !currentProject.branches[branchId] || force) { service .getBranchData(projectId, branchId) .then(({ data }) => { + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_FETCH_BRANCH_DATA_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FETCH_BRANCH_DATA, + start: WEBIDE_MARK_FETCH_BRANCH_DATA_START, + }, + ], + }); const { id } = data.commit; commit(types.SET_BRANCH, { projectPath: projectId, @@ -291,6 +307,7 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force = resolve(currentProject.branches[branchId]); } }); +}; export * from './actions/tree'; export * from './actions/file'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 4b9b958ddd6..8b43c7238fd 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -1,5 +1,11 @@ import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { + WEBIDE_MARK_FETCH_FILE_DATA_START, + WEBIDE_MARK_FETCH_FILE_DATA_FINISH, + WEBIDE_MEASURE_FETCH_FILE_DATA, +} from '~/performance/constants'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; @@ -61,6 +67,7 @@ export const getFileData = ( { state, commit, dispatch, getters }, { path, makeFileActive = true, openFile = makeFileActive, toggleLoading = true }, ) => { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_FILE_DATA_START }); const file = state.entries[path]; const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); @@ -81,6 +88,15 @@ export const getFileData = ( return service .getFileData(url) .then(({ data }) => { + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_FETCH_FILE_DATA_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FETCH_FILE_DATA, + start: WEBIDE_MARK_FETCH_FILE_DATA_START, + }, + ], + }); if (data) commit(types.SET_FILE_DATA, { data, file }); if (openFile) commit(types.TOGGLE_FILE_OPEN, path); @@ -150,6 +166,13 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = export const changeFileContent = ({ commit, state, getters }, { path, content }) => { const file = state.entries[path]; + + // It's possible for monaco to hit a race condition where it tries to update renamed files. + // See issue https://gitlab.com/gitlab-org/gitlab/-/issues/284930 + if (!file) { + return; + } + commit(types.UPDATE_FILE_CONTENT, { path, content, diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 3a7daf30cc4..23a5e26bc1c 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -1,4 +1,10 @@ import { defer } from 'lodash'; +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { + WEBIDE_MARK_FETCH_FILES_FINISH, + WEBIDE_MEASURE_FETCH_FILES, + WEBIDE_MARK_FETCH_FILES_START, +} from '~/performance/constants'; import { __ } from '../../../locale'; import service from '../../services'; import * as types from '../mutation_types'; @@ -46,8 +52,9 @@ export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeL }); }; -export const getFiles = ({ state, commit, dispatch }, payload = {}) => - new Promise((resolve, reject) => { +export const getFiles = ({ state, commit, dispatch }, payload = {}) => { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_FILES_START }); + return new Promise((resolve, reject) => { const { projectId, branchId, ref = branchId } = payload; if ( @@ -61,6 +68,15 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) => service .getFiles(selectedProject.path_with_namespace, ref) .then(({ data }) => { + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_FETCH_FILES_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FETCH_FILES, + start: WEBIDE_MARK_FETCH_FILES_START, + }, + ], + }); const { entries, treeList } = decorateFiles({ data }); commit(types.SET_ENTRIES, entries); @@ -85,6 +101,7 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) => resolve(); } }); +}; export const restoreTree = ({ dispatch, commit, state }, path) => { const entry = state.entries[path]; diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 1ca1b971de1..43276f32322 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -97,10 +97,6 @@ export function trimTrailingWhitespace(content) { return content.replace(/[^\S\r\n]+$/gm, ''); } -export function insertFinalNewline(content, eol = '\n') { - return content.slice(-eol.length) !== eol ? `${content}${eol}` : content; -} - export function getPathParents(path, maxDepth = Infinity) { const pathComponents = path.split('/'); const paths = []; diff --git a/app/assets/javascripts/import_projects/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index 9e3347a657f..9e3347a657f 100644 --- a/app/assets/javascripts/import_projects/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue diff --git a/app/assets/javascripts/import_projects/constants.js b/app/assets/javascripts/import_entities/constants.js index ad33ca158d2..ad33ca158d2 100644 --- a/app/assets/javascripts/import_projects/constants.js +++ b/app/assets/javascripts/import_entities/constants.js 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 new file mode 100644 index 00000000000..153c58b556e --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -0,0 +1,78 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; +import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; +import setTargetNamespaceMutation from '../graphql/mutations/set_target_namespace.mutation.graphql'; +import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql'; +import importGroupMutation from '../graphql/mutations/import_group.mutation.graphql'; +import ImportTableRow from './import_table_row.vue'; + +const mapApolloMutations = mutations => + Object.fromEntries( + Object.entries(mutations).map(([key, mutation]) => [ + key, + function mutate(config) { + return this.$apollo.mutate({ + mutation, + ...config, + }); + }, + ]), + ); + +export default { + components: { + GlLoadingIcon, + ImportTableRow, + }, + + apollo: { + bulkImportSourceGroups: bulkImportSourceGroupsQuery, + availableNamespaces: availableNamespacesQuery, + }, + + methods: { + ...mapApolloMutations({ + setTargetNamespace: setTargetNamespaceMutation, + setNewName: setNewNameMutation, + importGroup: importGroupMutation, + }), + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> + <div v-else-if="bulkImportSourceGroups.length"> + <table class="gl-w-full"> + <thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1"> + <th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th> + <th class="gl-py-4 import-jobs-to-col">{{ s__('BulkImport|To new group') }}</th> + <th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th> + <th class="gl-py-4 import-jobs-cta-col"></th> + </thead> + <tbody> + <template v-for="group in bulkImportSourceGroups"> + <import-table-row + :key="group.id" + :group="group" + :available-namespaces="availableNamespaces" + @update-target-namespace=" + setTargetNamespace({ + variables: { sourceGroupId: group.id, targetNamespace: $event }, + }) + " + @update-new-name=" + setNewName({ + variables: { sourceGroupId: group.id, newName: $event }, + }) + " + @import-group="importGroup({ variables: { sourceGroupId: group.id } })" + /> + </template> + </tbody> + </table> + </div> + </div> +</template> 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 new file mode 100644 index 00000000000..07603d89f0f --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue @@ -0,0 +1,106 @@ +<script> +import { GlButton, GlIcon, GlLink, GlFormInput } from '@gitlab/ui'; +import { joinPaths } from '~/lib/utils/url_utility'; +import Select2Select from '~/vue_shared/components/select2_select.vue'; +import ImportStatus from '../../components/import_status.vue'; +import { STATUSES } from '../../constants'; + +export default { + components: { + Select2Select, + ImportStatus, + GlButton, + GlLink, + GlIcon, + GlFormInput, + }, + props: { + group: { + type: Object, + required: true, + }, + availableNamespaces: { + type: Array, + required: true, + }, + }, + computed: { + isDisabled() { + return this.group.status !== STATUSES.NONE; + }, + + isFinished() { + return this.group.status === STATUSES.FINISHED; + }, + + select2Options() { + return { + data: this.availableNamespaces.map(namespace => ({ + id: namespace.full_path, + text: namespace.full_path, + })), + }; + }, + }, + methods: { + getPath(group) { + return `${group.import_target.target_namespace}/${group.import_target.new_name}`; + }, + + getFullPath(group) { + return joinPaths(gon.relative_url_root || '/', this.getPath(group)); + }, + }, +}; +</script> + +<template> + <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1"> + <td class="gl-p-4"> + <gl-link :href="group.web_url" target="_blank"> + {{ group.full_path }} <gl-icon name="external-link" /> + </gl-link> + </td> + <td class="gl-p-4"> + <gl-link v-if="isFinished" :href="getFullPath(group)">{{ getPath(group) }}</gl-link> + + <div + v-else + class="import-entities-target-select gl-display-flex gl-align-items-stretch" + :class="{ + disabled: isDisabled, + }" + > + <select2-select + :disabled="isDisabled" + :options="select2Options" + :value="group.import_target.target_namespace" + @input="$emit('update-target-namespace', $event)" + /> + <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" + > + / + </div> + <gl-form-input + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + :disabled="isDisabled" + :value="group.import_target.new_name" + @input="$emit('update-new-name', $event)" + /> + </div> + </td> + <td class="gl-p-4 gl-white-space-nowrap"> + <import-status :status="group.status" /> + </td> + <td class="gl-p-4"> + <gl-button + v-if="!isDisabled" + variant="success" + category="secondary" + @click="$emit('import-group')" + >{{ __('Import') }}</gl-button + > + </td> + </tr> +</template> diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js new file mode 100644 index 00000000000..4fcaa1b55fc --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -0,0 +1,95 @@ +import axios from '~/lib/utils/axios_utils'; +import createDefaultClient from '~/lib/graphql'; +import { s__ } from '~/locale'; +import createFlash from '~/flash'; +import { STATUSES } from '../../constants'; +import availableNamespacesQuery from './queries/available_namespaces.query.graphql'; +import { SourceGroupsManager } from './services/source_groups_manager'; +import { StatusPoller } from './services/status_poller'; + +export const clientTypenames = { + BulkImportSourceGroup: 'ClientBulkImportSourceGroup', + AvailableNamespace: 'ClientAvailableNamespace', +}; + +export function createResolvers({ endpoints }) { + let statusPoller; + + return { + Query: { + async bulkImportSourceGroups(_, __, { client }) { + const { + data: { availableNamespaces }, + } = await client.query({ query: availableNamespacesQuery }); + + return axios.get(endpoints.status).then(({ data }) => { + return data.importable_data.map(group => ({ + __typename: clientTypenames.BulkImportSourceGroup, + ...group, + status: STATUSES.NONE, + import_target: { + new_name: group.full_path, + target_namespace: availableNamespaces[0].full_path, + }, + })); + }); + }, + + availableNamespaces: () => + axios.get(endpoints.availableNamespaces).then(({ data }) => + data.map(namespace => ({ + __typename: clientTypenames.AvailableNamespace, + ...namespace, + })), + ), + }, + Mutation: { + setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) { + new SourceGroupsManager({ client }).updateById(sourceGroupId, sourceGroup => { + // eslint-disable-next-line no-param-reassign + sourceGroup.import_target.target_namespace = targetNamespace; + }); + }, + + setNewName(_, { newName, sourceGroupId }, { client }) { + new SourceGroupsManager({ client }).updateById(sourceGroupId, sourceGroup => { + // eslint-disable-next-line no-param-reassign + sourceGroup.import_target.new_name = newName; + }); + }, + + async importGroup(_, { sourceGroupId }, { client }) { + const groupManager = new SourceGroupsManager({ client }); + const group = groupManager.findById(sourceGroupId); + groupManager.setImportStatus(group, STATUSES.SCHEDULING); + try { + await axios.post(endpoints.createBulkImport, { + bulk_import: [ + { + source_type: 'group_entity', + source_full_path: group.full_path, + destination_namespace: group.import_target.target_namespace, + destination_name: group.import_target.new_name, + }, + ], + }); + groupManager.setImportStatus(group, STATUSES.STARTED); + if (!statusPoller) { + statusPoller = new StatusPoller({ client, interval: 3000 }); + statusPoller.startPolling(); + } + } catch (e) { + createFlash({ + message: s__('BulkImport|Importing the group failed'), + }); + + groupManager.setImportStatus(group, STATUSES.NONE); + throw e; + } + }, + }, + }; +} + +export const createApolloClient = ({ endpoints }) => + createDefaultClient(createResolvers({ endpoints }), { assumeImmutableResults: true }); diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql new file mode 100644 index 00000000000..50774e36599 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql @@ -0,0 +1,8 @@ +fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup { + id + web_url + full_path + full_name + status + import_target +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql new file mode 100644 index 00000000000..412608d3faf --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql @@ -0,0 +1,3 @@ +mutation importGroup($sourceGroupId: String!) { + importGroup(sourceGroupId: $sourceGroupId) @client +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql new file mode 100644 index 00000000000..2bc19891401 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql @@ -0,0 +1,3 @@ +mutation setNewName($newName: String!, $sourceGroupId: String!) { + setNewName(newName: $newName, sourceGroupId: $sourceGroupId) @client +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql new file mode 100644 index 00000000000..fc98a1652c1 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql @@ -0,0 +1,3 @@ +mutation setTargetNamespace($targetNamespace: String!, $sourceGroupId: String!) { + setTargetNamespace(targetNamespace: $targetNamespace, sourceGroupId: $sourceGroupId) @client +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql new file mode 100644 index 00000000000..5ab9796b50a --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql @@ -0,0 +1,6 @@ +query availableNamespaces { + availableNamespaces @client { + id + full_path + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql new file mode 100644 index 00000000000..8d52d94925c --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql @@ -0,0 +1,7 @@ +#import "../fragments/bulk_import_source_group_item.fragment.graphql" + +query bulkImportSourceGroups { + bulkImportSourceGroups @client { + ...BulkImportSourceGroupItem + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js new file mode 100644 index 00000000000..f752ecc8cd6 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js @@ -0,0 +1,45 @@ +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import produce from 'immer'; +import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql'; + +function extractTypeConditionFromFragment(fragment) { + return fragment.definitions[0]?.typeCondition.name.value; +} + +function generateGroupId(id) { + return defaultDataIdFromObject({ + __typename: extractTypeConditionFromFragment(ImportSourceGroupFragment), + id, + }); +} + +export class SourceGroupsManager { + constructor({ client }) { + this.client = client; + } + + findById(id) { + const cacheId = generateGroupId(id); + return this.client.readFragment({ fragment: ImportSourceGroupFragment, id: cacheId }); + } + + update(group, fn) { + this.client.writeFragment({ + fragment: ImportSourceGroupFragment, + id: generateGroupId(group.id), + data: produce(group, fn), + }); + } + + updateById(id, fn) { + const group = this.findById(id); + this.update(group, fn); + } + + setImportStatus(group, status) { + this.update(group, sourceGroup => { + // eslint-disable-next-line no-param-reassign + sourceGroup.status = status; + }); + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js new file mode 100644 index 00000000000..5d2922b0ba8 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js @@ -0,0 +1,68 @@ +import gql from 'graphql-tag'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import bulkImportSourceGroupsQuery from '../queries/bulk_import_source_groups.query.graphql'; +import { STATUSES } from '../../../constants'; +import { SourceGroupsManager } from './source_groups_manager'; + +const groupId = i => `group${i}`; + +function generateGroupsQuery(groups) { + return gql`{ + ${groups + .map( + (g, idx) => + `${groupId(idx)}: group(fullPath: "${g.import_target.target_namespace}/${ + g.import_target.new_name + }") { id }`, + ) + .join('\n')} + }`; +} + +export class StatusPoller { + constructor({ client, interval }) { + this.client = client; + this.interval = interval; + this.timeoutId = null; + this.groupManager = new SourceGroupsManager({ client }); + } + + startPolling() { + if (this.timeoutId) { + return; + } + + this.checkPendingImports(); + } + + stopPolling() { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + + async checkPendingImports() { + try { + const { bulkImportSourceGroups } = this.client.readQuery({ + query: bulkImportSourceGroupsQuery, + }); + const groupsInProgress = bulkImportSourceGroups.filter(g => g.status === STATUSES.STARTED); + if (groupsInProgress.length) { + const { data: results } = await this.client.query({ + query: generateGroupsQuery(groupsInProgress), + fetchPolicy: 'no-cache', + }); + const completedGroups = groupsInProgress.filter((_, idx) => Boolean(results[groupId(idx)])); + completedGroups.forEach(group => { + this.groupManager.setImportStatus(group, STATUSES.FINISHED); + }); + } + } catch (e) { + createFlash({ + message: s__('BulkImport|Update of import statuses with realtime changes failed'), + }); + } finally { + this.timeoutId = setTimeout(() => this.checkPendingImports(), this.interval); + } + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js new file mode 100644 index 00000000000..bf427075564 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import Translate from '~/vue_shared/translate'; +import { createApolloClient } from './graphql/client_factory'; +import ImportTable from './components/import_table.vue'; + +Vue.use(Translate); +Vue.use(VueApollo); + +export function mountImportGroupsApp(mountElement) { + if (!mountElement) return undefined; + + const { statusPath, availableNamespacesPath, createBulkImportPath } = mountElement.dataset; + const apolloProvider = new VueApollo({ + defaultClient: createApolloClient({ + endpoints: { + status: statusPath, + availableNamespaces: availableNamespacesPath, + createBulkImport: createBulkImportPath, + }, + }), + }); + + return new Vue({ + el: mountElement, + apolloProvider, + render(createElement) { + return createElement(ImportTable); + }, + }); +} diff --git a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue b/app/assets/javascripts/import_entities/import_projects/components/bitbucket_status_table.vue index bc8aa522596..bc8aa522596 100644 --- a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/bitbucket_status_table.vue diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index 96100e4ac0c..2b6b8b765a2 100644 --- a/app/assets/javascripts/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -178,11 +178,7 @@ export default { :key="pagePaginationStateKey" @appear="fetchRepos" /> - <gl-loading-icon - v-if="isLoading" - class="js-loading-button-icon import-projects-loading-icon" - size="md" - /> + <gl-loading-icon v-if="isLoading" class="import-projects-loading-icon" size="md" /> <div v-if="!isLoading && repositories.length === 0" class="text-center"> <strong>{{ emptyStateText }}</strong> diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index 18971313dfe..983abda57f7 100644 --- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -3,8 +3,8 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import { GlIcon, GlBadge } from '@gitlab/ui'; import Select2Select from '~/vue_shared/components/select2_select.vue'; import { __ } from '~/locale'; -import ImportStatus from './import_status.vue'; -import { STATUSES } from '../constants'; +import ImportStatus from '../../components/import_status.vue'; +import { STATUSES } from '../../constants'; import { isProjectImportable, isIncompatible, getImportStatus } from '../utils'; export default { diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js index 79fbd58e355..7373b628f2b 100644 --- a/app/assets/javascripts/import_projects/index.js +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -1,8 +1,7 @@ import Vue from 'vue'; -import Translate from '../vue_shared/translate'; +import Translate from '~/vue_shared/translate'; +import { parseBoolean } from '~/lib/utils/common_utils'; import ImportProjectsTable from './components/import_projects_table.vue'; -import { parseBoolean } from '../lib/utils/common_utils'; -import { queryToObject } from '../lib/utils/url_utility'; import createStore from './store'; Vue.use(Translate); @@ -20,18 +19,12 @@ export function initStoreFromElement(element) { paginatable, } = element.dataset; - const params = queryToObject(document.location.search); - const page = parseInt(params.page ?? 1, 10); - return createStore({ initialState: { defaultTargetNamespace: gon.current_username, ciCdOnly: parseBoolean(ciCdOnly), canSelectNamespace: parseBoolean(canSelectNamespace), provider, - pageInfo: { - page, - }, }, endpoints: { reposPath, diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js index 7b7afd13c55..7b7afd13c55 100644 --- a/app/assets/javascripts/import_projects/store/actions.js +++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_entities/import_projects/store/getters.js index b76c52beea2..31e22b50554 100644 --- a/app/assets/javascripts/import_projects/store/getters.js +++ b/app/assets/javascripts/import_entities/import_projects/store/getters.js @@ -1,4 +1,4 @@ -import { STATUSES } from '../constants'; +import { STATUSES } from '../../constants'; import { isProjectImportable, isIncompatible } from '../utils'; export const isLoading = state => state.isLoadingRepos || state.isLoadingNamespaces; diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_entities/import_projects/store/index.js index 7ba12f81eb9..7ba12f81eb9 100644 --- a/app/assets/javascripts/import_projects/store/index.js +++ b/app/assets/javascripts/import_entities/import_projects/store/index.js diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js index 6adf5e59cff..6adf5e59cff 100644 --- a/app/assets/javascripts/import_projects/store/mutation_types.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js index 6999253d4b2..3d718a6a386 100644 --- a/app/assets/javascripts/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import * as types from './mutation_types'; -import { STATUSES } from '../constants'; +import { STATUSES } from '../../constants'; const makeNewImportedProject = importedProject => ({ importSource: { diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_entities/import_projects/store/state.js index ecd93561d52..ecd93561d52 100644 --- a/app/assets/javascripts/import_projects/store/state.js +++ b/app/assets/javascripts/import_entities/import_projects/store/state.js diff --git a/app/assets/javascripts/import_projects/utils.js b/app/assets/javascripts/import_entities/import_projects/utils.js index 695b12cbcba..0610117e09b 100644 --- a/app/assets/javascripts/import_projects/utils.js +++ b/app/assets/javascripts/import_entities/import_projects/utils.js @@ -1,4 +1,4 @@ -import { STATUSES } from './constants'; +import { STATUSES } from '../constants'; export function isIncompatible(project) { return project.importSource.incompatible; diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js deleted file mode 100644 index 078c50ee9c6..00000000000 --- a/app/assets/javascripts/importer_status.js +++ /dev/null @@ -1,149 +0,0 @@ -import $ from 'jquery'; -import { escape } from 'lodash'; -import { __, sprintf } from './locale'; -import axios from './lib/utils/axios_utils'; -import { deprecatedCreateFlash as flash } from './flash'; -import { parseBoolean, spriteIcon } from './lib/utils/common_utils'; - -class ImporterStatus { - constructor({ jobsUrl, importUrl, ciCdOnly }) { - this.jobsUrl = jobsUrl; - this.importUrl = importUrl; - this.ciCdOnly = ciCdOnly; - this.initStatusPage(); - this.setAutoUpdate(); - } - - initStatusPage() { - $('.js-add-to-import') - .off('click') - .on('click', this.addToImport.bind(this)); - - $('.js-import-all') - .off('click') - .on('click', function onClickImportAll() { - const $btn = $(this); - $btn.disable().addClass('is-loading'); - return $('.js-add-to-import').each(function triggerAddImport() { - return $(this).trigger('click'); - }); - }); - } - - addToImport(event) { - const $btn = $(event.currentTarget); - const $tr = $btn.closest('tr'); - const $targetField = $tr.find('.import-target'); - const $namespaceInput = $targetField.find('.js-select-namespace option:selected'); - const repoData = $tr.data(); - const id = repoData.id || $tr.attr('id').replace('repo_', ''); - - let targetNamespace; - let newName; - if ($namespaceInput.length > 0) { - targetNamespace = $namespaceInput[0].innerHTML; - newName = $targetField.find('#path').prop('value'); - $targetField.empty().append(`${targetNamespace}/${newName}`); - } - $btn.disable().addClass('is-loading'); - - this.id = id; - - let attributes = { - repo_id: id, - target_namespace: targetNamespace, - new_name: newName, - ci_cd_only: this.ciCdOnly, - }; - - if (repoData) { - attributes = Object.assign(repoData, attributes); - } - - return axios - .post(this.importUrl, attributes) - .then(({ data }) => { - const job = $tr; - job.attr('id', `project_${data.id}`); - - job.find('.import-target').html(`<a href="${data.full_path}">${data.full_path}</a>`); - $('table.import-jobs tbody').prepend(job); - - job.addClass('table-active'); - const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing'); - job.find('.import-actions').html( - sprintf( - escape(__('%{loadingIcon} Started')), - { - loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${escape( - connectingVerb, - )}"></i>`, - }, - false, - ), - ); - }) - .catch(error => { - let details = error; - - const $statusField = $tr.find('.job-status'); - $statusField.text(__('Failed')); - - if (error.response && error.response.data && error.response.data.errors) { - details = error.response.data.errors; - } - - flash(sprintf(__('An error occurred while importing project: %{details}'), { details })); - }); - } - - autoUpdate() { - return axios.get(this.jobsUrl).then(({ data = [] }) => { - data.forEach(job => { - const jobItem = $(`#project_${job.id}`); - const statusField = jobItem.find('.job-status'); - - const spinner = '<i class="fa fa-spinner fa-spin"></i>'; - - switch (job.import_status) { - case 'finished': - jobItem.removeClass('table-active').addClass('table-success'); - statusField.html(`<span>${spriteIcon('check', 's16')} ${__('Done')}</span>`); - break; - case 'scheduled': - statusField.html(`${spinner} ${__('Scheduled')}`); - break; - case 'started': - statusField.html(`${spinner} ${__('Started')}`); - break; - case 'failed': - statusField.html(__('Failed')); - break; - default: - statusField.html(job.import_status); - break; - } - }); - }); - } - - setAutoUpdate() { - setInterval(this.autoUpdate.bind(this), 4000); - } -} - -// eslint-disable-next-line consistent-return -function initImporterStatus() { - const importerStatus = document.querySelector('.js-importer-status'); - - if (importerStatus) { - const data = importerStatus.dataset; - return new ImporterStatus({ - jobsUrl: data.jobsImportPath, - importUrl: data.importPath, - ciCdOnly: parseBoolean(data.ciCdOnly), - }); - } -} - -export { initImporterStatus as default, ImporterStatus }; diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/incidents_settings/components/alerts_form.vue index 5fe0badc56e..e8daad8811e 100644 --- a/app/assets/javascripts/incidents_settings/components/alerts_form.vue +++ b/app/assets/javascripts/incidents_settings/components/alerts_form.vue @@ -86,7 +86,7 @@ export default { <form ref="settingsForm" @submit.prevent="updateAlertsIntegrationSettings"> <gl-form-group class="gl-pl-0"> <gl-form-checkbox v-model="createIssueEnabled" data-qa-selector="create_issue_checkbox"> - <span>{{ $options.i18n.createIssue.label }}</span> + <span>{{ $options.i18n.createIncident.label }}</span> </gl-form-checkbox> </gl-form-group> @@ -96,7 +96,7 @@ export default { class="col-8 col-md-9 gl-px-6" > <label class="gl-display-inline-flex" for="alert-integration-settings-issue-template"> - {{ $options.i18n.issueTemplate.label }} + {{ $options.i18n.incidentTemplate.label }} <gl-link :href="$options.ISSUE_TEMPLATES_DOCS_LINK" target="_blank"> <gl-icon name="question" :size="12" /> </gl-link> diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue index 9a8c4bc5af9..b56dd66342a 100644 --- a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue +++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue @@ -109,7 +109,20 @@ export default { {{ webhookUpdateAlertMsg }} </gl-alert> - <p>{{ $options.i18n.introText }}</p> + <p> + <gl-sprintf :message="$options.i18n.introText"> + <template #link="{ content }"> + <gl-link + :href="$options.CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK" + target="_blank" + class="gl-display-inline-flex" + > + <span>{{ content }}</span> + <gl-icon name="external-link" /> + </gl-link> + </template> + </gl-sprintf> + </p> <form ref="settingsForm" @submit.prevent="updatePagerDutyIntegrationSettings"> <gl-form-group class="col-8 col-md-9 gl-p-0"> <gl-toggle @@ -134,23 +147,9 @@ export default { </template> </gl-form-input-group> - <div class="gl-text-gray-200 gl-pt-2"> - <gl-sprintf :message="$options.i18n.webhookUrl.helpText"> - <template #docsLink> - <gl-link - :href="$options.CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK" - target="_blank" - class="gl-display-inline-flex" - > - <span>{{ $options.i18n.webhookUrl.helpDocsLink }}</span> - <gl-icon name="external-link" /> - </gl-link> - </template> - </gl-sprintf> - </div> <gl-button v-gl-modal.resetWebhookModal - class="gl-mt-3" + class="gl-mt-5" :disabled="loading" :loading="resettingWebhook" data-testid="webhook-reset-btn" diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js index 42f1f645d16..fcac9c519c2 100644 --- a/app/assets/javascripts/incidents_settings/constants.js +++ b/app/assets/javascripts/incidents_settings/constants.js @@ -33,17 +33,17 @@ export const I18N_ALERT_SETTINGS_FORM = { saveBtnLabel: __('Save changes'), introText: __('Action to take when receiving an alert. %{docsLink}'), introLinkText: __('More information.'), - createIssue: { - label: __('Create an issue. Issues are created for each alert triggered.'), + createIncident: { + label: __('Create an incident. Incidents are created for each alert triggered.'), }, - issueTemplate: { - label: __('Issue template (optional)'), + incidentTemplate: { + label: __('Incident template (optional)'), }, sendEmail: { label: __('Send a separate email notification to Developers.'), }, autoCloseIncidents: { - label: __('Automatically close incident issues when the associated Prometheus alert resolves.'), + label: __('Automatically close incidents when the associated Prometheus alert resolves.'), }, }; @@ -57,17 +57,13 @@ export const ISSUE_TEMPLATES_DOCS_LINK = export const I18N_PAGERDUTY_SETTINGS_FORM = { introText: s__( - 'PagerDutySettings|Setting up a webhook with PagerDuty will automatically create a GitLab issue for each PagerDuty incident.', + 'PagerDutySettings|Create a GitLab incident for each PagerDuty incident by %{linkStart}configuring a webhook in PagerDuty%{linkEnd}', ), activeToggle: { label: s__('PagerDutySettings|Active'), }, webhookUrl: { label: s__('PagerDutySettings|Webhook URL'), - helpText: s__( - 'PagerDutySettings|Create a GitLab issue for each PagerDuty incident by %{docsLink}', - ), - helpDocsLink: s__('PagerDutySettings|configuring a webhook in PagerDuty'), resetWebhookUrl: s__('PagerDutySettings|Reset webhook URL'), copyToClipboard: __('Copy'), updateErrMsg: s__('PagerDutySettings|Failed to update Webhook URL'), diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index bbfa865905a..c6f8ba8dcb2 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -33,7 +33,14 @@ export default { mixins: [glFeatureFlagsMixin()], computed: { ...mapGetters(['currentKey', 'propsSource', 'isDisabled']), - ...mapState(['defaultState', 'override', 'isSaving', 'isTesting', 'isResetting']), + ...mapState([ + 'defaultState', + 'customState', + 'override', + 'isSaving', + 'isTesting', + 'isResetting', + ]), isEditable() { return this.propsSource.editable; }, @@ -42,8 +49,8 @@ export default { }, isInstanceOrGroupLevel() { return ( - this.propsSource.integrationLevel === integrationLevels.INSTANCE || - this.propsSource.integrationLevel === integrationLevels.GROUP + this.customState.integrationLevel === integrationLevels.INSTANCE || + this.customState.integrationLevel === integrationLevels.GROUP ); }, showJiraIssuesFields() { @@ -52,9 +59,18 @@ export default { showReset() { return this.isInstanceOrGroupLevel && this.propsSource.resetPath; }, + saveButtonKey() { + return `save-button-${this.isDisabled}`; + }, }, methods: { - ...mapActions(['setOverride', 'setIsSaving', 'setIsTesting', 'setIsResetting']), + ...mapActions([ + 'setOverride', + 'setIsSaving', + 'setIsTesting', + 'setIsResetting', + 'fetchResetIntegration', + ]), onSaveClick() { this.setIsSaving(true); eventHub.$emit('saveIntegration'); @@ -63,7 +79,9 @@ export default { this.setIsTesting(true); eventHub.$emit('testIntegration'); }, - onResetClick() {}, + onResetClick() { + this.fetchResetIntegration(); + }, }, }; </script> @@ -102,6 +120,7 @@ export default { <div v-if="isEditable" class="footer-block row-content-block"> <template v-if="isInstanceOrGroupLevel"> <gl-button + :key="saveButtonKey" v-gl-modal.confirmSaveIntegration category="primary" variant="success" @@ -115,6 +134,7 @@ export default { </template> <gl-button v-else + :key="saveButtonKey" category="primary" variant="success" type="submit" diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js index 097304be242..421917b720a 100644 --- a/app/assets/javascripts/integrations/edit/store/actions.js +++ b/app/assets/javascripts/integrations/edit/store/actions.js @@ -1,3 +1,5 @@ +import axios from 'axios'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; import * as types from './mutation_types'; export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override); @@ -5,3 +7,22 @@ export const setIsSaving = ({ commit }, isSaving) => commit(types.SET_IS_SAVING, export const setIsTesting = ({ commit }, isTesting) => commit(types.SET_IS_TESTING, isTesting); export const setIsResetting = ({ commit }, isResetting) => commit(types.SET_IS_RESETTING, isResetting); + +export const requestResetIntegration = ({ commit }) => { + commit(types.REQUEST_RESET_INTEGRATION); +}; +export const receiveResetIntegrationSuccess = () => { + refreshCurrentPage(); +}; +export const receiveResetIntegrationError = ({ commit }) => { + commit(types.RECEIVE_RESET_INTEGRATION_ERROR); +}; + +export const fetchResetIntegration = ({ dispatch, getters }) => { + dispatch('requestResetIntegration'); + + return axios + .post(getters.propsSource.resetPath, { params: { format: 'json' } }) + .then(() => dispatch('receiveResetIntegrationSuccess')) + .catch(() => dispatch('receiveResetIntegrationError')); +}; diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js index 2a84408f658..54928148b22 100644 --- a/app/assets/javascripts/integrations/edit/store/mutation_types.js +++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js @@ -2,3 +2,6 @@ export const SET_OVERRIDE = 'SET_OVERRIDE'; export const SET_IS_SAVING = 'SET_IS_SAVING'; export const SET_IS_TESTING = 'SET_IS_TESTING'; export const SET_IS_RESETTING = 'SET_IS_RESETTING'; + +export const REQUEST_RESET_INTEGRATION = 'REQUEST_RESET_INTEGRATION'; +export const RECEIVE_RESET_INTEGRATION_ERROR = 'RECEIVE_RESET_INTEGRATION_ERROR'; diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js index 07e3e25ccf0..826757e665b 100644 --- a/app/assets/javascripts/integrations/edit/store/mutations.js +++ b/app/assets/javascripts/integrations/edit/store/mutations.js @@ -13,4 +13,10 @@ export default { [types.SET_IS_RESETTING](state, isResetting) { state.isResetting = isResetting; }, + [types.REQUEST_RESET_INTEGRATION](state) { + state.isResetting = true; + }, + [types.RECEIVE_RESET_INTEGRATION_ERROR](state) { + state.isResetting = false; + }, }; diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index 1d0814125e6..14d6f133d27 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -35,12 +35,14 @@ export default class IntegrationSettingsForm { } saveIntegration() { - // Service was marked active so now we check; + // Save Service if not active and check the following if active; // 1) If form contents are valid // 2) If this service can be saved // If both conditions are true, we override form submission // and save the service using provided configuration. - if (this.$form.get(0).checkValidity()) { + const formValid = this.$form.get(0).checkValidity() || this.formActive === false; + + if (formValid) { this.$form.submit(); } else { eventHub.$emit('validateForm'); diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js index e0fb58ef195..12f03873958 100644 --- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadCSSFile } from '../lib/utils/css_utils'; let instanceCount = 0; @@ -13,10 +14,15 @@ class AutoWidthDropdownSelect { const { dropdownClass } = this; import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - this.$selectElement.select2({ - dropdownCssClass: dropdownClass, - ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), - }); + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), + }); + }) + .catch(() => {}); }) .catch(() => {}); diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index c7806fc17fc..6ba21cd7869 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -15,6 +15,7 @@ export default { }, bindEvents() { + // eslint-disable-next-line @gitlab/no-global-event-off return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); }, diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 6f2bd2da078..2072e41514d 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import UsersSelect from './users_select'; +import { loadCSSFile } from './lib/utils/css_utils'; export default class IssuableContext { constructor(currentUser) { @@ -10,10 +11,15 @@ export default class IssuableContext { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + }) + .catch(() => {}); }) .catch(() => {}); diff --git a/app/assets/javascripts/issuable_create/components/issuable_create_root.vue b/app/assets/javascripts/issuable_create/components/issuable_create_root.vue index 1ef42976032..f4cbaba9313 100644 --- a/app/assets/javascripts/issuable_create/components/issuable_create_root.vue +++ b/app/assets/javascripts/issuable_create/components/issuable_create_root.vue @@ -29,7 +29,7 @@ export default { <template> <div class="issuable-create-container"> <slot name="title"></slot> - <hr /> + <hr class="gl-mt-0" /> <issuable-form :description-preview-path="descriptionPreviewPath" :description-help-path="descriptionHelpPath" diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index ed34e2f5623..791b5fef699 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -7,6 +7,7 @@ import ZenMode from './zen_mode'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; import { queryToObject, objectToQuery } from './lib/utils/url_utility'; +import { loadCSSFile } from './lib/utils/css_utils'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; const MR_TARGET_BRANCH = 'merge_request[target_branch]'; @@ -184,36 +185,41 @@ export default class IssuableForm { initTargetBranchDropdown() { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - this.$targetBranchSelect.select2({ - ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), - ajax: { - url: this.$targetBranchSelect.data('endpoint'), - dataType: 'JSON', - quietMillis: 250, - data(search) { - return { - search, - }; - }, - results(data) { - return { - // `data` keys are translated so we can't just access them with a string based key - results: data[Object.keys(data)[0]].map(name => ({ - id: name, - text: name, - })), - }; - }, - }, - initSelection(el, callback) { - const val = el.val(); - - callback({ - id: val, - text: val, + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + this.$targetBranchSelect.select2({ + ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), + ajax: { + url: this.$targetBranchSelect.data('endpoint'), + dataType: 'JSON', + quietMillis: 250, + data(search) { + return { + search, + }; + }, + results(data) { + return { + // `data` keys are translated so we can't just access them with a string based key + results: data[Object.keys(data)[0]].map(name => ({ + id: name, + text: name, + })), + }; + }, + }, + initSelection(el, callback) { + const val = el.val(); + + callback({ + id: val, + text: val, + }); + }, }); - }, - }); + }) + .catch(() => {}); }) .catch(() => {}); } diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 1ee794ab208..583e5cb703d 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -128,7 +128,7 @@ export default { <template> <li class="issue gl-px-5!"> - <div class="issue-box"> + <div class="issuable-info-container"> <div v-if="showCheckbox" class="issue-check"> <gl-form-checkbox class="gl-mr-0" @@ -136,101 +136,99 @@ export default { @input="$emit('checked-input', $event)" /> </div> - <div class="issuable-info-container"> - <div class="issuable-main-info"> - <div data-testid="issuable-title" class="issue-title title"> - <span class="issue-title-text" dir="auto"> - <gl-link :href="issuable.webUrl" v-bind="issuableTitleProps" - >{{ issuable.title - }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" - /></gl-link> - </span> - </div> - <div class="issuable-info"> - <slot v-if="hasSlotContents('reference')" name="reference"></slot> - <span v-else data-testid="issuable-reference" class="issuable-reference" - >{{ issuableSymbol }}{{ issuable.iid }}</span - > - <span class="issuable-authored d-none d-sm-inline-block"> - · - <span - v-gl-tooltip:tooltipcontainer.bottom - data-testid="issuable-created-at" - :title="tooltipTitle(issuable.createdAt)" - >{{ createdAt }}</span - > - {{ __('by') }} - <slot v-if="hasSlotContents('author')" name="author"></slot> - <gl-link - v-else - :data-user-id="authorId" - :data-username="author.username" - :data-name="author.name" - :data-avatar-url="author.avatarUrl" - :href="author.webUrl" - data-testid="issuable-author" - class="author-link js-user-link" - > - <span class="author">{{ author.name }}</span> - </gl-link> - </span> - <slot name="timeframe"></slot> - - <gl-label - v-for="(label, index) in labels" - :key="index" - :background-color="label.color" - :title="labelTitle(label)" - :description="label.description" - :scoped="scopedLabel(label)" - :target="labelTarget(label)" - :class="{ 'gl-ml-2': index }" - size="sm" - /> - </div> + <div class="issuable-main-info"> + <div data-testid="issuable-title" class="issue-title title"> + <span class="issue-title-text" dir="auto"> + <gl-link :href="issuable.webUrl" v-bind="issuableTitleProps" + >{{ issuable.title + }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" + /></gl-link> + </span> </div> - <div class="issuable-meta"> - <ul v-if="showIssuableMeta" class="controls"> - <li v-if="hasSlotContents('status')" class="issuable-status"> - <slot name="status"></slot> - </li> - <li - v-if="showDiscussions" - data-testid="issuable-discussions" - class="issuable-comments gl-display-none gl-display-sm-block" - > - <gl-link - v-gl-tooltip:tooltipcontainer.top - :title="__('Comments')" - :href="`${issuable.webUrl}#notes`" - :class="{ 'no-comments': !issuable.userDiscussionsCount }" - class="gl-reset-color!" - > - <gl-icon name="comments" /> - {{ issuable.userDiscussionsCount }} - </gl-link> - </li> - <li v-if="assignees.length" class="gl-display-flex"> - <issuable-assignees - :assignees="issuable.assignees" - :icon-size="16" - :max-visible="4" - img-css-classes="gl-mr-2!" - class="gl-align-items-center gl-display-flex gl-ml-3" - /> - </li> - </ul> - <div - data-testid="issuable-updated-at" - class="float-right issuable-updated-at d-none d-sm-inline-block" + <div class="issuable-info"> + <slot v-if="hasSlotContents('reference')" name="reference"></slot> + <span v-else data-testid="issuable-reference" class="issuable-reference" + >{{ issuableSymbol }}{{ issuable.iid }}</span > + <span class="issuable-authored d-none d-sm-inline-block"> + · <span v-gl-tooltip:tooltipcontainer.bottom - :title="tooltipTitle(issuable.updatedAt)" - class="issuable-updated-at" - >{{ updatedAt }}</span + data-testid="issuable-created-at" + :title="tooltipTitle(issuable.createdAt)" + >{{ createdAt }}</span + > + {{ __('by') }} + <slot v-if="hasSlotContents('author')" name="author"></slot> + <gl-link + v-else + :data-user-id="authorId" + :data-username="author.username" + :data-name="author.name" + :data-avatar-url="author.avatarUrl" + :href="author.webUrl" + data-testid="issuable-author" + class="author-link js-user-link" + > + <span class="author">{{ author.name }}</span> + </gl-link> + </span> + <slot name="timeframe"></slot> + + <gl-label + v-for="(label, index) in labels" + :key="index" + :background-color="label.color" + :title="labelTitle(label)" + :description="label.description" + :scoped="scopedLabel(label)" + :target="labelTarget(label)" + :class="{ 'gl-ml-2': index }" + size="sm" + /> + </div> + </div> + <div class="issuable-meta"> + <ul v-if="showIssuableMeta" class="controls"> + <li v-if="hasSlotContents('status')" class="issuable-status"> + <slot name="status"></slot> + </li> + <li + v-if="showDiscussions" + data-testid="issuable-discussions" + class="issuable-comments gl-display-none gl-display-sm-block" + > + <gl-link + v-gl-tooltip:tooltipcontainer.top + :title="__('Comments')" + :href="`${issuable.webUrl}#notes`" + :class="{ 'no-comments': !issuable.userDiscussionsCount }" + class="gl-reset-color!" > - </div> + <gl-icon name="comments" /> + {{ issuable.userDiscussionsCount }} + </gl-link> + </li> + <li v-if="assignees.length" class="gl-display-flex"> + <issuable-assignees + :assignees="issuable.assignees" + :icon-size="16" + :max-visible="4" + img-css-classes="gl-mr-2!" + class="gl-align-items-center gl-display-flex gl-ml-3" + /> + </li> + </ul> + <div + data-testid="issuable-updated-at" + class="float-right issuable-updated-at d-none d-sm-inline-block" + > + <span + v-gl-tooltip:tooltipcontainer.bottom + :title="tooltipTitle(issuable.updatedAt)" + class="issuable-updated-at" + >{{ updatedAt }}</span + > </div> </div> </div> diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue index e6a05c1ab8b..c084f328f42 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_body.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue @@ -36,10 +36,18 @@ export default { type: Boolean, required: true, }, + enableAutosave: { + type: Boolean, + required: true, + }, editFormVisible: { type: Boolean, required: true, }, + showFieldTitle: { + type: Boolean, + required: true, + }, descriptionPreviewPath: { type: String, required: true, @@ -57,6 +65,14 @@ export default { return this.issuable.updatedBy; }, }, + methods: { + handleKeydownTitle(e, issuableMeta) { + this.$emit('keydown-title', e, issuableMeta); + }, + handleKeydownDescription(e, issuableMeta) { + this.$emit('keydown-description', e, issuableMeta); + }, + }, }; </script> @@ -67,8 +83,12 @@ export default { v-if="editFormVisible" :issuable="issuable" :enable-autocomplete="enableAutocomplete" + :enable-autosave="enableAutosave" + :show-field-title="showFieldTitle" :description-preview-path="descriptionPreviewPath" :description-help-path="descriptionHelpPath" + @keydown-title="handleKeydownTitle" + @keydown-description="handleKeydownDescription" > <template #edit-form-actions="issuableMeta"> <slot name="edit-form-actions" v-bind="issuableMeta"></slot> diff --git a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue index 7b9a83a740f..93e4db8b99c 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue @@ -23,6 +23,14 @@ export default { type: Boolean, required: true, }, + enableAutosave: { + type: Boolean, + required: true, + }, + showFieldTitle: { + type: Boolean, + required: true, + }, descriptionPreviewPath: { type: String, required: true, @@ -33,19 +41,27 @@ export default { }, }, data() { - const { title, description } = this.issuable; - return { - title, - description, + title: '', + description: '', }; }, + watch: { + issuable: { + handler(value) { + this.title = value?.title || ''; + this.description = value?.description || ''; + }, + deep: true, + immediate: true, + }, + }, created() { eventHub.$on('update.issuable', this.resetAutosave); eventHub.$on('close.form', this.resetAutosave); }, mounted() { - this.initAutosave(); + if (this.enableAutosave) this.initAutosave(); }, beforeDestroy() { eventHub.$off('update.issuable', this.resetAutosave); @@ -73,6 +89,12 @@ export default { this.autosaveTitle.reset(); this.autosaveDescription.reset(); }, + handleKeydown(e, inputType) { + this.$emit(`keydown-${inputType}`, e, { + issuableTitle: this.title, + issuableDescription: this.description, + }); + }, }, }; </script> @@ -82,9 +104,9 @@ export default { <gl-form-group data-testid="title" :label="__('Title')" - :label-sr-only="true" + :label-sr-only="!showFieldTitle" label-for="issuable-title" - class="col-12" + class="col-12 gl-px-0" > <gl-form-input id="issuable-title" @@ -94,14 +116,16 @@ export default { :aria-label="__('Title')" :autofocus="true" class="qa-title-input" + @keydown="handleKeydown($event, 'title')" /> </gl-form-group> <gl-form-group data-testid="description" :label="__('Description')" - :label-sr-only="true" + :label-sr-only="!showFieldTitle" label-for="issuable-description" - class="col-12 common-note-form" + label-class="gl-pb-0!" + class="col-12 gl-px-0 common-note-form" > <markdown-field :markdown-preview-path="descriptionPreviewPath" @@ -120,11 +144,12 @@ export default { class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" dir="auto" + @keydown="handleKeydown($event, 'description')" ></textarea> </template> </markdown-field> </gl-form-group> - <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 clearfix"> + <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 gl-px-0 clearfix"> <slot name="edit-form-actions" :issuable-title="title" diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/issuable_show/components/issuable_header.vue index 3815c50cac6..5404753631d 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_header.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_header.vue @@ -112,7 +112,7 @@ export default { </div> <div data-testid="header-actions" - class="detail-page-header-actions js-issuable-actions js-issuable-buttons gl-display-flex gl-display-md-block" + class="detail-page-header-actions gl-display-flex gl-display-md-block" > <slot name="header-actions"></slot> </div> 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 b41f5e270a8..2443338e8c4 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue @@ -35,11 +35,21 @@ export default { required: false, default: false, }, + enableAutosave: { + type: Boolean, + required: false, + default: true, + }, editFormVisible: { type: Boolean, required: false, default: false, }, + showFieldTitle: { + type: Boolean, + required: false, + default: false, + }, descriptionPreviewPath: { type: String, required: false, @@ -51,6 +61,14 @@ export default { default: '', }, }, + methods: { + handleKeydownTitle(e, issuableMeta) { + this.$emit('keydown-title', e, issuableMeta); + }, + handleKeydownDescription(e, issuableMeta) { + this.$emit('keydown-description', e, issuableMeta); + }, + }, }; </script> @@ -77,10 +95,14 @@ export default { :status-icon="statusIcon" :enable-edit="enableEdit" :enable-autocomplete="enableAutocomplete" + :enable-autosave="enableAutosave" :edit-form-visible="editFormVisible" + :show-field-title="showFieldTitle" :description-preview-path="descriptionPreviewPath" :description-help-path="descriptionHelpPath" @edit-issuable="$emit('edit-issuable', $event)" + @keydown-title="handleKeydownTitle" + @keydown-description="handleKeydownDescription" > <template #status-badge> <slot name="status-badge"></slot> diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index f65d9259e7b..5d2880c3c10 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,41 +1,22 @@ -/* eslint-disable consistent-return */ - import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import { addDelimiter } from './lib/utils/text_utility'; import { deprecatedCreateFlash as flash } from './flash'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; -import IssuablesHelper from './helpers/issuables_helper'; import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from './locale'; export default class Issue { constructor() { - if ($('.btn-close, .btn-reopen').length) this.initIssueBtnEventListeners(); - - if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener(); - if ($('.js-alert-moved-from-service-desk-warning').length) { - const trimmedPathname = window.location.pathname.slice(1); - this.alertMovedFromServiceDeskDismissedKey = joinPaths( - trimmedPathname, - 'alert-issue-moved-from-service-desk-dismissed', - ); - - this.initIssueMovedFromServiceDeskDismissHandler(); + Issue.initIssueMovedFromServiceDeskDismissHandler(); } - Issue.$btnNewBranch = $('#new-branch'); - Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); - if (document.querySelector('#related-branches')) { Issue.initRelatedBranches(); } - this.closeButtons = $('.btn-close'); - this.reopenButtons = $('.btn-reopen'); - - this.initCloseReopenReport(); + Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); if (Issue.createMrDropdownWrap) { this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap); @@ -71,7 +52,6 @@ export default class Issue { isOpenBadge.toggleClass('hidden', isClosed); $(document).trigger('issuable:change', isClosed); - this.toggleCloseReopenButton(isClosed); let numProjectIssues = Number( projectIssuesCounter @@ -91,104 +71,16 @@ export default class Issue { } } - initIssueBtnEventListeners() { - const issueFailMessage = __('Unable to update this issue at this time.'); - - $('.report-abuse-link').on('click', e => { - // this is needed because of the implementation of - // the dropdown toggle and Report Abuse needing to be - // linked to another page. - e.stopPropagation(); - }); - - // NOTE: data attribute seems unnecessary but is actually necessary - return $('.js-issuable-buttons[data-action="close-reopen"]').on( - 'click', - '.btn-close, .btn-reopen, .btn-close-anyway', - e => { - e.preventDefault(); - e.stopImmediatePropagation(); - const $button = $(e.currentTarget); - const shouldSubmit = $button.hasClass('btn-comment'); - if (shouldSubmit) { - Issue.submitNoteForm($button.closest('form')); - } - - const shouldDisplayBlockedWarning = $button.hasClass('btn-issue-blocked'); - const warningBanner = $('.js-close-blocked-issue-warning'); - if (shouldDisplayBlockedWarning) { - this.toggleWarningAndCloseButton(); - } else { - this.disableCloseReopenButton($button); - - const url = $button.data('endpoint'); - - return axios - .put(url) - .then(({ data }) => { - const isClosed = $button.is('.btn-close, .btn-close-anyway'); - this.updateTopState(isClosed, data); - if ($button.hasClass('btn-close-anyway')) { - warningBanner.addClass('hidden'); - if (this.closeReopenReportToggle) - $('.js-issuable-close-dropdown').removeClass('hidden'); - } - }) - .catch(() => flash(issueFailMessage)) - .then(() => { - this.disableCloseReopenButton($button, false); - }); - } - }, - ); - } - - initCloseReopenReport() { - this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport(); - - if (this.closeButtons) this.closeButtons = this.closeButtons.not('.issuable-close-button'); - if (this.reopenButtons) this.reopenButtons = this.reopenButtons.not('.issuable-close-button'); - } - - disableCloseReopenButton($button, shouldDisable) { - if (this.closeReopenReportToggle) { - this.closeReopenReportToggle.setDisable(shouldDisable); - } else { - $button.prop('disabled', shouldDisable); - } - } - - toggleCloseReopenButton(isClosed) { - if (this.closeReopenReportToggle) this.closeReopenReportToggle.updateButton(isClosed); - this.closeButtons.toggleClass('hidden', isClosed); - this.reopenButtons.toggleClass('hidden', !isClosed); - } - - toggleWarningAndCloseButton() { - const warningBanner = $('.js-close-blocked-issue-warning'); - warningBanner.toggleClass('hidden'); - $('.btn-close').toggleClass('hidden'); - if (this.closeReopenReportToggle) { - $('.js-issuable-close-dropdown').toggleClass('hidden'); - } - } + static initIssueMovedFromServiceDeskDismissHandler() { + const alertMovedFromServiceDeskWarning = $('.js-alert-moved-from-service-desk-warning'); - initIssueWarningBtnEventListener() { - return $(document).on( - 'click', - '.js-close-blocked-issue-warning .js-cancel-blocked-issue-warning', - e => { - e.preventDefault(); - e.stopImmediatePropagation(); - this.toggleWarningAndCloseButton(); - }, + const trimmedPathname = window.location.pathname.slice(1); + const alertMovedFromServiceDeskDismissedKey = joinPaths( + trimmedPathname, + 'alert-issue-moved-from-service-desk-dismissed', ); - } - initIssueMovedFromServiceDeskDismissHandler() { - const alertMovedFromServiceDeskWarning = $('.js-alert-moved-from-service-desk-warning'); - - if (!localStorage.getItem(this.alertMovedFromServiceDeskDismissedKey)) { + if (!localStorage.getItem(alertMovedFromServiceDeskDismissedKey)) { alertMovedFromServiceDeskWarning.show(); } @@ -196,20 +88,13 @@ export default class Issue { e.preventDefault(); e.stopImmediatePropagation(); alertMovedFromServiceDeskWarning.remove(); - localStorage.setItem(this.alertMovedFromServiceDeskDismissedKey, true); + localStorage.setItem(alertMovedFromServiceDeskDismissedKey, true); }); } - static submitNoteForm(form) { - const noteText = form.find('textarea.js-note-text').val(); - if (noteText && noteText.trim().length > 0) { - return form.submit(); - } - } - static initRelatedBranches() { const $container = $('#related-branches'); - return axios + axios .get($container.data('url')) .then(({ data }) => { if ('html' in data) { diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index 8a1a8448bb8..ea6e03404e7 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -61,11 +61,7 @@ export default { data-toggle="dropdown" > <span class="dropdown-toggle-text">{{ __('Choose a template') }}</span> - <gl-icon - name="chevron-down" - class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" - aria-hidden="true" - /> + <gl-icon name="chevron-down" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" /> </button> <div class="dropdown-menu dropdown-select"> <div class="dropdown-title gl-display-flex gl-justify-content-center"> @@ -75,7 +71,7 @@ export default { :aria-label="__('Close')" type="button" > - <gl-icon name="close" class="dropdown-menu-close-icon" :aria-hidden="true" /> + <gl-icon name="close" class="dropdown-menu-close-icon" /> </button> </div> <div class="dropdown-input"> @@ -85,7 +81,7 @@ export default { :placeholder="__('Filter')" autocomplete="off" /> - <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" /> + <gl-icon name="search" class="dropdown-input-search" /> <gl-icon name="close" class="dropdown-input-clear js-dropdown-input-clear" diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue index 4c8c86390f4..998f740be0e 100644 --- a/app/assets/javascripts/issue_show/components/header_actions.vue +++ b/app/assets/javascripts/issue_show/components/header_actions.vue @@ -1,12 +1,13 @@ <script> import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash, { FLASH_TYPES } from '~/flash'; import { IssuableType } from '~/issuable_show/constants'; import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; +import eventHub from '~/notes/event_hub'; import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql'; import updateIssueMutation from '../queries/update_issue.mutation.graphql'; @@ -72,15 +73,11 @@ export default { default: '', }, }, - data() { - return { - isUpdatingState: false, - }; - }, computed: { - ...mapGetters(['getNoteableData']), + ...mapState(['isToggleStateButtonLoading']), + ...mapGetters(['openState', 'getBlockedByIssues']), isClosed() { - return this.getNoteableData.state === IssuableStatus.Closed; + return this.openState === IssuableStatus.Closed; }, buttonText() { return this.isClosed @@ -107,9 +104,16 @@ export default { return canClose || canReopen; }, }, + created() { + eventHub.$on('toggle.issuable.state', this.toggleIssueState); + }, + beforeDestroy() { + eventHub.$off('toggle.issuable.state', this.toggleIssueState); + }, methods: { + ...mapActions(['toggleStateButtonLoading']), toggleIssueState() { - if (!this.isClosed && this.getNoteableData?.blocked_by_issues?.length) { + if (!this.isClosed && this.getBlockedByIssues?.length) { this.$refs.blockedByIssuesModal.show(); return; } @@ -117,7 +121,7 @@ export default { this.invokeUpdateIssueMutation(); }, invokeUpdateIssueMutation() { - this.isUpdatingState = true; + this.toggleStateButtonLoading(true); this.$apollo .mutate({ @@ -146,13 +150,13 @@ export default { // Dispatch event which updates open/close state, shared among the issue show page document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload)); }) - .catch(() => createFlash({ message: __('Update failed. Please try again.') })) + .catch(() => createFlash({ message: __('Error occurred while updating the issue status') })) .finally(() => { - this.isUpdatingState = false; + this.toggleStateButtonLoading(false); }); }, promoteToEpic() { - this.isUpdatingState = true; + this.toggleStateButtonLoading(true); this.$apollo .mutate({ @@ -179,7 +183,7 @@ export default { }) .catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage })) .finally(() => { - this.isUpdatingState = false; + this.toggleStateButtonLoading(false); }); }, }, @@ -188,18 +192,19 @@ export default { <template> <div class="detail-page-header-actions"> - <gl-dropdown class="gl-display-block gl-display-sm-none!" block :text="dropdownText"> - <gl-dropdown-item - v-if="showToggleIssueStateButton" - :disabled="isUpdatingState" - @click="toggleIssueState" - > + <gl-dropdown + class="gl-display-block gl-display-sm-none!" + block + :text="dropdownText" + :loading="isToggleStateButtonLoading" + > + <gl-dropdown-item v-if="showToggleIssueStateButton" @click="toggleIssueState"> {{ buttonText }} </gl-dropdown-item> <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> {{ newIssueTypeText }} </gl-dropdown-item> - <gl-dropdown-item v-if="canPromoteToEpic" :disabled="isUpdatingState" @click="promoteToEpic"> + <gl-dropdown-item v-if="canPromoteToEpic" @click="promoteToEpic"> {{ __('Promote to epic') }} </gl-dropdown-item> <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> @@ -220,7 +225,7 @@ export default { class="gl-display-none gl-display-sm-inline-flex!" category="secondary" :data-qa-selector="qaSelector" - :loading="isUpdatingState" + :loading="isToggleStateButtonLoading" :variant="buttonVariant" @click="toggleIssueState" > @@ -234,7 +239,7 @@ export default { right > <template #button-content> - <gl-icon name="ellipsis_v" aria-hidden="true" /> + <gl-icon name="ellipsis_v" /> <span class="gl-sr-only">{{ dropdownText }}</span> </template> @@ -243,7 +248,7 @@ export default { </gl-dropdown-item> <gl-dropdown-item v-if="canPromoteToEpic" - :disabled="isUpdatingState" + :disabled="isToggleStateButtonLoading" data-testid="promote-button" @click="promoteToEpic" > @@ -272,7 +277,7 @@ export default { > <p>{{ __('This issue is currently blocked by the following issues:') }}</p> <ul> - <li v-for="issue in getNoteableData.blocked_by_issues" :key="issue.iid"> + <li v-for="issue in getBlockedByIssues" :key="issue.iid"> <gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link> </li> </ul> diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js index 620974901fb..12f38005366 100644 --- a/app/assets/javascripts/issue_show/utils/parse_data.js +++ b/app/assets/javascripts/issue_show/utils/parse_data.js @@ -4,13 +4,11 @@ import { sanitize } from '~/lib/dompurify'; // We currently load + parse the data from the issue app and related merge request let cachedParsedData; -export const parseIssuableData = () => { +export const parseIssuableData = el => { try { if (cachedParsedData) return cachedParsedData; - const initialDataEl = document.getElementById('js-issuable-app'); - - const parsedData = JSON.parse(initialDataEl.dataset.initial); + const parsedData = JSON.parse(el.dataset.initial); parsedData.initialTitleHtml = sanitize(parsedData.initialTitleHtml); parsedData.initialDescriptionHtml = sanitize(parsedData.initialDescriptionHtml); @@ -23,5 +21,3 @@ export const parseIssuableData = () => { return {}; } }; - -export default {}; diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue index b12b20d0135..16f8e67cde0 100644 --- a/app/assets/javascripts/issues_list/components/issuable.vue +++ b/app/assets/javascripts/issues_list/components/issuable.vue @@ -35,6 +35,7 @@ export default { i18n: { openedAgo: __('opened %{timeAgoString} by %{user}'), openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'), + openedAgoServiceDesk: __('opened %{timeAgoString} by %{email} via %{user}'), }, inject: ['scopedLabelsAvailable'], components: { @@ -206,6 +207,11 @@ export default { healthStatus() { return convertToCamelCase(this.issuable.health_status); }, + openedMessage() { + if (this.isJiraIssue) return this.$options.i18n.openedAgoJira; + if (this.issuable.service_desk_reply_to) return this.$options.i18n.openedAgoServiceDesk; + return this.$options.i18n.openedAgo; + }, }, mounted() { // TODO: Refactor user popover to use its own component instead of @@ -311,9 +317,7 @@ export default { <span data-testid="openedByMessage" class="gl-display-none d-sm-inline-block gl-mr-4"> · - <gl-sprintf - :message="isJiraIssue ? $options.i18n.openedAgoJira : $options.i18n.openedAgo" - > + <gl-sprintf :message="openedMessage"> <template #timeAgoString> <span>{{ issuableCreatedAt }}</span> </template> @@ -326,6 +330,9 @@ export default { >{{ issuableAuthor.name }}</gl-link > </template> + <template #email> + <span>{{ issuable.service_desk_reply_to }}</span> + </template> </gl-sprintf> </span> 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 0d4f5bce965..0ce2bcc1cce 100644 --- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue @@ -215,6 +215,7 @@ export default { this.fetchIssuables(); }, beforeDestroy() { + // eslint-disable-next-line @gitlab/no-global-event-off issueableEventHub.$off('issuables:toggleBulkEdit'); }, methods: { diff --git a/app/assets/javascripts/jira_connect.js b/app/assets/javascripts/jira_connect.js deleted file mode 100644 index 0864a3024ac..00000000000 --- a/app/assets/javascripts/jira_connect.js +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable func-names, no-var, no-alert */ -/* global $ */ -/* global AP */ - -/** - * This script is not going through Webpack bundling - * as it is only included in `app/views/jira_connect/subscriptions/index.html.haml` - * which is going to be rendered within iframe on Jira app dashboard - * hence any code written here needs to be IE11+ compatible (no fully ES6) - */ - -function onLoaded() { - var reqComplete = function() { - AP.navigator.reload(); - }; - - var reqFailed = function(res) { - alert(res.responseJSON.error); - }; - - AP.getLocation(function(location) { - $('.js-jira-connect-sign-in').each(function() { - var updatedLink = `${$(this).attr('href')}?return_to=${location}`; - $(this).attr('href', updatedLink); - }); - }); - - $('#add-subscription-form').on('submit', function(e) { - var actionUrl = $(this).attr('action'); - e.preventDefault(); - - AP.context.getToken(function(token) { - // eslint-disable-next-line no-jquery/no-ajax - $.post(actionUrl, { - jwt: token, - namespace_path: $('#namespace-input').val(), - format: 'json', - }) - .done(reqComplete) - .fail(reqFailed); - }); - }); - - $('.remove-subscription').on('click', function(e) { - var href = $(this).attr('href'); - e.preventDefault(); - - AP.context.getToken(function(token) { - // eslint-disable-next-line no-jquery/no-ajax - $.ajax({ - url: href, - method: 'DELETE', - data: { - jwt: token, - format: 'json', - }, - }) - .done(reqComplete) - .fail(reqFailed); - }); - }); -} -document.addEventListener('DOMContentLoaded', onLoaded); diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue index 6d32ba41eae..490bf2fdd66 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -1,7 +1,16 @@ <script> -export default {}; +export default { + name: 'JiraConnectApp', + computed: { + state() { + return this.$root.$data.state || {}; + }, + error() { + return this.state.error; + }, + }, +}; </script> - <template> <div></div> </template> diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js index 37f00d56a05..e7aa4c437bb 100644 --- a/app/assets/javascripts/jira_connect/index.js +++ b/app/assets/javascripts/jira_connect/index.js @@ -1,11 +1,85 @@ import Vue from 'vue'; +import $ from 'jquery'; import App from './components/app.vue'; +const store = { + state: { + error: '', + }, + setErrorMessage(errorMessage) { + this.state.error = errorMessage; + }, +}; + +/** + * Initialize necessary form handlers for the Jira Connect app + */ +const initJiraFormHandlers = () => { + const reqComplete = () => { + AP.navigator.reload(); + }; + + const reqFailed = (res, fallbackErrorMessage) => { + const { responseJSON: { error = fallbackErrorMessage } = {} } = res || {}; + + store.setErrorMessage(error); + // eslint-disable-next-line no-alert + alert(error); + }; + + AP.getLocation(location => { + $('.js-jira-connect-sign-in').each(function updateSignInLink() { + const updatedLink = `${$(this).attr('href')}?return_to=${location}`; + $(this).attr('href', updatedLink); + }); + }); + + $('#add-subscription-form').on('submit', function onAddSubscriptionForm(e) { + const actionUrl = $(this).attr('action'); + e.preventDefault(); + + AP.context.getToken(token => { + // eslint-disable-next-line no-jquery/no-ajax + $.post(actionUrl, { + jwt: token, + namespace_path: $('#namespace-input').val(), + format: 'json', + }) + .done(reqComplete) + .fail(err => reqFailed(err, 'Failed to add namespace. Please try again.')); + }); + }); + + $('.remove-subscription').on('click', function onRemoveSubscriptionClick(e) { + const href = $(this).attr('href'); + e.preventDefault(); + + AP.context.getToken(token => { + // eslint-disable-next-line no-jquery/no-ajax + $.ajax({ + url: href, + method: 'DELETE', + data: { + jwt: token, + format: 'json', + }, + }) + .done(reqComplete) + .fail(err => reqFailed(err, 'Failed to remove namespace. Please try again.')); + }); + }); +}; + function initJiraConnect() { const el = document.querySelector('.js-jira-connect-app'); + initJiraFormHandlers(); + return new Vue({ el, + data: { + state: store.state, + }, render(createElement) { return createElement(App, {}); }, diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index c6adf2f231f..30093224631 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -1,12 +1,11 @@ <script> import { throttle, isEmpty } from 'lodash'; import { mapGetters, mapState, mapActions } from 'vuex'; -import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml, GlAlert } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { polyfillSticky } from '~/lib/utils/sticky'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; -import Callout from '~/vue_shared/components/callout.vue'; import EmptyState from './empty_state.vue'; import EnvironmentsBlock from './environments_block.vue'; import ErasedBlock from './erased_block.vue'; @@ -22,7 +21,6 @@ export default { name: 'JobPageApp', components: { CiHeader, - Callout, EmptyState, EnvironmentsBlock, ErasedBlock, @@ -34,6 +32,7 @@ export default { Sidebar, GlLoadingIcon, SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'), + GlAlert, }, directives: { SafeHtml, @@ -223,10 +222,14 @@ export default { @clickedSidebarButton="toggleSidebar" /> </div> - - <callout v-if="shouldRenderHeaderCallout"> + <gl-alert + v-if="shouldRenderHeaderCallout" + variant="danger" + class="gl-mt-3" + :dismissible="false" + > <div v-safe-html="job.callout_message"></div> - </callout> + </gl-alert> </header> <!-- EO Header Section --> diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue index affaddcdee2..87af387ca91 100644 --- a/app/assets/javascripts/jobs/components/log/line.vue +++ b/app/assets/javascripts/jobs/components/log/line.vue @@ -18,46 +18,33 @@ export default { render(h, { props }) { const { line, path } = props; - let chars; - if (gon?.features?.ciJobLineLinks) { - chars = line.content.map(content => { - return h( - 'span', - { - class: ['gl-white-space-pre-wrap', content.style], - }, - // Simple "tokenization": Split text in chunks of text - // which alternate between text and urls. - content.text.split(linkRegex).map(chunk => { - // Return normal string for non-links - if (!chunk.match(linkRegex)) { - return chunk; - } - return h( - 'a', - { - attrs: { - href: chunk, - class: 'gl-reset-color! gl-text-decoration-underline', - rel: 'nofollow noopener noreferrer', // eslint-disable-line @gitlab/require-i18n-strings - }, + const chars = line.content.map(content => { + return h( + 'span', + { + class: ['gl-white-space-pre-wrap', content.style], + }, + // Simple "tokenization": Split text in chunks of text + // which alternate between text and urls. + content.text.split(linkRegex).map(chunk => { + // Return normal string for non-links + if (!chunk.match(linkRegex)) { + return chunk; + } + return h( + 'a', + { + attrs: { + href: chunk, + class: 'gl-reset-color! gl-text-decoration-underline', + rel: 'nofollow noopener noreferrer', // eslint-disable-line @gitlab/require-i18n-strings }, - chunk, - ); - }), - ); - }); - } else { - chars = line.content.map(content => { - return h( - 'span', - { - class: ['gl-white-space-pre-wrap', content.style], - }, - content.text, - ); - }); - } + }, + chunk, + ); + }), + ); + }); return h('div', { class: 'js-line log-line' }, [ h(LineNumber, { diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue index 633561c879e..c9747ca9f02 100644 --- a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue +++ b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue @@ -1,11 +1,19 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlAlert } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; /** * Renders Unmet Prerequisites block for job's view. */ export default { + i18n: { + failMessage: s__( + 'Job|This job failed because the necessary resources were not successfully created.', + ), + moreInformation: __('More information'), + }, components: { GlLink, + GlAlert, }, props: { helpPath: { @@ -16,15 +24,10 @@ export default { }; </script> <template> - <div class="bs-callout bs-callout-danger"> - <p class="js-failed-unmet-prerequisites gl-mb-0"> - {{ - s__(`Job|This job failed because the necessary resources were not successfully created.`) - }} - - <gl-link :href="helpPath" class="js-help-path"> - <strong> {{ __('More information') }} </strong> - </gl-link> - </p> - </div> + <gl-alert variant="danger" class="gl-mt-3" :dismissible="false"> + {{ $options.i18n.failMessage }} + <gl-link :href="helpPath"> + {{ $options.i18n.moreInformation }} + </gl-link> + </gl-alert> </template> diff --git a/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js b/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js index 8c7fb785a61..7b17dc7f693 100644 --- a/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js +++ b/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js @@ -20,7 +20,10 @@ export default { computed: { isDelayedJob() { - return this.job && this.job.scheduled; + return this.job?.scheduled || this.job?.scheduledAt; + }, + scheduledTime() { + return this.job.scheduled_at || this.job.scheduledAt; }, }, @@ -43,7 +46,7 @@ export default { }, updateRemainingTime() { - const remainingMilliseconds = calculateRemainingMilliseconds(this.job.scheduled_at); + const remainingMilliseconds = calculateRemainingMilliseconds(this.scheduledTime); this.remainingTime = formatTime(remainingMilliseconds); }, }, diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 1e4b5e986db..cac9dc06284 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -13,6 +13,7 @@ import { scrollDown, scrollUp, } from '~/lib/utils/scroll_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { dispatch('setJobEndpoint', endpoint); @@ -20,8 +21,7 @@ export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { logState, pagePath, }); - - return Promise.all([dispatch('fetchJob'), dispatch('fetchTrace')]); + dispatch('fetchJob'); }; export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint); @@ -39,6 +39,7 @@ export const toggleSidebar = ({ dispatch, state }) => { }; let eTagPoll; +let isTraceReadyForRender; export const clearEtagPoll = () => { eTagPoll = null; @@ -70,7 +71,14 @@ export const fetchJob = ({ state, dispatch }) => { }); if (!Visibility.hidden()) { - eTagPoll.makeRequest(); + // eslint-disable-next-line promise/catch-or-return + eTagPoll.makeRequest().then(() => { + // if a job is canceled we still need to dispatch + // fetchTrace to get the trace so we check for has_trace + if (state.job.started || state.job.has_trace) { + dispatch('fetchTrace'); + } + }); } else { axios .get(state.jobEndpoint) @@ -80,9 +88,15 @@ export const fetchJob = ({ state, dispatch }) => { Visibility.change(() => { if (!Visibility.hidden()) { + // This check is needed to ensure the loading icon + // is not shown for a finished job during a visibility change + if (!isTraceReadyForRender && state.job.started) { + dispatch('startPollingTrace'); + } dispatch('restartPolling'); } else { dispatch('stopPolling'); + dispatch('stopPollingTrace'); } }); }; @@ -163,6 +177,8 @@ export const fetchTrace = ({ dispatch, state }) => params: { state: state.traceState }, }) .then(({ data }) => { + isTraceReadyForRender = data.complete; + dispatch('toggleScrollisInBottom', isScrolledToBottom()); dispatch('receiveTraceSuccess', data); @@ -172,7 +188,11 @@ export const fetchTrace = ({ dispatch, state }) => dispatch('startPollingTrace'); } }) - .catch(() => dispatch('receiveTraceError')); + .catch(e => + e.response.status === httpStatusCodes.FORBIDDEN + ? dispatch('receiveTraceUnauthorizedError') + : dispatch('receiveTraceError'), + ); export const startPollingTrace = ({ dispatch, commit }) => { const traceTimeout = setTimeout(() => { @@ -194,6 +214,10 @@ export const receiveTraceError = ({ dispatch }) => { dispatch('stopPollingTrace'); flash(__('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.')); +}; /** * When the user clicks a collapsible line in the job * log, we commit a mutation to update the state @@ -234,7 +258,7 @@ export const receiveJobsForStageError = ({ commit }) => { flash(__('An error occurred while fetching the jobs.')); }; -export const triggerManualJob = ({ state }, variables) => { +export const triggerManualJob = ({ state, dispatch }, variables) => { const parsedVariables = variables.map(variable => { const copyVar = { ...variable }; delete copyVar.id; @@ -245,5 +269,6 @@ export const triggerManualJob = ({ state }, variables) => { .post(state.job.status.action.path, { job_variables_attributes: parsedVariables, }) + .then(() => dispatch('fetchTrace')) .catch(() => flash(__('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..dea53f715a7 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -49,6 +49,7 @@ export default { [types.SET_TRACE_TIMEOUT](state, id) { state.traceTimeout = id; + state.isTraceComplete = false; }, /** diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js index 28a125b2b8f..122f23a5bb5 100644 --- a/app/assets/javascripts/jobs/utils.js +++ b/app/assets/javascripts/jobs/utils.js @@ -1,4 +1,12 @@ -// capture anything starting with http:// or https:// -// up until a disallowed character or whitespace -export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+)/g; +/** + * capture anything starting with http:// or https:// + * https?:\/\/ + * + * up until a disallowed character or whitespace + * [^"<>\\^`{|}\s]+ + * + * and a disallowed character or whitespace, including non-ending chars .,:;!? + * [^"<>\\^`{|}\s.,:;!?] + */ +export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+[^"<>\\^`{|}\s.,:;!?])/g; export default { linkRegex }; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 8bbd4300c96..ac5aa24d5d8 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -45,8 +45,7 @@ export default class LabelsSelect { const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); const $value = $block.find('.value'); const $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); - // eslint-disable-next-line no-jquery/no-fade - const $loading = $block.find('.block-loading').fadeOut(); + const $loading = $block.find('.block-loading').addClass('gl-display-none'); const fieldName = $dropdown.data('fieldName'); let initialSelected = $selectbox .find(`input[name="${$dropdown.data('fieldName')}"]`) @@ -83,15 +82,13 @@ export default class LabelsSelect { if (!selected.length) { data[abilityName].label_ids = ['']; } - // eslint-disable-next-line no-jquery/no-fade - $loading.removeClass('hidden').fadeIn(); + $loading.removeClass('gl-display-none'); $dropdown.trigger('loading.gl.dropdown'); axios .put(issueUpdateURL, data) .then(({ data }) => { let template; - // eslint-disable-next-line no-jquery/no-fade - $loading.fadeOut(); + $loading.addClass('gl-display-none'); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); data.issueUpdateURL = issueUpdateURL; @@ -340,9 +337,8 @@ export default class LabelsSelect { const { $el, e, isMarking } = clickEvent; const label = clickEvent.selectedObj; - const fadeOutLoader = () => { - // eslint-disable-next-line no-jquery/no-fade - $loading.fadeOut(); + const hideLoader = () => { + $loading.addClass('gl-display-none'); }; const page = $('body').attr('data-page'); @@ -403,8 +399,7 @@ export default class LabelsSelect { boardsStore.detail.issue.labels = labels; } - // eslint-disable-next-line no-jquery/no-fade - $loading.fadeIn(); + $loading.removeClass('gl-display-none'); const oldLabels = boardsStore.detail.issue.labels; boardsStore.detail.issue @@ -420,8 +415,8 @@ export default class LabelsSelect { .removeClass('is-active'); } }) - .then(fadeOutLoader) - .catch(fadeOutLoader); + .then(hideLoader) + .catch(hideLoader); } else if (handleClick) { e.preventDefault(); handleClick(label); diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index 4d2955a8d3d..ab83f1ecc14 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import ContextualSidebar from './contextual_sidebar'; import initFlyOutNav from './fly_out_nav'; +import { setNotification } from './whats_new/utils/notification'; function hideEndFade($scrollingTabs) { $scrollingTabs.each(function scrollTabsLoop() { @@ -14,25 +15,17 @@ function hideEndFade($scrollingTabs) { function initDeferred() { $(document).trigger('init.scrolling-tabs'); - const whatsNewTriggerEl = document.querySelector('.js-whats-new-trigger'); - if (whatsNewTriggerEl) { - const storageKey = whatsNewTriggerEl.getAttribute('data-storage-key'); + const appEl = document.getElementById('whats-new-app'); + if (!appEl) return; - $('.header-help').on('show.bs.dropdown', () => { - const displayNotification = JSON.parse(localStorage.getItem(storageKey)); - if (displayNotification === false) { - $('.js-whats-new-notification-count').remove(); - } - }); - - whatsNewTriggerEl.addEventListener('click', () => { - import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new') - .then(({ default: initWhatsNew }) => { - initWhatsNew(); - }) - .catch(() => {}); - }); - } + setNotification(appEl); + document.querySelector('.js-whats-new-trigger').addEventListener('click', () => { + import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new') + .then(({ default: initWhatsNew }) => { + initWhatsNew(appEl); + }) + .catch(() => {}); + }); } export default function initLayoutNav() { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 42a5de68cfa..f88a0433535 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -218,23 +218,46 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2; export const contentTop = () => { - const perfBar = $('#js-peek').outerHeight() || 0; - const mrTabsHeight = $('.merge-request-tabs').outerHeight() || 0; - const headerHeight = $('.navbar-gitlab').outerHeight() || 0; - const diffFilesChanged = $('.js-diff-files-changed').outerHeight() || 0; const isDesktop = breakpointInstance.isDesktop(); - const diffFileTitleBar = - (isDesktop && $('.diff-file .file-title-flex-parent:visible').outerHeight()) || 0; - const compareVersionsHeaderHeight = (isDesktop && $('.mr-version-controls').outerHeight()) || 0; + const heightCalculators = [ + () => $('#js-peek').outerHeight(), + () => $('.navbar-gitlab').outerHeight(), + ({ desktop }) => { + const container = document.querySelector('.line-resolve-all-container'); + let size = 0; + + if (!desktop && container) { + size = container.offsetHeight; + } - return ( - perfBar + - mrTabsHeight + - headerHeight + - diffFilesChanged + - diffFileTitleBar + - compareVersionsHeaderHeight - ); + return size; + }, + () => $('.merge-request-tabs').outerHeight(), + () => $('.js-diff-files-changed').outerHeight(), + ({ desktop }) => { + const diffsTabIsActive = window.mrTabs?.currentAction === 'diffs'; + let size; + + if (desktop && diffsTabIsActive) { + size = $('.diff-file .file-title-flex-parent:visible').outerHeight(); + } + + return size; + }, + ({ desktop }) => { + let size; + + if (desktop) { + size = $('.mr-version-controls').outerHeight(); + } + + return size; + }, + ]; + + return heightCalculators.reduce((totalHeight, calculator) => { + return totalHeight + (calculator({ desktop: isDesktop }) || 0); + }, 0); }; export const scrollToElement = (element, options = {}) => { diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 7bba7ba2f45..2f19a0c9b26 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -1,6 +1,14 @@ import { has } from 'lodash'; import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils'; +/** + * Checks whether an element's content exceeds the element's width. + * + * @param element DOM element to check + */ +export const hasHorizontalOverflow = element => + Boolean(element && element.scrollWidth > element.offsetWidth); + export const addClassIfElementExists = (element, className) => { if (element) { element.classList.add(className); diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js index 618266f7a09..6f5cd7460f8 100644 --- a/app/assets/javascripts/lib/utils/keycodes.js +++ b/app/assets/javascripts/lib/utils/keycodes.js @@ -2,6 +2,7 @@ // See: https://gitlab.com/gitlab-org/gitlab/-/issues/216102 export const BACKSPACE_KEY_CODE = 8; +export const TAB_KEY_CODE = 9; export const ENTER_KEY_CODE = 13; export const ESC_KEY_CODE = 27; export const UP_KEY_CODE = 38; diff --git a/app/assets/javascripts/lib/utils/scroll_utils.js b/app/assets/javascripts/lib/utils/scroll_utils.js index b4da1e16f08..01e43fd3b93 100644 --- a/app/assets/javascripts/lib/utils/scroll_utils.js +++ b/app/assets/javascripts/lib/utils/scroll_utils.js @@ -49,5 +49,3 @@ export const toggleDisableButton = ($button, disable) => { if (disable && $button.prop('disabled')) return; $button.prop('disabled', disable); }; - -export default {}; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index dfb86787788..c711c0bd163 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -339,6 +339,7 @@ export function addMarkdownListeners(form) { Shortcuts.initMarkdownEditorShortcuts($(this), updateTextForToolbarBtn); }); + // eslint-disable-next-line @gitlab/no-global-event-off const $allToolbarBtns = $('.js-md', form) .off('click') .on('click', function() { @@ -351,6 +352,7 @@ export function addMarkdownListeners(form) { } export function addEditorMarkdownListeners(editor) { + // eslint-disable-next-line @gitlab/no-global-event-off $('.js-md') .off('click') .on('click', e => { @@ -376,5 +378,6 @@ export function removeMarkdownListeners(form) { Shortcuts.removeMarkdownEditorShortcuts($(this)); }); + // eslint-disable-next-line @gitlab/no-global-event-off return $('.js-md', form).off('click'); } diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index a81ca3f211f..c398874db24 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -411,3 +411,13 @@ export const hasContent = obj => isString(obj) && obj.trim() !== ''; export const isValidSha1Hash = str => { return /^[0-9a-f]{5,40}$/.test(str); }; + +/** + * Adds a final newline to the content if it doesn't already exist + * + * @param {*} content Content + * @param {*} endOfLine Type of newline: CRLF='\r\n', LF='\n', CR='\r' + */ +export function insertFinalNewline(content, endOfLine = '\n') { + return content.slice(-endOfLine.length) !== endOfLine ? `${content}${endOfLine}` : content; +} diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js index 8e537a4025f..880f762e225 100644 --- a/app/assets/javascripts/logs/utils.js +++ b/app/assets/javascripts/logs/utils.js @@ -23,5 +23,3 @@ export const getTimeRange = (seconds = 0) => { }; export const formatDate = timestamp => dateFormat(timestamp, dateFormatMask); - -export default {}; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index b404f390a2d..de7648c31b1 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -23,7 +23,6 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility'; // everything else import { deprecatedCreateFlash as Flash, removeFlashClickListener } from './flash'; import initTodoToggle from './header'; -import initImporterStatus from './importer_status'; import initLayoutNav from './layout_nav'; import initAlertHandler from './alert_handler'; import './feature_highlight/feature_highlight_options'; @@ -78,6 +77,7 @@ if (process.env.NODE_ENV !== 'production' && gon?.test_env) { document.addEventListener('beforeunload', () => { // Unbind scroll events + // eslint-disable-next-line @gitlab/no-global-event-off $(document).off('scroll'); // Close any open tooltips tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]')); @@ -107,7 +107,6 @@ function deferredInitialisation() { const $body = $('body'); initBreadcrumbs(); - initImporterStatus(); initTodoToggle(); initLogoAnimation(); initUsagePingConsent(); @@ -138,10 +137,9 @@ function deferredInitialisation() { $('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() { tooltips.dispose(this); - // eslint-disable-next-line no-jquery/no-fade $(this) .closest('li') - .fadeOut(); + .addClass('gl-display-none!'); }); $('.js-remove-tr').on('ajax:before', function removeTRAjaxBeforeCallback() { @@ -149,10 +147,9 @@ function deferredInitialisation() { }); $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() { - // eslint-disable-next-line no-jquery/no-fade $(this) .closest('tr') - .fadeOut(); + .addClass('gl-display-none!'); }); const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); diff --git a/app/assets/javascripts/maintenance_mode_settings/components/app.vue b/app/assets/javascripts/maintenance_mode_settings/components/app.vue deleted file mode 100644 index 11d154ed9d1..00000000000 --- a/app/assets/javascripts/maintenance_mode_settings/components/app.vue +++ /dev/null @@ -1,44 +0,0 @@ -<script> -import { GlToggle, GlFormGroup, GlFormTextarea, GlButton } from '@gitlab/ui'; - -export default { - name: 'MaintenanceModeSettingsApp', - components: { - GlToggle, - GlFormGroup, - GlFormTextarea, - GlButton, - }, - data() { - return { - inMaintenanceMode: false, - bannerMessage: '', - }; - }, -}; -</script> -<template> - <article> - <div class="d-flex align-items-center mb-3"> - <gl-toggle v-model="inMaintenanceMode" class="mb-0" /> - <div class="ml-2"> - <p class="mb-0">{{ __('Enable maintenance mode') }}</p> - <p class="mb-0 text-secondary-500"> - {{ - __('Non-admin users can sign in with read-only access and make read-only API requests.') - }} - </p> - </div> - </div> - <gl-form-group label="Banner Message" label-for="maintenanceBannerMessage"> - <gl-form-textarea - id="maintenanceBannerMessage" - v-model="bannerMessage" - :placeholder="__(`GitLab is undergoing maintenance and is operating in a read-only mode.`)" - /> - </gl-form-group> - <div class="mt-4"> - <gl-button variant="success" category="primary">{{ __('Save changes') }}</gl-button> - </div> - </article> -</template> diff --git a/app/assets/javascripts/maintenance_mode_settings/index.js b/app/assets/javascripts/maintenance_mode_settings/index.js deleted file mode 100644 index 7a80233faf0..00000000000 --- a/app/assets/javascripts/maintenance_mode_settings/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import MaintenanceModeSettingsApp from './components/app.vue'; - -Vue.use(Translate); - -export default () => { - const el = document.getElementById('js-maintenance-mode-settings'); - - return new Vue({ - el, - components: { - MaintenanceModeSettingsApp, - }, - - render(createElement) { - return createElement('maintenance-mode-settings-app'); - }, - }); -}; diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index 6dd4018f87a..5bd228496da 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -11,9 +11,11 @@ export default class Members { } addListeners() { + // eslint-disable-next-line @gitlab/no-global-event-off $('.js-member-update-control') .off('change') .on('change', this.formSubmit.bind(this)); + // eslint-disable-next-line @gitlab/no-global-event-off $('.js-edit-member-form') .off('ajax:success') .on('ajax:success', this.formSuccess.bind(this)); diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue index 10078d5cd64..10078d5cd64 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue b/app/assets/javascripts/members/components/action_buttons/action_button_group.vue index 8356fdb60b1..8356fdb60b1 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue +++ b/app/assets/javascripts/members/components/action_buttons/action_button_group.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue index e8a53ff173d..e8a53ff173d 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/group_action_buttons.vue index 2aebfe80db5..2aebfe80db5 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/group_action_buttons.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue index 2b0a75640e2..2b0a75640e2 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue b/app/assets/javascripts/members/components/action_buttons/leave_button.vue index d9976e7181c..443a962e0cf 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/leave_button.vue @@ -2,7 +2,7 @@ import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import LeaveModal from '../modals/leave_modal.vue'; -import { LEAVE_MODAL_ID } from '../constants'; +import { LEAVE_MODAL_ID } from '../../constants'; export default { name: 'LeaveButton', diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue index 9d89cb40676..9d89cb40676 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue index b0b7ff4ce9a..b0b7ff4ce9a 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue index 1cc3fd17e98..1cc3fd17e98 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue index 484dbb8fef5..f2bc9c7e876 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -11,7 +11,7 @@ export default { RemoveMemberButton, LeaveButton, LdapOverrideButton: () => - import('ee_component/vue_shared/components/members/ldap/ldap_override_button.vue'), + import('ee_component/members/components/ldap/ldap_override_button.vue'), }, props: { member: { diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue b/app/assets/javascripts/members/components/avatars/group_avatar.vue index 12b748f9ab6..3b176bf2b43 100644 --- a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/group_avatar.vue @@ -1,6 +1,6 @@ <script> import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; -import { AVATAR_SIZE } from '../constants'; +import { AVATAR_SIZE } from '../../constants'; export default { name: 'GroupAvatar', diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue b/app/assets/javascripts/members/components/avatars/invite_avatar.vue index 28654a60860..08e702007bb 100644 --- a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/invite_avatar.vue @@ -1,6 +1,6 @@ <script> import { GlAvatarLabeled } from '@gitlab/ui'; -import { AVATAR_SIZE } from '../constants'; +import { AVATAR_SIZE } from '../../constants'; export default { name: 'InviteAvatar', diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue index e5e7cdf149c..fe45ca769af 100644 --- a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue @@ -5,9 +5,9 @@ import { GlBadge, GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; -import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils'; +import { generateBadges } from 'ee_else_ce/members/utils'; import { __ } from '~/locale'; -import { AVATAR_SIZE } from '../constants'; +import { AVATAR_SIZE } from '../../constants'; import { glEmojiTag } from '~/emoji'; export default { diff --git a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue new file mode 100644 index 00000000000..f869ecd392f --- /dev/null +++ b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue @@ -0,0 +1,26 @@ +<script> +import { mapState } from 'vuex'; +import MembersFilteredSearchBar from './members_filtered_search_bar.vue'; +import SortDropdown from './sort_dropdown.vue'; + +export default { + name: 'FilterSortContainer', + components: { MembersFilteredSearchBar, SortDropdown }, + computed: { + ...mapState(['filteredSearchBar', 'tableSortableFields']), + showContainer() { + return this.filteredSearchBar.show || this.showSortDropdown; + }, + showSortDropdown() { + return this.tableSortableFields.length; + }, + }, +}; +</script> + +<template> + <div v-if="showContainer" class="gl-bg-gray-10 gl-p-3 gl-display-md-flex"> + <members-filtered-search-bar v-if="filteredSearchBar.show" class="gl-p-3 gl-flex-grow-1" /> + <sort-dropdown v-if="showSortDropdown" class="gl-p-3 gl-flex-shrink-0" /> + </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 new file mode 100644 index 00000000000..c1df0b94234 --- /dev/null +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -0,0 +1,132 @@ +<script> +import { mapState } from 'vuex'; +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { setUrlParams, queryToObject } from '~/lib/utils/url_utility'; +import { getParameterByName } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { SEARCH_TOKEN_TYPE, SORT_PARAM } from '~/members/constants'; + +export default { + name: 'MembersFilteredSearchBar', + components: { FilteredSearchBar }, + availableTokens: [ + { + type: 'two_factor', + icon: 'lock', + title: s__('Members|2FA'), + token: GlFilteredSearchToken, + unique: true, + operators: [{ value: '=', description: 'is' }], + options: [ + { value: 'enabled', title: s__('Members|Enabled') }, + { value: 'disabled', title: s__('Members|Disabled') }, + ], + requiredPermissions: 'canManageMembers', + }, + { + type: 'with_inherited_permissions', + icon: 'group', + title: s__('Members|Membership'), + token: GlFilteredSearchToken, + unique: true, + operators: [{ value: '=', description: 'is' }], + options: [ + { value: 'exclude', title: s__('Members|Direct') }, + { value: 'only', title: s__('Members|Inherited') }, + ], + }, + ], + data() { + return { + initialFilterValue: [], + }; + }, + computed: { + ...mapState(['sourceId', 'filteredSearchBar', 'canManageMembers']), + tokens() { + return this.$options.availableTokens.filter(token => { + if ( + Object.prototype.hasOwnProperty.call(token, 'requiredPermissions') && + !this[token.requiredPermissions] + ) { + return false; + } + + return this.filteredSearchBar.tokens?.includes(token.type); + }); + }, + }, + created() { + const query = queryToObject(window.location.search); + + const tokens = this.tokens + .filter(token => query[token.type]) + .map(token => ({ + type: token.type, + value: { + data: query[token.type], + operator: '=', + }, + })); + + if (query[this.filteredSearchBar.searchParam]) { + tokens.push({ + type: SEARCH_TOKEN_TYPE, + value: { + data: query[this.filteredSearchBar.searchParam], + }, + }); + } + + this.initialFilterValue = tokens; + }, + methods: { + handleFilter(tokens) { + const params = tokens.reduce((accumulator, token) => { + const { type, value } = token; + + if (!type || !value) { + return accumulator; + } + + if (type === SEARCH_TOKEN_TYPE) { + if (value.data !== '') { + return { + ...accumulator, + [this.filteredSearchBar.searchParam]: value.data, + }; + } + } else { + return { + ...accumulator, + [type]: value.data, + }; + } + + return accumulator; + }, {}); + + const sortParam = getParameterByName(SORT_PARAM); + + window.location.href = setUrlParams( + { ...params, ...(sortParam && { sort: sortParam }) }, + window.location.href, + true, + ); + }, + }, +}; +</script> + +<template> + <filtered-search-bar + :namespace="sourceId.toString()" + :tokens="tokens" + :recent-searches-storage-key="filteredSearchBar.recentSearchesStorageKey" + :search-input-placeholder="filteredSearchBar.placeholder" + :initial-filter-value="initialFilterValue" + data-testid="members-filtered-search-bar" + @onFilter="handleFilter" + /> +</template> diff --git a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue new file mode 100644 index 00000000000..de7fbc4241c --- /dev/null +++ b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue @@ -0,0 +1,77 @@ +<script> +import { mapState } from 'vuex'; +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { parseSortParam, buildSortHref } from '~/members/utils'; +import { FIELDS } from '~/members/constants'; + +export default { + name: 'SortDropdown', + components: { GlSorting, GlSortingItem }, + computed: { + ...mapState(['tableSortableFields', 'filteredSearchBar']), + sort() { + return parseSortParam(this.tableSortableFields); + }, + activeOption() { + return FIELDS.find(field => field.key === this.sort.sortByKey); + }, + activeOptionLabel() { + return this.activeOption?.label; + }, + isAscending() { + return !this.sort.sortDesc; + }, + filteredOptions() { + return FIELDS.filter(field => this.tableSortableFields.includes(field.key) && field.sort).map( + field => ({ + key: field.key, + label: field.label, + href: buildSortHref({ + sortBy: field.key, + sortDesc: false, + filteredSearchBarTokens: this.filteredSearchBar.tokens, + filteredSearchBarSearchParam: this.filteredSearchBar.searchParam, + }), + }), + ); + }, + }, + methods: { + isActive(key) { + return this.activeOption.key === key; + }, + handleSortDirectionChange() { + visitUrl( + buildSortHref({ + sortBy: this.activeOption.key, + sortDesc: !this.sort.sortDesc, + filteredSearchBarTokens: this.filteredSearchBar.tokens, + filteredSearchBarSearchParam: this.filteredSearchBar.searchParam, + }), + ); + }, + }, +}; +</script> + +<template> + <gl-sorting + class="gl-display-flex" + dropdown-class="gl-w-full" + data-testid="members-sort-dropdown" + :text="activeOptionLabel" + :is-ascending="isAscending" + :sort-direction-tool-tip="__('Sort direction')" + @sortDirectionChange="handleSortDirectionChange" + > + <gl-sorting-item + v-for="option in filteredOptions" + :key="option.key" + :href="option.href" + :active="isActive(option.key)" + > + {{ option.label }} + </gl-sorting-item> + </gl-sorting> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue index 9a2ce0d4931..57a5da774e3 100644 --- a/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue +++ b/app/assets/javascripts/members/components/modals/leave_modal.vue @@ -3,7 +3,7 @@ import { mapState } from 'vuex'; import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; -import { LEAVE_MODAL_ID } from '../constants'; +import { LEAVE_MODAL_ID } from '../../constants'; export default { name: 'LeaveModal', diff --git a/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue index e8890717724..231d014a4ec 100644 --- a/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue @@ -3,7 +3,7 @@ import { mapState, mapActions } from 'vuex'; import { GlModal, GlSprintf, GlForm } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; -import { REMOVE_GROUP_LINK_MODAL_ID } from '../constants'; +import { REMOVE_GROUP_LINK_MODAL_ID } from '../../constants'; export default { name: 'RemoveGroupLinkModal', diff --git a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue b/app/assets/javascripts/members/components/table/created_at.vue index 0bad70894f9..0bad70894f9 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue +++ b/app/assets/javascripts/members/components/table/created_at.vue diff --git a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue b/app/assets/javascripts/members/components/table/expiration_datepicker.vue index 0a8af81c1d1..0a8af81c1d1 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue +++ b/app/assets/javascripts/members/components/table/expiration_datepicker.vue diff --git a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue b/app/assets/javascripts/members/components/table/expires_at.vue index de65e3fb10f..c91de061b50 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue +++ b/app/assets/javascripts/members/components/table/expires_at.vue @@ -6,7 +6,7 @@ import { formatDate, getDayDifference, } from '~/lib/utils/datetime_utility'; -import { DAYS_TO_EXPIRE_SOON } from '../constants'; +import { DAYS_TO_EXPIRE_SOON } from '../../constants'; export default { name: 'ExpiresAt', diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue index 320d8c99223..c61ebec33bd 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue +++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue @@ -3,7 +3,7 @@ import UserActionButtons from '../action_buttons/user_action_buttons.vue'; import GroupActionButtons from '../action_buttons/group_action_buttons.vue'; import InviteActionButtons from '../action_buttons/invite_action_buttons.vue'; import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue'; -import { MEMBER_TYPES } from '../constants'; +import { MEMBER_TYPES } from '../../constants'; export default { name: 'MemberActionButtons', diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue b/app/assets/javascripts/members/components/table/member_avatar.vue index a1f98d4008a..a1f98d4008a 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue +++ b/app/assets/javascripts/members/components/table/member_avatar.vue diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue b/app/assets/javascripts/members/components/table/member_source.vue index 030d72c3420..030d72c3420 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue +++ b/app/assets/javascripts/members/components/table/member_source.vue diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index a4f67caff31..da77e5caad2 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -1,14 +1,9 @@ <script> import { mapState } from 'vuex'; import { GlTable, GlBadge } from '@gitlab/ui'; -import MembersTableCell from 'ee_else_ce/vue_shared/components/members/table/members_table_cell.vue'; -import { - canOverride, - canRemove, - canResend, - canUpdate, -} from 'ee_else_ce/vue_shared/components/members/utils'; -import { FIELDS } from '../constants'; +import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; +import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils'; +import { FIELDS } from '../../constants'; import initUserPopovers from '~/user_popovers'; import MemberAvatar from './member_avatar.vue'; import MemberSource from './member_source.vue'; @@ -34,9 +29,7 @@ export default { RemoveGroupLinkModal, ExpirationDatepicker, LdapOverrideConfirmationModal: () => - import( - 'ee_component/vue_shared/components/members/ldap/ldap_override_confirmation_modal.vue' - ), + import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), }, computed: { ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']), diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue index 11e1aef9803..20aa01b96bc 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue +++ b/app/assets/javascripts/members/components/table/members_table_cell.vue @@ -1,7 +1,14 @@ <script> import { mapState } from 'vuex'; -import { MEMBER_TYPES } from '../constants'; -import { isGroup, isDirectMember, isCurrentUser, canRemove, canResend, canUpdate } from '../utils'; +import { MEMBER_TYPES } from '../../constants'; +import { + isGroup, + isDirectMember, + isCurrentUser, + canRemove, + canResend, + canUpdate, +} from '../../utils'; export default { name: 'MembersTableCell', diff --git a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index 6f6cae6072d..8ad45ab6920 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -9,8 +9,7 @@ export default { components: { GlDropdown, GlDropdownItem, - LdapDropdownItem: () => - import('ee_component/vue_shared/components/members/ldap/ldap_dropdown_item.vue'), + LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'), }, props: { member: { diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/members/constants.js index 5885420a122..21af825f795 100644 --- a/app/assets/javascripts/vue_shared/components/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -4,6 +4,10 @@ export const FIELDS = [ { key: 'account', label: __('Account'), + sort: { + asc: 'name_asc', + desc: 'name_desc', + }, }, { key: 'source', @@ -16,6 +20,10 @@ export const FIELDS = [ label: __('Access granted'), thClass: 'col-meta', tdClass: 'col-meta', + sort: { + asc: 'last_joined', + desc: 'oldest_joined', + }, }, { key: 'invited', @@ -40,6 +48,10 @@ export const FIELDS = [ label: __('Max role'), thClass: 'col-max-role', tdClass: 'col-max-role', + sort: { + asc: 'access_level_asc', + desc: 'access_level_desc', + }, }, { key: 'expiration', @@ -48,6 +60,14 @@ export const FIELDS = [ tdClass: 'col-expiration', }, { + key: 'lastSignIn', + label: __('Last sign-in'), + sort: { + asc: 'recent_sign_in', + desc: 'oldest_sign_in', + }, + }, + { key: 'actions', thClass: 'col-actions', tdClass: 'col-actions', @@ -55,6 +75,11 @@ export const FIELDS = [ }, ]; +export const DEFAULT_SORT = { + sortByKey: 'account', + sortDesc: false, +}; + export const AVATAR_SIZE = 48; export const MEMBER_TYPES = { @@ -69,3 +94,7 @@ export const DAYS_TO_EXPIRE_SOON = 7; export const LEAVE_MODAL_ID = 'member-leave-modal'; 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'; diff --git a/app/assets/javascripts/vuex_shared/modules/members/actions.js b/app/assets/javascripts/members/store/actions.js index 4c31b3c9744..4c31b3c9744 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/actions.js +++ b/app/assets/javascripts/members/store/actions.js diff --git a/app/assets/javascripts/members/store/index.js b/app/assets/javascripts/members/store/index.js new file mode 100644 index 00000000000..f219f8931b0 --- /dev/null +++ b/app/assets/javascripts/members/store/index.js @@ -0,0 +1,9 @@ +import createState from 'ee_else_ce/members/store/state'; +import mutations from 'ee_else_ce/members/store/mutations'; +import * as actions from 'ee_else_ce/members/store/actions'; + +export default initialState => ({ + state: createState(initialState), + actions, + mutations, +}); diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js b/app/assets/javascripts/members/store/mutation_types.js index 77307aa745b..77307aa745b 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js +++ b/app/assets/javascripts/members/store/mutation_types.js diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutations.js b/app/assets/javascripts/members/store/mutations.js index 2415e744290..2415e744290 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/mutations.js +++ b/app/assets/javascripts/members/store/mutations.js diff --git a/app/assets/javascripts/vuex_shared/modules/members/state.js b/app/assets/javascripts/members/store/state.js index ab3ebb34616..23a7983adcc 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/state.js +++ b/app/assets/javascripts/members/store/state.js @@ -2,18 +2,24 @@ export default ({ members, sourceId, currentUserId, + canManageMembers, tableFields, tableAttrs, + tableSortableFields, memberPath, requestFormatter, + filteredSearchBar, }) => ({ members, sourceId, currentUserId, + canManageMembers, tableFields, tableAttrs, + tableSortableFields, memberPath, requestFormatter, + filteredSearchBar, showError: false, errorMessage: '', removeGroupLinkModalVisible: false, diff --git a/app/assets/javascripts/vuex_shared/modules/members/utils.js b/app/assets/javascripts/members/store/utils.js index 7dcd33111e8..7dcd33111e8 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/utils.js +++ b/app/assets/javascripts/members/store/utils.js diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js new file mode 100644 index 00000000000..bf1fc2d7515 --- /dev/null +++ b/app/assets/javascripts/members/utils.js @@ -0,0 +1,97 @@ +import { __ } from '~/locale'; +import { getParameterByName } from '~/lib/utils/common_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { FIELDS, DEFAULT_SORT } from './constants'; + +export const generateBadges = (member, isCurrentUser) => [ + { + show: isCurrentUser, + text: __("It's you"), + variant: 'success', + }, + { + show: member.user?.blocked, + text: __('Blocked'), + variant: 'danger', + }, + { + show: member.user?.twoFactorEnabled, + text: __('2FA'), + variant: 'info', + }, +]; + +export const isGroup = member => { + return Boolean(member.sharedWithGroup); +}; + +export const isDirectMember = (member, sourceId) => { + return isGroup(member) || member.source?.id === sourceId; +}; + +export const isCurrentUser = (member, currentUserId) => { + return member.user?.id === currentUserId; +}; + +export const canRemove = (member, sourceId) => { + return isDirectMember(member, sourceId) && member.canRemove; +}; + +export const canResend = member => { + return Boolean(member.invite?.canResend); +}; + +export const canUpdate = (member, currentUserId, sourceId) => { + return ( + !isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate + ); +}; + +export const parseSortParam = sortableFields => { + const sortParam = getParameterByName('sort'); + + const sortedField = FIELDS.filter(field => sortableFields.includes(field.key)).find( + field => field.sort?.asc === sortParam || field.sort?.desc === sortParam, + ); + + if (!sortedField) { + return DEFAULT_SORT; + } + + return { + sortByKey: sortedField.key, + sortDesc: sortedField?.sort?.desc === sortParam, + }; +}; + +export const buildSortHref = ({ + sortBy, + sortDesc, + filteredSearchBarTokens, + filteredSearchBarSearchParam, +}) => { + const sortDefinition = FIELDS.find(field => field.key === sortBy)?.sort; + + if (!sortDefinition) { + return ''; + } + + const sortParam = sortDesc ? sortDefinition.desc : sortDefinition.asc; + + const filterParams = + filteredSearchBarTokens?.reduce((accumulator, token) => { + return { + ...accumulator, + [token]: getParameterByName(token), + }; + }, {}) || {}; + + if (filteredSearchBarSearchParam) { + filterParams[filteredSearchBarSearchParam] = getParameterByName(filteredSearchBarSearchParam); + } + + return setUrlParams({ ...filterParams, sort: sortParam }, window.location.href, true); +}; + +// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js` +export const canOverride = () => false; diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js index 25c357b6073..c803774f4a7 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js @@ -54,7 +54,6 @@ import { s__ } from '~/locale'; file.promptDiscardConfirmation = false; file.resolveMode = DEFAULT_RESOLVE_MODE; file.filePath = this.getFilePath(file); - file.iconClass = `fa-${file.blob_icon}`; file.blobPath = file.blob_path; if (file.type === CONFLICT_TYPES.TEXT) { diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index a5a930572e1..229f6f3e339 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; import { deprecatedCreateFlash as createFlash } from '../flash'; import initIssuableSidebar from '../init_issuable_sidebar'; import './merge_conflict_store'; @@ -24,6 +25,7 @@ export default function initMergeConflicts() { gl.MergeConflictsResolverApp = new Vue({ el: '#conflicts', components: { + FileIcon, 'diff-file-editor': gl.mergeConflicts.diffFileEditor, 'inline-conflict-lines': gl.mergeConflicts.inlineConflictLines, 'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines, diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index fe4e2cee69f..344f8dee5ea 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -102,14 +102,6 @@ MergeRequest.prototype.initMRBtnListeners = function() { return $('.btn-close, .btn-reopen').on('click', function(e) { const $this = $(this); const shouldSubmit = $this.hasClass('btn-comment'); - if ($this.hasClass('js-btn-issue-action')) { - const url = $this.data('endpoint'); - return axios - .put(url) - .then(() => window.location.reload()) - .catch(() => createFlash(__('Something went wrong.'))); - } - if (shouldSubmit && $this.data('submitted')) { return; } @@ -171,10 +163,6 @@ MergeRequest.decreaseCounter = function(by = 1) { MergeRequest.hideCloseButton = function() { const el = document.querySelector('.merge-request .js-issuable-actions'); - const closeDropdownItem = el.querySelector('li.close-item'); - if (closeDropdownItem) { - closeDropdownItem.classList.add('hidden'); - } // Dropdown for mobile screen el.querySelector('li.js-close-item').classList.add('hidden'); }; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index bdcdabe8f78..6e9661ea1a8 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -369,6 +369,9 @@ export default class MergeRequestTabs { projectId: pipelineTableViewEl.dataset.projectId, mergeRequestId: mrWidgetData ? mrWidgetData.iid : null, }, + provide: { + targetProjectFullPath: mrWidgetData?.target_project_full_path || '', + }, }).$mount(); // $mount(el) replaces the el with the new rendered component. We need it in order to mount diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 52f6786ca28..baa5e41989b 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -53,8 +53,7 @@ export default class MilestoneSelect { const $block = $selectBox.closest('.block'); const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); const $value = $block.find('.value'); - // eslint-disable-next-line no-jquery/no-fade - const $loading = $block.find('.block-loading').fadeOut(); + const $loading = $block.find('.block-loading').addClass('gl-display-none'); selectedMilestoneDefault = showAny ? '' : null; selectedMilestoneDefault = showNo && defaultNo ? __('No milestone') : selectedMilestoneDefault; @@ -255,34 +254,29 @@ export default class MilestoneSelect { } $dropdown.trigger('loading.gl.dropdown'); - // eslint-disable-next-line no-jquery/no-fade - $loading.removeClass('hidden').fadeIn(); + $loading.removeClass('gl-display-none'); boardsStore.detail.issue .update($dropdown.attr('data-issue-update')) .then(() => { $dropdown.trigger('loaded.gl.dropdown'); - // eslint-disable-next-line no-jquery/no-fade - $loading.fadeOut(); + $loading.addClass('gl-display-none'); }) .catch(() => { - // eslint-disable-next-line no-jquery/no-fade - $loading.fadeOut(); + $loading.addClass('gl-display-none'); }); } else { selected = $selectBox.find('input[type="hidden"]').val(); data = {}; data[abilityName] = {}; data[abilityName].milestone_id = selected != null ? selected : null; - // eslint-disable-next-line no-jquery/no-fade - $loading.removeClass('hidden').fadeIn(); + $loading.removeClass('gl-display-none'); $dropdown.trigger('loading.gl.dropdown'); return axios .put(issueUpdateURL, data) .then(({ data }) => { $dropdown.trigger('loaded.gl.dropdown'); - // eslint-disable-next-line no-jquery/no-fade - $loading.fadeOut(); + $loading.addClass('gl-display-none'); $selectBox.hide(); $value.css('display', ''); if (data.milestone != null) { @@ -313,8 +307,7 @@ export default class MilestoneSelect { .text(__('None')); }) .catch(() => { - // eslint-disable-next-line no-jquery/no-fade - $loading.fadeOut(); + $loading.addClass('gl-display-none'); }); } }, diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js index 818ca8aa847..18ea27e9a34 100644 --- a/app/assets/javascripts/mirrors/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -39,6 +39,7 @@ export default class MirrorRepos { initMirrorSSH() { if (this.$password) { + // eslint-disable-next-line @gitlab/no-global-event-off this.$password.off('input.updateUrl'); } this.$password = undefined; diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index eecfaa76168..c6486350f3b 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -185,10 +185,15 @@ export default class SSHMirror { } destroy() { + // eslint-disable-next-line @gitlab/no-global-event-off this.$repositoryUrl.off('keyup'); + // eslint-disable-next-line @gitlab/no-global-event-off this.$form.find('.js-known-hosts').off('keyup'); + // eslint-disable-next-line @gitlab/no-global-event-off this.$dropdownAuthType.off('change'); + // eslint-disable-next-line @gitlab/no-global-event-off this.$btnDetectHostKeys.off('click'); + // eslint-disable-next-line @gitlab/no-global-event-off this.$btnSSHHostsShowAdvanced.off('click'); } } diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue index 6f29b34141d..71691429ece 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue @@ -31,7 +31,7 @@ const SUBMIT_ACTION_TEXT = { const SUBMIT_BUTTON_CLASS = { create: 'btn-success', update: 'btn-success', - delete: 'btn-remove', + delete: 'btn-danger', }; export default { diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index bda2adeb62a..170c5ff7695 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -367,6 +367,7 @@ export default { }, ); + // eslint-disable-next-line @gitlab/no-global-event-off eChart.off('datazoom'); eChart.on('datazoom', this.throttledDatazoom); }, diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 597600bba07..ad7127d97de 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -394,10 +394,10 @@ export default { data-qa-selector="prometheus_graph_widgets" > <div data-testid="dropdown-wrapper" class="d-flex align-items-center"> - <!-- + <!-- 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 diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js index 9245ffdb3b9..4ae5cf04ff9 100644 --- a/app/assets/javascripts/monitoring/stores/variable_mapping.js +++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js @@ -271,5 +271,3 @@ export const optionsFromSeriesData = ({ label, data = [] }) => { return [...optionsSet].map(parseSimpleCustomValues); }; - -export default {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 92bbce498d5..a4c5a881fae 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -404,5 +404,3 @@ export const barChartsDataParser = (data = []) => }), {}, ); - -export default {}; diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index a3d7ddd5bad..dc5b2b66348 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -1,5 +1,5 @@ <script> -/* eslint-disable vue/no-v-html */ +import { GlSafeHtmlDirective } from '@gitlab/ui'; import { sanitize } from '~/lib/dompurify'; import Prompt from '../prompt.vue'; @@ -7,6 +7,9 @@ export default { components: { Prompt, }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, props: { count: { type: Number, @@ -23,9 +26,7 @@ export default { }, computed: { sanitizedOutput() { - return sanitize(this.rawCode, { - ALLOWED_ATTR: ['src'], - }); + return sanitize(this.rawCode); }, showOutput() { return this.index === 0; @@ -37,6 +38,6 @@ export default { <template> <div class="output"> <prompt type="Out" :count="count" :show-output="showOutput" /> - <div class="gl-overflow-auto" v-html="sanitizedOutput"></div> + <div v-safe-html="sanitizedOutput" class="gl-overflow-auto"></div> </div> </template> diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index f2d3796cccf..113d8cfc435 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -31,6 +31,8 @@ export default { return 'text/plain'; } else if (output.data['image/png']) { return 'image/png'; + } else if (output.data['image/jpeg']) { + return 'image/jpeg'; } else if (output.data['text/html']) { return 'text/html'; } else if (output.data['image/svg+xml']) { @@ -53,6 +55,8 @@ export default { return CodeOutput; } else if (output.data['image/png']) { return ImageOutput; + } else if (output.data['image/jpeg']) { + return ImageOutput; } else if (output.data['text/html']) { return HtmlOutput; } else if (output.data['image/svg+xml']) { diff --git a/app/assets/javascripts/notebook/lib/highlight.js b/app/assets/javascripts/notebook/lib/highlight.js index 74ade6d2edf..313aeecbd51 100644 --- a/app/assets/javascripts/notebook/lib/highlight.js +++ b/app/assets/javascripts/notebook/lib/highlight.js @@ -1,22 +1,5 @@ import Prism from 'prismjs'; import 'prismjs/components/prism-python'; -import 'prismjs/plugins/custom-class/prism-custom-class'; - -Prism.plugins.customClass.map({ - comment: 'c', - error: 'err', - operator: 'o', - constant: 'kc', - namespace: 'kn', - keyword: 'k', - string: 's', - number: 'm', - 'attr-name': 'na', - builtin: 'nb', - entity: 'ni', - function: 'nf', - tag: 'nt', - variable: 'nv', -}); +import 'prismjs/themes/prism.css'; export default Prism; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 37bb79defd1..9a887021e5d 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -187,6 +187,7 @@ export default class Notes { this.$wrapperEl.off('click', '.js-discussion-reply-button'); this.$wrapperEl.off('click', '.js-add-diff-note-button'); this.$wrapperEl.off('click', '.js-add-image-diff-note-button'); + // eslint-disable-next-line @gitlab/no-global-event-off this.$wrapperEl.off('visibilitychange'); this.$wrapperEl.off('keyup input', '.js-note-text'); this.$wrapperEl.off('click', '.js-note-target-reopen'); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 9cc53a320b8..0363173f912 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -3,23 +3,23 @@ import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import { isEmpty } from 'lodash'; import Autosize from 'autosize'; -import { GlAlert, GlIntersperse, GlLink, GlSprintf, GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import { deprecatedCreateFlash as Flash } from '../../flash'; -import Autosave from '../../autosave'; +import { deprecatedCreateFlash as Flash } from '~/flash'; +import Autosave from '~/autosave'; import { capitalizeFirstCharacter, convertToCamelCase, splitCamelCase, slugifyWithUnderscore, -} from '../../lib/utils/text_utility'; +} from '~/lib/utils/text_utility'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import * as constants from '../constants'; import eventHub from '../event_hub'; -import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue'; -import markdownField from '../../vue_shared/components/markdown/field.vue'; -import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; +import markdownField from '~/vue_shared/components/markdown/field.vue'; +import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; import discussionLockedWidget from './discussion_locked_widget.vue'; import issuableStateMixin from '../mixins/issuable_state'; @@ -34,10 +34,6 @@ export default { userAvatarLink, GlButton, TimelineEntryItem, - GlAlert, - GlIntersperse, - GlLink, - GlSprintf, GlIcon, }, mixins: [issuableStateMixin], @@ -63,9 +59,8 @@ export default { 'getNoteableDataByProp', 'getNotesData', 'openState', - 'getBlockedByIssues', ]), - ...mapState(['isToggleStateButtonLoading', 'isToggleBlockedIssueWarning']), + ...mapState(['isToggleStateButtonLoading']), noteableDisplayName() { return splitCamelCase(this.noteableType).toLowerCase(); }, @@ -143,7 +138,7 @@ export default { ? __('merge request') : __('issue'); }, - isIssueType() { + isIssue() { return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE; }, trackingLabel() { @@ -172,11 +167,9 @@ export default { 'stopPolling', 'restartPolling', 'removePlaceholderNotes', - 'closeIssue', - 'reopenIssue', + 'closeIssuable', + 'reopenIssuable', 'toggleIssueLocalState', - 'toggleStateButtonLoading', - 'toggleBlockedIssueWarning', ]), setIsSubmitButtonDisabled(note, isSubmitting) { if (!isEmpty(note) && !isSubmitting) { @@ -186,8 +179,6 @@ export default { } }, handleSave(withIssueAction) { - this.isSubmitting = true; - if (this.note.length) { const noteData = { endpoint: this.endpoint, @@ -210,9 +201,10 @@ export default { this.resizeTextarea(); this.stopPolling(); + this.isSubmitting = true; + this.saveNote(noteData) .then(() => { - this.enableButton(); this.restartPolling(); this.discard(); @@ -221,7 +213,6 @@ export default { } }) .catch(() => { - this.enableButton(); this.discard(false); const msg = __( 'Your comment could not be submitted! Please check your network connection and try again.', @@ -229,64 +220,27 @@ export default { Flash(msg, 'alert', this.$el); this.note = noteData.data.note.note; // Restore textarea content. this.removePlaceholderNotes(); + }) + .finally(() => { + this.isSubmitting = false; }); } else { this.toggleIssueState(); } }, - enableButton() { - this.isSubmitting = false; - }, toggleIssueState() { - if ( - this.noteableType.toLowerCase() === constants.ISSUE_NOTEABLE_TYPE && - this.isOpen && - this.getBlockedByIssues && - this.getBlockedByIssues.length > 0 - ) { - this.toggleBlockedIssueWarning(true); + if (this.isIssue) { + // We want to invoke the close/reopen logic in the issue header + // since that is where the blocked-by issues modal logic is also defined + eventHub.$emit('toggle.issuable.state'); return; } - if (this.isOpen) { - this.forceCloseIssue(); - } else { - this.reopenIssue() - .then(() => { - this.enableButton(); - refreshUserMergeRequestCounts(); - }) - .catch(({ data }) => { - this.enableButton(); - this.toggleStateButtonLoading(false); - let errorMessage = sprintf( - __('Something went wrong while reopening the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, - ); - if (data) { - errorMessage = Object.values(data).join('\n'); - } + const toggleState = this.isOpen ? this.closeIssuable : this.reopenIssuable; - Flash(errorMessage); - }); - } - }, - forceCloseIssue() { - this.closeIssue() - .then(() => { - this.enableButton(); - refreshUserMergeRequestCounts(); - }) - .catch(() => { - this.enableButton(); - this.toggleStateButtonLoading(false); - Flash( - sprintf( - __('Something went wrong while closing the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, - ), - ); - }); + toggleState() + .then(refreshUserMergeRequestCounts) + .catch(() => Flash(constants.toggleStateErrorMessage[this.noteableType][this.openState])); }, discard(shouldClear = true) { // `blur` is needed to clear slash commands autocomplete cache if event fired. @@ -384,6 +338,7 @@ export default { name="note[note]" class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area" data-qa-selector="comment_field" + data-testid="comment-field" data-supports-quick-actions="true" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @@ -392,36 +347,7 @@ export default { @keydown.ctrl.enter="handleSave()" ></textarea> </markdown-field> - <gl-alert - v-if="isToggleBlockedIssueWarning" - class="gl-mt-5" - :title="__('Are you sure you want to close this blocked issue?')" - :primary-button-text="__('Yes, close issue')" - :secondary-button-text="__('Cancel')" - variant="warning" - :dismissible="false" - @primaryAction="toggleBlockedIssueWarning(false) && forceCloseIssue()" - @secondaryAction="toggleBlockedIssueWarning(false) && enableButton()" - > - <p> - <gl-sprintf - :message=" - __('This issue is currently blocked by the following issues: %{issues}.') - " - > - <template #issues> - <gl-intersperse> - <gl-link - v-for="blockingIssue in getBlockedByIssues" - :key="blockingIssue.web_url" - :href="blockingIssue.web_url" - >#{{ blockingIssue.iid }}</gl-link - > - </gl-intersperse> - </template> - </gl-sprintf> - </p> - </gl-alert> + <div class="note-form-actions"> <div class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" @@ -430,6 +356,7 @@ export default { :disabled="isSubmitButtonDisabled" class="js-comment-button js-comment-submit-button" data-qa-selector="comment_button" + data-testid="comment-button" type="submit" category="primary" variant="success" @@ -488,15 +415,13 @@ export default { </div> <gl-button - v-if="canToggleIssueState && !isToggleBlockedIssueWarning" + v-if="canToggleIssueState" :loading="isToggleStateButtonLoading" category="secondary" :variant="buttonVariant" - :class="[ - actionButtonClassNames, - 'btn-comment btn-comment-and-close js-action-button', - ]" - :disabled="isToggleStateButtonLoading || isSubmitting" + :class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']" + :disabled="isSubmitting" + data-testid="close-reopen-button" @click="handleSave(true)" >{{ issueActionButtonTitle }}</gl-button > diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 91cf682943e..1580c94658a 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -7,7 +7,7 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { getDiffMode } from '~/diffs/store/utils'; import { diffViewerModes } from '~/ide/constants'; -import { isCollapsed } from '../../diffs/diff_file'; +import { isCollapsed } from '../../diffs/utils/diff_file'; const FIRST_CHAR_REGEX = /^(\+|-| )/; @@ -131,14 +131,18 @@ export default { :file-hash="discussion.diff_file.file_hash" :project-path="projectPath" > - <image-diff-overlay - slot="image-overlay" - :discussions="discussion" - :file-hash="discussion.diff_file.file_hash" - :show-comment-icon="true" - :should-toggle-discussion="false" - badge-class="image-comment-badge" - /> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <image-diff-overlay + v-if="renderedWidth" + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + :discussions="discussion" + :file-hash="discussion.diff_file.file_hash" + :show-comment-icon="true" + :should-toggle-discussion="false" + badge-class="image-comment-badge gl-text-gray-500" + /> + </template> </diff-viewer> <slot></slot> </div> diff --git a/app/assets/javascripts/notes/components/multiline_comment_utils.js b/app/assets/javascripts/notes/components/multiline_comment_utils.js index dbae10c8f6c..2451400e980 100644 --- a/app/assets/javascripts/notes/components/multiline_comment_utils.js +++ b/app/assets/javascripts/notes/components/multiline_comment_utils.js @@ -103,9 +103,15 @@ export function getCommentedLines(selectedCommentPosition, diffLines) { }; } + const findLineCodeIndex = line => position => { + return [position.line_code, position.left?.line_code, position.right?.line_code].includes( + line.line_code, + ); + }; + const { start, end } = selectedCommentPosition; - const startLine = diffLines.findIndex(l => l.line_code === start.line_code); - const endLine = diffLines.findIndex(l => l.line_code === end.line_code); + const startLine = diffLines.findIndex(findLineCodeIndex(start)); + const endLine = diffLines.findIndex(findLineCodeIndex(end)); return { startLine, endLine }; } diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 43f17c5d65c..84769bfc7c8 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -422,7 +422,7 @@ export default { </button> <button v-if="discussion.resolvable" - class="btn btn-nr btn-default gl-mr-3 js-comment-resolve-button" + class="btn btn-default gl-mr-3 js-comment-resolve-button" @click.prevent="handleUpdate(true)" > {{ resolveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index cacf209ed81..17a995018d3 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -85,7 +85,10 @@ export default { }; }, authorStatus() { - return this.author.status_tooltip_html; + if (this.author?.show_status) { + return this.author.status_tooltip_html; + } + return false; }, authorIsBusy() { const { status } = this.author; @@ -142,7 +145,7 @@ export default { type="button" @click="handleToggle" > - <gl-icon ref="chevronIcon" :name="toggleChevronIconName" aria-hidden="true" /> + <gl-icon ref="chevronIcon" :name="toggleChevronIconName" /> {{ __('Toggle thread') }} </button> </div> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 9be53fe60f2..5073922e4a4 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -23,6 +23,7 @@ import { commentLineOptions, formatLineRange, } from './multiline_comment_utils'; +import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; export default { name: 'NoteableNote', @@ -169,12 +170,8 @@ export default { return this.line && this.startLineNumber !== this.endLineNumber; }, commentLineOptions() { - const sideA = this.line.type === 'new' ? 'right' : 'left'; - const sideB = sideA === 'left' ? 'right' : 'left'; - const lines = this.diffFile.highlighted_diff_lines.length - ? this.diffFile.highlighted_diff_lines - : this.diffFile.parallel_diff_lines.map(l => l[sideA] || l[sideB]); - return commentLineOptions(lines, this.commentLineStart, this.line.line_code, sideA); + const lines = this.diffFile[INLINE_DIFF_LINES_KEY].length; + return commentLineOptions(lines, this.commentLineStart, this.line.line_code); }, diffFile() { if (this.commentLineStart.line_code) { diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 7acf2ad57c8..cc14ea42a89 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const DISCUSSION_NOTE = 'DiscussionNote'; export const DIFF_NOTE = 'DiffNote'; export const DISCUSSION = 'discussion'; @@ -36,3 +38,16 @@ export const DISCUSSION_FILTER_TYPES = { COMMENTS: 'comments', HISTORY: 'history', }; + +export const toggleStateErrorMessage = { + Epic: { + [CLOSED]: __('Something went wrong while reopening the epic. Please try again later.'), + [OPENED]: __('Something went wrong while closing the epic. Please try again later.'), + [REOPENED]: __('Something went wrong while closing the epic. Please try again later.'), + }, + MergeRequest: { + [CLOSED]: __('Something went wrong while reopening the merge request. Please try again later.'), + [OPENED]: __('Something went wrong while closing the merge request. Please try again later.'), + [REOPENED]: __('Something went wrong while closing the merge request. Please try again later.'), + }, +}; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 61298a15c5d..c6932bfacae 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,16 +1,17 @@ import { mapGetters, mapActions, mapState } from 'vuex'; -import { scrollToElementWithContext } from '~/lib/utils/common_utils'; +import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils'; import eventHub from '../event_hub'; /** * @param {string} selector * @returns {boolean} */ -function scrollTo(selector) { +function scrollTo(selector, { withoutContext = false } = {}) { const el = document.querySelector(selector); + const scrollFunction = withoutContext ? scrollToElement : scrollToElementWithContext; if (el) { - scrollToElementWithContext(el); + scrollFunction(el); return true; } @@ -35,7 +36,7 @@ function diffsJump({ expandDiscussion }, id) { function discussionJump({ expandDiscussion }, id) { const selector = `div.discussion[data-discussion-id="${id}"]`; expandDiscussion({ discussionId: id }); - return scrollTo(selector); + return scrollTo(selector, { withoutContext: true }); } /** diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 2c60b5ee84a..1fe5d6c2955 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -244,21 +244,7 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, }); }; -export const toggleBlockedIssueWarning = ({ commit }, value) => { - commit(types.TOGGLE_BLOCKED_ISSUE_WARNING, value); - // Hides Close issue button at the top of issue page - const closeDropdown = document.querySelector('.js-issuable-close-dropdown'); - if (closeDropdown) { - closeDropdown.classList.toggle('d-none'); - } else { - const closeButton = document.querySelector( - '.detail-page-header-actions .btn-close.btn-grouped', - ); - closeButton.classList.toggle('d-md-block'); - } -}; - -export const closeIssue = ({ commit, dispatch, state }) => { +export const closeIssuable = ({ commit, dispatch, state }) => { dispatch('toggleStateButtonLoading', true); return axios.put(state.notesData.closePath).then(({ data }) => { commit(types.CLOSE_ISSUE); @@ -267,7 +253,7 @@ export const closeIssue = ({ commit, dispatch, state }) => { }); }; -export const reopenIssue = ({ commit, dispatch, state }) => { +export const reopenIssuable = ({ commit, dispatch, state }) => { dispatch('toggleStateButtonLoading', true); return axios.put(state.notesData.reopenPath).then(({ data }) => { commit(types.REOPEN_ISSUE); @@ -435,6 +421,10 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }; const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { + if (state.isResolvingDiscussion) { + return null; + } + if (resp.notes?.length) { dispatch('updateOrCreateNotes', resp.notes); dispatch('startTaskList'); @@ -574,6 +564,9 @@ export const submitSuggestion = ( const dispatchResolveDiscussion = () => dispatch('resolveDiscussion', { discussionId }).catch(() => {}); + commit(types.SET_RESOLVING_DISCUSSION, true); + dispatch('stopPolling'); + return Api.applySuggestion(suggestionId) .then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId })) .then(dispatchResolveDiscussion) @@ -587,6 +580,10 @@ export const submitSuggestion = ( const flashMessage = errorMessage || defaultMessage; Flash(__(flashMessage), 'alert', flashContainer); + }) + .finally(() => { + commit(types.SET_RESOLVING_DISCUSSION, false); + dispatch('restartPolling'); }); }; @@ -605,6 +602,8 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai }); commit(types.SET_APPLYING_BATCH_STATE, true); + commit(types.SET_RESOLVING_DISCUSSION, true); + dispatch('stopPolling'); return Api.applySuggestionBatch(suggestionIds) .then(() => Promise.all(applyAllSuggestions())) @@ -621,7 +620,11 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai Flash(__(flashMessage), 'alert', flashContainer); }) - .finally(() => commit(types.SET_APPLYING_BATCH_STATE, false)); + .finally(() => { + commit(types.SET_APPLYING_BATCH_STATE, false); + commit(types.SET_RESOLVING_DISCUSSION, false); + dispatch('restartPolling'); + }); }; export const addSuggestionInfoToBatch = ({ commit }, { suggestionId, noteId, discussionId }) => diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js index d94fc626a3f..f34247d4eb0 100644 --- a/app/assets/javascripts/notes/stores/collapse_utils.js +++ b/app/assets/javascripts/notes/stores/collapse_utils.js @@ -70,6 +70,3 @@ export const collapseSystemNotes = notes => { return acc; }, []); }; - -// for babel-rewire -export default {}; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index a8738fa7c5f..4421a84a6b1 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -26,7 +26,6 @@ export default () => ({ // View layer isToggleStateButtonLoading: false, - isToggleBlockedIssueWarning: false, isNotesFetched: false, isLoading: true, isLoadingDescriptionVersion: false, @@ -42,6 +41,7 @@ export default () => ({ current_user: {}, preview_note_path: 'path/to/preview', }, + isResolvingDiscussion: false, commentsDisabled: false, resolvableDiscussionsCount: 0, unresolvedDiscussionsCount: 0, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 7496dd630f6..5c4f62f4575 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -38,12 +38,12 @@ export const SET_TIMELINE_VIEW = 'SET_TIMELINE_VIEW'; export const SET_SELECTED_COMMENT_POSITION = 'SET_SELECTED_COMMENT_POSITION'; export const SET_SELECTED_COMMENT_POSITION_HOVER = 'SET_SELECTED_COMMENT_POSITION_HOVER'; export const SET_FETCHING_DISCUSSIONS = 'SET_FETCHING_DISCUSSIONS'; +export const SET_RESOLVING_DISCUSSION = 'SET_RESOLVING_DISCUSSION'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const REOPEN_ISSUE = 'REOPEN_ISSUE'; export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; -export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING'; export const SET_ISSUE_CONFIDENTIAL = 'SET_ISSUE_CONFIDENTIAL'; export const SET_ISSUABLE_LOCK = 'SET_ISSUABLE_LOCK'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 7cc619ec1c5..53387b2eaff 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -213,6 +213,10 @@ export default { } }, + [types.SET_RESOLVING_DISCUSSION](state, isResolving) { + state.isResolvingDiscussion = isResolving; + }, + [types.UPDATE_NOTE](state, note) { const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); @@ -301,10 +305,6 @@ export default { Object.assign(state, { isToggleStateButtonLoading: value }); }, - [types.TOGGLE_BLOCKED_ISSUE_WARNING](state, value) { - Object.assign(state, { isToggleBlockedIssueWarning: value }); - }, - [types.SET_NOTES_FETCHED_STATE](state, value) { Object.assign(state, { isNotesFetched: value }); }, diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages/details/components/app.vue index af3220840a6..c9f1c8b903c 100644 --- a/app/assets/javascripts/packages/details/components/app.vue +++ b/app/assets/javascripts/packages/details/components/app.vue @@ -5,29 +5,26 @@ import { GlModal, GlModalDirective, GlTooltipDirective, - GlLink, GlEmptyState, GlTab, GlTabs, - GlTable, GlSprintf, } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import Tracking from '~/tracking'; +import { s__ } from '~/locale'; +import { objectToQueryString } from '~/lib/utils/common_utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; import PackageHistory from './package_history.vue'; import PackageTitle from './package_title.vue'; import PackagesListLoader from '../../shared/components/packages_list_loader.vue'; import PackageListRow from '../../shared/components/package_list_row.vue'; +import { packageTypeToTrackCategory } from '../../shared/utils'; +import { PackageType, TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '../../shared/constants'; import DependencyRow from './dependency_row.vue'; import AdditionalMetadata from './additional_metadata.vue'; import InstallationCommands from './installation_commands.vue'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import FileIcon from '~/vue_shared/components/file_icon.vue'; -import { __, s__ } from '~/locale'; -import { PackageType, TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '../../shared/constants'; -import { packageTypeToTrackCategory } from '../../shared/utils'; -import { objectToQueryString } from '~/lib/utils/common_utils'; +import PackageFiles from './package_files.vue'; export default { name: 'PackagesApp', @@ -35,12 +32,9 @@ export default { GlBadge, GlButton, GlEmptyState, - GlLink, GlModal, GlTab, GlTabs, - GlTable, - FileIcon, GlSprintf, PackageTitle, PackagesListLoader, @@ -49,12 +43,13 @@ export default { PackageHistory, AdditionalMetadata, InstallationCommands, + PackageFiles, }, directives: { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, - mixins: [timeagoMixin, Tracking.mixin()], + mixins: [Tracking.mixin()], trackingActions: { ...TrackingActions }, computed: { ...mapState([ @@ -72,14 +67,6 @@ export default { isValidPackage() { return Boolean(this.packageEntity.name); }, - filesTableRows() { - return this.packageFiles.map(x => ({ - name: x.file_name, - downloadPath: x.download_path, - size: this.formatSize(x.size), - created: x.created_at, - })); - }, tracking() { return { category: packageTypeToTrackCategory(this.packageEntity.package_type), @@ -128,22 +115,6 @@ export default { `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`, ), }, - filesTableHeaderFields: [ - { - key: 'name', - label: __('Name'), - tdClass: 'd-flex align-items-center', - }, - { - key: 'size', - label: __('Size'), - }, - { - key: 'created', - label: __('Created'), - class: 'text-right', - }, - ], }; </script> @@ -185,35 +156,11 @@ export default { <additional-metadata :package-entity="packageEntity" /> </div> - <template v-if="showFiles"> - <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> - <gl-table - :fields="$options.filesTableHeaderFields" - :items="filesTableRows" - tbody-tr-class="js-file-row" - > - <template #cell(name)="{ item }"> - <gl-link - :href="item.downloadPath" - class="js-file-download gl-relative" - @click="track($options.trackingActions.PULL_PACKAGE)" - > - <file-icon - :file-name="item.name" - css-classes="gl-relative file-icon" - class="gl-mr-1 gl-relative" - /> - <span class="gl-relative">{{ item.name }}</span> - </gl-link> - </template> - - <template #cell(created)="{ item }"> - <span v-gl-tooltip :title="tooltipTitle(item.created)">{{ - timeFormatted(item.created) - }}</span> - </template> - </gl-table> - </template> + <package-files + v-if="showFiles" + :package-files="packageFiles" + @download-file="track($options.trackingActions.PULL_PACKAGE)" + /> </gl-tab> <gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab"> diff --git a/app/assets/javascripts/packages/details/components/package_files.vue b/app/assets/javascripts/packages/details/components/package_files.vue new file mode 100644 index 00000000000..ab46dd0114d --- /dev/null +++ b/app/assets/javascripts/packages/details/components/package_files.vue @@ -0,0 +1,107 @@ +<script> +import { GlLink, GlTable } from '@gitlab/ui'; +import { last } from 'lodash'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; + +export default { + name: 'PackageFiles', + components: { + GlLink, + GlTable, + FileIcon, + TimeAgoTooltip, + }, + mixins: [Tracking.mixin()], + props: { + packageFiles: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + filesTableRows() { + return this.packageFiles.map(pf => ({ + ...pf, + size: this.formatSize(pf.size), + pipeline: last(pf.pipelines), + })); + }, + showCommitColumn() { + return this.filesTableRows.some(row => Boolean(row.pipeline?.id)); + }, + filesTableHeaderFields() { + return [ + { + key: 'name', + label: __('Name'), + tdClass: 'gl-display-flex gl-align-items-center', + }, + { + key: 'commit', + label: __('Commit'), + hide: !this.showCommitColumn, + }, + { + key: 'size', + label: __('Size'), + }, + { + key: 'created', + label: __('Created'), + class: 'gl-text-right', + }, + ].filter(c => !c.hide); + }, + }, + methods: { + formatSize(size) { + return numberToHumanSize(size); + }, + }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> + <gl-table + :fields="filesTableHeaderFields" + :items="filesTableRows" + :tbody-tr-attr="{ 'data-testid': 'file-row' }" + > + <template #cell(name)="{ item }"> + <gl-link + :href="item.download_path" + class="gl-relative gl-text-gray-500" + data-testid="download-link" + @click="$emit('download-file')" + > + <file-icon + :file-name="item.file_name" + css-classes="gl-relative file-icon" + class="gl-mr-1 gl-relative" + /> + <span class="gl-relative">{{ item.file_name }}</span> + </gl-link> + </template> + + <template #cell(commit)="{item}"> + <gl-link + :href="item.pipeline.project.commit_url" + class="gl-text-gray-500" + data-testid="commit-link" + >{{ item.pipeline.git_commit_message }}</gl-link + > + </template> + + <template #cell(created)="{ item }"> + <time-ago-tooltip :time="item.created_at" /> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/package_history.vue b/app/assets/javascripts/packages/details/components/package_history.vue index 413ab1d15cb..62550602428 100644 --- a/app/assets/javascripts/packages/details/components/package_history.vue +++ b/app/assets/javascripts/packages/details/components/package_history.vue @@ -1,17 +1,26 @@ <script> import { GlLink, GlSprintf } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { first } from 'lodash'; +import { s__, n__ } from '~/locale'; +import { truncateSha } from '~/lib/utils/text_utility'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants'; export default { name: 'PackageHistory', i18n: { - createdOn: s__('PackageRegistry|%{name} version %{version} was created %{datetime}'), - updatedAtText: s__('PackageRegistry|%{name} version %{version} was updated %{datetime}'), - commitText: s__('PackageRegistry|Commit %{link} on branch %{branch}'), - pipelineText: s__('PackageRegistry|Pipeline %{link} triggered %{datetime} by %{author}'), + createdOn: s__('PackageRegistry|%{name} version %{version} was first created %{datetime}'), + createdByCommitText: s__('PackageRegistry|Created by commit %{link} on branch %{branch}'), + createdByPipelineText: s__( + 'PackageRegistry|Built by pipeline %{link} triggered %{datetime} by %{author}', + ), publishText: s__('PackageRegistry|Published to the %{project} Package Registry %{datetime}'), + combinedUpdateText: s__( + 'PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}', + ), + archivedPipelineMessageSingular: s__('PackageRegistry|Package has %{number} archived update'), + archivedPipelineMessagePlural: s__('PackageRegistry|Package has %{number} archived updates'), }, components: { GlLink, @@ -35,8 +44,32 @@ export default { }; }, computed: { - packagePipeline() { - return this.packageEntity.pipeline?.id ? this.packageEntity.pipeline : null; + pipelines() { + return this.packageEntity.pipelines || []; + }, + firstPipeline() { + return first(this.pipelines); + }, + lastPipelines() { + return this.pipelines.slice(1).slice(-HISTORY_PIPELINES_LIMIT); + }, + showPipelinesInfo() { + return Boolean(this.firstPipeline?.id); + }, + archiviedLines() { + return Math.max(this.pipelines.length - HISTORY_PIPELINES_LIMIT - 1, 0); + }, + archivedPipelineMessage() { + return n__( + this.$options.i18n.archivedPipelineMessageSingular, + this.$options.i18n.archivedPipelineMessagePlural, + this.archiviedLines, + ); + }, + }, + methods: { + truncate(value) { + return truncateSha(value); }, }, }; @@ -59,46 +92,35 @@ export default { </template> </gl-sprintf> </history-item> - <history-item icon="pencil" data-testid="updated-at"> - <gl-sprintf :message="$options.i18n.updatedAtText"> - <template #name> - <strong>{{ packageEntity.name }}</strong> - </template> - <template #version> - <strong>{{ packageEntity.version }}</strong> - </template> - <template #datetime> - <time-ago-tooltip :time="packageEntity.updated_at" /> - </template> - </gl-sprintf> - </history-item> - <template v-if="packagePipeline"> - <history-item icon="commit" data-testid="commit"> - <gl-sprintf :message="$options.i18n.commitText"> + + <template v-if="showPipelinesInfo"> + <!-- FIRST PIPELINE BLOCK --> + <history-item icon="commit" data-testid="first-pipeline-commit"> + <gl-sprintf :message="$options.i18n.createdByCommitText"> <template #link> - <gl-link :href="packagePipeline.project.commit_url">{{ - packagePipeline.sha - }}</gl-link> + <gl-link :href="firstPipeline.project.commit_url" + >#{{ truncate(firstPipeline.sha) }}</gl-link + > </template> <template #branch> - <strong>{{ packagePipeline.ref }}</strong> + <strong>{{ firstPipeline.ref }}</strong> </template> </gl-sprintf> </history-item> - <history-item icon="pipeline" data-testid="pipeline"> - <gl-sprintf :message="$options.i18n.pipelineText"> + <history-item icon="pipeline" data-testid="first-pipeline-pipeline"> + <gl-sprintf :message="$options.i18n.createdByPipelineText"> <template #link> - <gl-link :href="packagePipeline.project.pipeline_url" - >#{{ packagePipeline.id }}</gl-link - > + <gl-link :href="firstPipeline.project.pipeline_url">#{{ firstPipeline.id }}</gl-link> </template> <template #datetime> - <time-ago-tooltip :time="packagePipeline.created_at" /> + <time-ago-tooltip :time="firstPipeline.created_at" /> </template> - <template #author>{{ packagePipeline.user.name }}</template> + <template #author>{{ firstPipeline.user.name }}</template> </gl-sprintf> </history-item> </template> + + <!-- PUBLISHED LINE --> <history-item icon="package" data-testid="published"> <gl-sprintf :message="$options.i18n.publishText"> <template #project> @@ -109,6 +131,37 @@ export default { </template> </gl-sprintf> </history-item> + + <history-item v-if="archiviedLines" icon="history" data-testid="archived"> + <gl-sprintf :message="archivedPipelineMessage"> + <template #number> + <strong>{{ archiviedLines }}</strong> + </template> + </gl-sprintf> + </history-item> + + <!-- PIPELINES LIST ENTRIES --> + <history-item + v-for="pipeline in lastPipelines" + :key="pipeline.id" + icon="pencil" + data-testid="pipeline-entry" + > + <gl-sprintf :message="$options.i18n.combinedUpdateText"> + <template #link> + <gl-link :href="pipeline.project.commit_url">#{{ truncate(pipeline.sha) }}</gl-link> + </template> + <template #branch> + <strong>{{ pipeline.ref }}</strong> + </template> + <template #pipeline> + <gl-link :href="pipeline.project.pipeline_url">#{{ pipeline.id }}</gl-link> + </template> + <template #datetime> + <time-ago-tooltip :time="pipeline.created_at" /> + </template> + </gl-sprintf> + </history-item> </ul> </div> </template> diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js index c6e1b388132..986b0667356 100644 --- a/app/assets/javascripts/packages/details/constants.js +++ b/app/assets/javascripts/packages/details/constants.js @@ -45,3 +45,5 @@ export const NpmManager = { export const FETCH_PACKAGE_VERSIONS_ERROR = s__( 'PackageRegistry|Unable to fetch package version information.', ); + +export const HISTORY_PIPELINES_LIMIT = 5; diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index 6a0e92bff2d..e14696e0d1c 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -68,6 +68,10 @@ export const PACKAGE_REGISTRY_TABS = [ title: s__('PackageRegistry|Conan'), type: PackageType.CONAN, }, + { + title: s__('PackageRegistry|Generic'), + type: PackageType.GENERIC, + }, { title: s__('PackageRegistry|Maven'), diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js index c481abd8658..c0f7f150337 100644 --- a/app/assets/javascripts/packages/shared/constants.js +++ b/app/assets/javascripts/packages/shared/constants.js @@ -7,6 +7,7 @@ export const PackageType = { NUGET: 'nuget', PYPI: 'pypi', COMPOSER: 'composer', + GENERIC: 'generic', }; export const TrackingActions = { diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js index b0807558266..d7a883e4397 100644 --- a/app/assets/javascripts/packages/shared/utils.js +++ b/app/assets/javascripts/packages/shared/utils.js @@ -21,7 +21,8 @@ export const getPackageTypeLabel = packageType => { return s__('PackageType|PyPI'); case PackageType.COMPOSER: return s__('PackageType|Composer'); - + case PackageType.GENERIC: + return s__('PackageType|Generic'); default: return null; } diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index 2aa37842707..f9a91ec322b 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -72,6 +72,7 @@ export default { }, initLoadMore() { + // eslint-disable-next-line @gitlab/no-global-event-off $(document).off('scroll'); $(document).endlessScroll({ bottomPixels: ENDLESS_SCROLL_BOTTOM_PX, diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js index 143d15f92cd..cce30e6b12a 100644 --- a/app/assets/javascripts/pages/admin/application_settings/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/index.js @@ -1,7 +1,6 @@ import initSettingsPanels from '~/settings_panels'; import projectSelect from '~/project_select'; import selfMonitor from '~/self_monitor'; -import maintenanceModeSettings from '~/maintenance_mode_settings'; import initVariableList from '~/ci_variable_list'; document.addEventListener('DOMContentLoaded', () => { @@ -9,7 +8,6 @@ document.addEventListener('DOMContentLoaded', () => { initVariableList('js-instance-variables'); } selfMonitor(); - maintenanceModeSettings(); // Initialize expandable settings panels initSettingsPanels(); projectSelect(); diff --git a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue index a08d32028c3..24c9fa4cb3f 100644 --- a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue +++ b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue @@ -1,14 +1,13 @@ <script> +import DeleteUserModal from './delete_user_modal.vue'; + export default { + components: { DeleteUserModal }, props: { modalConfiguration: { required: true, type: Object, }, - actionModals: { - required: true, - type: Object, - }, csrfToken: { required: true, type: String, @@ -21,10 +20,7 @@ export default { }, computed: { activeModal() { - if (!this.currentModalData) return null; - const { glModalAction: action } = this.currentModalData; - - return this.actionModals[action]; + return Boolean(this.currentModalData); }, modalProps() { @@ -56,9 +52,7 @@ export default { show(modalData) { const { glModalAction: requestedAction } = modalData; - if (!this.actionModals[requestedAction]) { - throw new Error(`Requested non-existing modal action ${requestedAction}`); - } + if (!this.modalConfiguration[requestedAction]) { throw new Error(`Modal action ${requestedAction} has no configuration in HTML`); } @@ -73,5 +67,5 @@ export default { }; </script> <template> - <div :is="activeModal" v-if="activeModal" ref="modal" v-bind="modalProps" /> + <delete-user-modal v-if="activeModal" ref="modal" v-bind="modalProps" /> </template> diff --git a/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue deleted file mode 100644 index 4ca6ce6f1c3..00000000000 --- a/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue +++ /dev/null @@ -1,71 +0,0 @@ -<script> -/* eslint-disable vue/no-v-html */ -import { GlModal } from '@gitlab/ui'; -import { sprintf } from '~/locale'; - -export default { - components: { - GlModal, - }, - props: { - title: { - type: String, - required: true, - }, - content: { - type: String, - required: true, - }, - action: { - type: String, - required: true, - }, - url: { - type: String, - required: true, - }, - username: { - type: String, - required: true, - }, - csrfToken: { - type: String, - required: true, - }, - method: { - type: String, - required: false, - default: 'put', - }, - }, - computed: { - modalTitle() { - return sprintf(this.title, { username: this.username }); - }, - }, - methods: { - show() { - this.$refs.modal.show(); - }, - submit() { - this.$refs.form.submit(); - }, - }, -}; -</script> -<template> - <gl-modal - ref="modal" - modal-id="user-operation-modal" - :title="modalTitle" - ok-variant="warning" - :ok-title="action" - @ok="submit" - > - <form ref="form" :action="url" method="post"> - <span v-html="content"></span> - <input ref="method" type="hidden" name="_method" :value="method" /> - <input :value="csrfToken" type="hidden" name="authenticity_token" /> - </form> - </gl-modal> -</template> diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js index 5f3cdc0bfc6..07462b4592f 100644 --- a/app/assets/javascripts/pages/admin/users/index.js +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -2,18 +2,12 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; import ModalManager from './components/user_modal_manager.vue'; -import DeleteUserModal from './components/delete_user_modal.vue'; -import UserOperationConfirmationModal from './components/user_operation_confirmation_modal.vue'; import csrf from '~/lib/utils/csrf'; import initConfirmModal from '~/confirm_modal'; +import initAdminUsersApp from '~/admin/users'; -const MODAL_TEXTS_CONTAINER_SELECTOR = '#modal-texts'; -const MODAL_MANAGER_SELECTOR = '#user-modal'; -const ACTION_MODALS = { - deactivate: UserOperationConfirmationModal, - delete: DeleteUserModal, - 'delete-with-contributions': DeleteUserModal, -}; +const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts'; +const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal'; function loadModalsConfigurationFromHtml(modalsElement) { const modalsConfiguration = {}; @@ -56,7 +50,6 @@ document.addEventListener('DOMContentLoaded', () => { ref: 'manager', props: { modalConfiguration, - actionModals: ACTION_MODALS, csrfToken: csrf.token, }, }); @@ -64,4 +57,5 @@ document.addEventListener('DOMContentLoaded', () => { }); initConfirmModal(); + initAdminUsersApp(); }); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 009a3eee526..d3900b84fa7 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -6,6 +6,7 @@ import groupsSelect from '~/groups_select'; import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; import { initGroupMembersApp } from '~/groups/members'; import { memberRequestFormatter, groupLinkRequestFormatter } from '~/groups/members/utils'; +import { s__ } from '~/locale'; function mountRemoveMemberModal() { const el = document.querySelector('.js-remove-member-modal'); @@ -22,30 +23,43 @@ function mountRemoveMemberModal() { } const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; -initGroupMembersApp( - document.querySelector('.js-group-members-list'), - SHARED_FIELDS.concat(['source', 'granted']), - { tr: { 'data-qa-selector': 'member_row' } }, - memberRequestFormatter, -); -initGroupMembersApp( - document.querySelector('.js-group-linked-list'), - SHARED_FIELDS.concat('granted'), - { table: { 'data-qa-selector': 'groups_list' }, tr: { 'data-qa-selector': 'group_row' } }, - groupLinkRequestFormatter, -); -initGroupMembersApp( - document.querySelector('.js-group-invited-members-list'), - SHARED_FIELDS.concat('invited'), - {}, - memberRequestFormatter, -); -initGroupMembersApp( - document.querySelector('.js-group-access-requests-list'), - SHARED_FIELDS.concat('requested'), - {}, - memberRequestFormatter, -); + +initGroupMembersApp(document.querySelector('.js-group-members-list'), { + tableFields: SHARED_FIELDS.concat(['source', 'granted']), + tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, + tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], + requestFormatter: memberRequestFormatter, + filteredSearchBar: { + show: true, + tokens: ['two_factor', 'with_inherited_permissions'], + searchParam: 'search', + placeholder: s__('Members|Filter members'), + recentSearchesStorageKey: 'group_members', + }, +}); +initGroupMembersApp(document.querySelector('.js-group-linked-list'), { + tableFields: SHARED_FIELDS.concat('granted'), + tableAttrs: { + table: { 'data-qa-selector': 'groups_list' }, + tr: { 'data-qa-selector': 'group_row' }, + }, + requestFormatter: groupLinkRequestFormatter, +}); +initGroupMembersApp(document.querySelector('.js-group-invited-members-list'), { + tableFields: SHARED_FIELDS.concat('invited'), + requestFormatter: memberRequestFormatter, + filteredSearchBar: { + show: true, + tokens: [], + searchParam: 'search_invited', + placeholder: s__('Members|Search invited'), + recentSearchesStorageKey: 'group_invited_members', + }, +}); +initGroupMembersApp(document.querySelector('.js-group-access-requests-list'), { + tableFields: SHARED_FIELDS.concat('requested'), + requestFormatter: memberRequestFormatter, +}); groupsSelect(); memberExpirationDate(); diff --git a/app/assets/javascripts/pages/import/bitbucket/status/index.js b/app/assets/javascripts/pages/import/bitbucket/status/index.js index 2a5432ce09d..f450a2aac00 100644 --- a/app/assets/javascripts/pages/import/bitbucket/status/index.js +++ b/app/assets/javascripts/pages/import/bitbucket/status/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import { initStoreFromElement, initPropsFromElement } from '~/import_projects'; -import BitbucketStatusTable from '~/import_projects/components/bitbucket_status_table.vue'; +import { initStoreFromElement, initPropsFromElement } from '~/import_entities/import_projects'; +import BitbucketStatusTable from '~/import_entities/import_projects/components/bitbucket_status_table.vue'; document.addEventListener('DOMContentLoaded', () => { const mountElement = document.getElementById('import-projects-mount-element'); diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue index 35ae9d8419f..f0c4ecbe3eb 100644 --- a/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue +++ b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import BitbucketStatusTable from '~/import_projects/components/bitbucket_status_table.vue'; +import BitbucketStatusTable from '~/import_entities/import_projects/components/bitbucket_status_table.vue'; export default { components: { diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js index a44fc4e6b29..a6d748ce857 100644 --- a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js +++ b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { initStoreFromElement, initPropsFromElement } from '~/import_projects'; +import { initStoreFromElement, initPropsFromElement } from '~/import_entities/import_projects'; import BitbucketServerStatusTable from './components/bitbucket_server_status_table.vue'; document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/pages/import/bulk_imports/index.js b/app/assets/javascripts/pages/import/bulk_imports/index.js new file mode 100644 index 00000000000..37ac1a98466 --- /dev/null +++ b/app/assets/javascripts/pages/import/bulk_imports/index.js @@ -0,0 +1,4 @@ +import { mountImportGroupsApp } from '~/import_entities/import_groups'; + +const mountElement = document.getElementById('import-groups-mount-element'); +mountImportGroupsApp(mountElement); diff --git a/app/assets/javascripts/pages/import/fogbugz/status/index.js b/app/assets/javascripts/pages/import/fogbugz/status/index.js index dcd84f0faf9..98ddb8b3aa4 100644 --- a/app/assets/javascripts/pages/import/fogbugz/status/index.js +++ b/app/assets/javascripts/pages/import/fogbugz/status/index.js @@ -1,4 +1,4 @@ -import mountImportProjectsTable from '~/import_projects'; +import mountImportProjectsTable from '~/import_entities/import_projects'; document.addEventListener('DOMContentLoaded', () => { const mountElement = document.getElementById('import-projects-mount-element'); diff --git a/app/assets/javascripts/pages/import/gitea/status/index.js b/app/assets/javascripts/pages/import/gitea/status/index.js index dcd84f0faf9..98ddb8b3aa4 100644 --- a/app/assets/javascripts/pages/import/gitea/status/index.js +++ b/app/assets/javascripts/pages/import/gitea/status/index.js @@ -1,4 +1,4 @@ -import mountImportProjectsTable from '~/import_projects'; +import mountImportProjectsTable from '~/import_entities/import_projects'; document.addEventListener('DOMContentLoaded', () => { const mountElement = document.getElementById('import-projects-mount-element'); diff --git a/app/assets/javascripts/pages/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js index dcd84f0faf9..98ddb8b3aa4 100644 --- a/app/assets/javascripts/pages/import/github/status/index.js +++ b/app/assets/javascripts/pages/import/github/status/index.js @@ -1,4 +1,4 @@ -import mountImportProjectsTable from '~/import_projects'; +import mountImportProjectsTable from '~/import_entities/import_projects'; document.addEventListener('DOMContentLoaded', () => { const mountElement = document.getElementById('import-projects-mount-element'); diff --git a/app/assets/javascripts/pages/import/gitlab/status/index.js b/app/assets/javascripts/pages/import/gitlab/status/index.js index dcd84f0faf9..98ddb8b3aa4 100644 --- a/app/assets/javascripts/pages/import/gitlab/status/index.js +++ b/app/assets/javascripts/pages/import/gitlab/status/index.js @@ -1,4 +1,4 @@ -import mountImportProjectsTable from '~/import_projects'; +import mountImportProjectsTable from '~/import_entities/import_projects'; document.addEventListener('DOMContentLoaded', () => { const mountElement = document.getElementById('import-projects-mount-element'); diff --git a/app/assets/javascripts/pages/import/manifest/status/index.js b/app/assets/javascripts/pages/import/manifest/status/index.js index dcd84f0faf9..98ddb8b3aa4 100644 --- a/app/assets/javascripts/pages/import/manifest/status/index.js +++ b/app/assets/javascripts/pages/import/manifest/status/index.js @@ -1,4 +1,4 @@ -import mountImportProjectsTable from '~/import_projects'; +import mountImportProjectsTable from '~/import_entities/import_projects'; document.addEventListener('DOMContentLoaded', () => { const mountElement = document.getElementById('import-projects-mount-element'); diff --git a/app/assets/javascripts/pages/profiles/accounts/show/index.js b/app/assets/javascripts/pages/profiles/accounts/show/index.js index 96c3d725780..6c1e953aa83 100644 --- a/app/assets/javascripts/pages/profiles/accounts/show/index.js +++ b/app/assets/javascripts/pages/profiles/accounts/show/index.js @@ -1,3 +1,6 @@ import initProfileAccount from '~/profile/account'; +import { initClose2faSuccessMessage } from '~/authentication/two_factor_auth'; document.addEventListener('DOMContentLoaded', initProfileAccount); + +initClose2faSuccessMessage(); diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js index 1aeba6669ee..24dbc312dd2 100644 --- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js +++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js @@ -1,9 +1,10 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import { mount2faRegistration } from '~/authentication/mount_2fa'; +import { initRecoveryCodes } from '~/authentication/two_factor_auth'; document.addEventListener('DOMContentLoaded', () => { const twoFactorNode = document.querySelector('.js-two-factor-auth'); - const skippable = parseBoolean(twoFactorNode.dataset.twoFactorSkippable); + const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSkippable) : false; if (skippable) { const button = `<a class="btn btn-sm btn-warning float-right" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`; @@ -13,3 +14,5 @@ document.addEventListener('DOMContentLoaded', () => { mount2faRegistration(); }); + +initRecoveryCodes(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 1879e263ce7..a96b88732b4 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -6,30 +6,6 @@ import GpgBadges from '~/gpg_badges'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import '~/sourcegraph/load'; import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; - -const createGitlabCiYmlVisualization = (containerId = '#js-blob-toggle-graph-preview') => { - const el = document.querySelector(containerId); - const { isCiConfigFile, blobData } = el?.dataset; - - if (el && parseBoolean(isCiConfigFile)) { - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - GitlabCiYamlVisualization: () => - import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'), - }, - render(createElement) { - return createElement('gitlabCiYamlVisualization', { - props: { - blobData, - }, - }); - }, - }); - } -}; document.addEventListener('DOMContentLoaded', () => { new BlobViewer(); // eslint-disable-line no-new @@ -73,25 +49,19 @@ document.addEventListener('DOMContentLoaded', () => { ); } - if (gon.features?.suggestPipeline) { - const successPipelineEl = document.querySelector('.js-success-pipeline-modal'); - - if (successPipelineEl) { - // eslint-disable-next-line no-new - new Vue({ - el: successPipelineEl, - render(createElement) { - return createElement(PipelineTourSuccessModal, { - props: { - ...successPipelineEl.dataset, - }, - }); - }, - }); - } - } + const successPipelineEl = document.querySelector('.js-success-pipeline-modal'); - if (gon?.features?.gitlabCiYmlPreview) { - createGitlabCiYmlVisualization(); + if (successPipelineEl) { + // eslint-disable-next-line no-new + new Vue({ + el: successPipelineEl, + render(createElement) { + return createElement(PipelineTourSuccessModal, { + props: { + ...successPipelineEl.dataset, + }, + }); + }, + }); } }); diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js index 26dea17ca8a..eaf340f2725 100644 --- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js +++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js @@ -1,8 +1,5 @@ import { initCommitBoxInfo } from '~/projects/commit_box/info'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; -document.addEventListener('DOMContentLoaded', () => { - initCommitBoxInfo(); - - initPipelines(); -}); +initCommitBoxInfo(); +initPipelines(); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index e0bd49bf6ef..0750f472341 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -15,35 +15,33 @@ import { __ } from '~/locale'; import loadAwardsHandler from '~/awards_handler'; import { initCommitBoxInfo } from '~/projects/commit_box/info'; -document.addEventListener('DOMContentLoaded', () => { - const hasPerfBar = document.querySelector('.with-performance-bar'); - const performanceHeight = hasPerfBar ? 35 : 0; - initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight); - new ZenMode(); - new ShortcutsNavigation(); +const hasPerfBar = document.querySelector('.with-performance-bar'); +const performanceHeight = hasPerfBar ? 35 : 0; +initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight); +new ZenMode(); +new ShortcutsNavigation(); - initCommitBoxInfo(); +initCommitBoxInfo(); - initNotes(); +initNotes(); - const filesContainer = $('.js-diffs-batch'); +const filesContainer = $('.js-diffs-batch'); - if (filesContainer.length) { - const batchPath = filesContainer.data('diffFilesPath'); +if (filesContainer.length) { + const batchPath = filesContainer.data('diffFilesPath'); - axios - .get(batchPath) - .then(({ data }) => { - filesContainer.html($(data.html)); - syntaxHighlight(filesContainer); - handleLocationHash(); - new Diff(); - }) - .catch(() => { - flash({ message: __('An error occurred while retrieving diff files') }); - }); - } else { - new Diff(); - } - loadAwardsHandler(); -}); + axios + .get(batchPath) + .then(({ data }) => { + filesContainer.html($(data.html)); + syntaxHighlight(filesContainer); + handleLocationHash(); + new Diff(); + }) + .catch(() => { + flash({ message: __('An error occurred while retrieving diff files') }); + }); +} else { + new Diff(); +} +loadAwardsHandler(); diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js index b456baac612..6239e4c99d2 100644 --- a/app/assets/javascripts/pages/projects/commits/show/index.js +++ b/app/assets/javascripts/pages/projects/commits/show/index.js @@ -1,12 +1,9 @@ import CommitsList from '~/commits'; import GpgBadges from '~/gpg_badges'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; - import mountCommits from '~/projects/commits'; -document.addEventListener('DOMContentLoaded', () => { - new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new - new ShortcutsNavigation(); // eslint-disable-line no-new - GpgBadges.fetch(); - mountCommits(document.getElementById('js-author-dropdown')); -}); +new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new +new ShortcutsNavigation(); // eslint-disable-line no-new +GpgBadges.fetch(); +mountCommits(document.getElementById('js-author-dropdown')); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue index 11ece478d36..6c0d20c55e9 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue @@ -85,6 +85,7 @@ export default { v-model="filter" :placeholder="$options.i18n.searchPlaceholder" class="gl-align-self-center gl-ml-auto fork-filtered-search" + data-qa-selector="fork_groups_list_search_field" /> </template> </gl-tabs> diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 4b15e435f60..614f8262e5b 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -17,7 +17,8 @@ import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; import { IssuableType } from '~/issuable_show/constants'; export default function() { - const { issueType, ...issuableData } = parseIssuableData(); + const initialDataEl = document.getElementById('js-issuable-app'); + const { issueType, ...issuableData } = parseIssuableData(initialDataEl); switch (issueType) { case IssuableType.Incident: diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js index 1b57c67f16b..ae04d070e62 100644 --- a/app/assets/javascripts/pages/projects/jobs/index/index.js +++ b/app/assets/javascripts/pages/projects/jobs/index/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; +import Tracking from '~/tracking'; document.addEventListener('DOMContentLoaded', () => { const remainingTimeElements = document.querySelectorAll('.js-remaining-time'); @@ -13,4 +14,13 @@ document.addEventListener('DOMContentLoaded', () => { }, }), ); + + const trackButtonClick = () => { + if (gon.tracking_data) { + const { category, action, ...data } = gon.tracking_data; + Tracking.event(category, action, data); + } + }; + const buttons = document.querySelectorAll('.js-empty-state-button'); + buttons.forEach(button => button.addEventListener('click', trackButtonClick)); }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 868e001b182..0714fc21b17 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -2,7 +2,6 @@ import ZenMode from '~/zen_mode'; import initIssuableSidebar from '~/init_issuable_sidebar'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { handleLocationHash } from '~/lib/utils/common_utils'; -import howToMerge from '~/how_to_merge'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initSourcegraph from '~/sourcegraph'; import loadAwardsHandler from '~/awards_handler'; @@ -15,7 +14,6 @@ export default function() { initPipelines(); new ShortcutsIssuable(true); // eslint-disable-line no-new handleLocationHash(); - howToMerge(); initSourcegraph(); loadAwardsHandler(); initInviteMemberModal(); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index 477a1ab887b..19aeb1d1ecf 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -2,46 +2,28 @@ import initProjectVisibilitySelector from '../../../project_visibility'; import initProjectNew from '../../../projects/project_new'; import { __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import Tracking from '~/tracking'; -import { isExperimentEnabled } from '~/lib/utils/experimentation'; document.addEventListener('DOMContentLoaded', () => { initProjectVisibilitySelector(); initProjectNew.bindEvents(); - const { category, property } = gon.tracking_data ?? { category: 'projects:new' }; - const hasNewCreateProjectUi = isExperimentEnabled('newCreateProjectUi'); + import( + /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation' + ) + .then(m => { + const el = document.querySelector('.js-experiment-new-project-creation'); - if (!hasNewCreateProjectUi) { - // Setting additional tracking for HAML template + if (!el) { + return; + } - Array.from( - document.querySelectorAll('.project-edit-container [data-experiment-track-label]'), - ).forEach(node => - node.addEventListener('click', event => { - const { experimentTrackLabel: label } = event.currentTarget.dataset; - Tracking.event(category, 'click_tab', { property, label }); - }), - ); - } else { - import( - /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation' - ) - .then(m => { - const el = document.querySelector('.js-experiment-new-project-creation'); - - if (!el) { - return; - } - - const config = { - hasErrors: 'hasErrors' in el.dataset, - isCiCdAvailable: 'isCiCdAvailable' in el.dataset, - }; - m.default(el, config); - }) - .catch(() => { - createFlash(__('An error occurred while loading project creation UI')); - }); - } + const config = { + hasErrors: 'hasErrors' in el.dataset, + isCiCdAvailable: 'isCiCdAvailable' in el.dataset, + }; + m.default(el, config); + }) + .catch(() => { + createFlash(__('An error occurred while loading project creation UI')); + }); }); diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index bed9a751d4c..63b1f2bf975 100644 --- a/app/assets/javascripts/pages/projects/pipelines/index/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -1,60 +1,3 @@ -import Vue from 'vue'; -import { GlToast } from '@gitlab/ui'; -import { doesHashExistInUrl } from '~/lib/utils/url_utility'; -import { - parseBoolean, - historyReplaceState, - buildUrlWithCurrentLocation, -} from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; -import PipelinesStore from '../../../../pipelines/stores/pipelines_store'; -import pipelinesComponent from '../../../../pipelines/components/pipelines_list/pipelines.vue'; -import Translate from '../../../../vue_shared/translate'; +import { initPipelinesIndex } from '~/pipelines/pipelines_index'; -Vue.use(Translate); -Vue.use(GlToast); - -document.addEventListener( - 'DOMContentLoaded', - () => - new Vue({ - el: '#pipelines-list-vue', - components: { - pipelinesComponent, - }, - data() { - return { - store: new PipelinesStore(), - }; - }, - created() { - this.dataset = document.querySelector(this.$options.el).dataset; - - if (doesHashExistInUrl('delete_success')) { - this.$toast.show(__('The pipeline has been deleted')); - historyReplaceState(buildUrlWithCurrentLocation()); - } - }, - render(createElement) { - return createElement('pipelines-component', { - props: { - store: this.store, - endpoint: this.dataset.endpoint, - pipelineScheduleUrl: this.dataset.pipelineScheduleUrl, - helpPagePath: this.dataset.helpPagePath, - emptyStateSvgPath: this.dataset.emptyStateSvgPath, - errorStateSvgPath: this.dataset.errorStateSvgPath, - noPipelinesSvgPath: this.dataset.noPipelinesSvgPath, - autoDevopsPath: this.dataset.helpAutoDevopsPath, - newPipelinePath: this.dataset.newPipelinePath, - canCreatePipeline: parseBoolean(this.dataset.canCreatePipeline), - hasGitlabCi: parseBoolean(this.dataset.hasGitlabCi), - ciLintPath: this.dataset.ciLintPath, - resetCachePath: this.dataset.resetCachePath, - projectId: this.dataset.projectId, - params: JSON.parse(this.dataset.params), - }, - }); - }, - }), -); +initPipelinesIndex(); diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 5317093c4cf..8c7aa04a0b6 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -9,47 +9,11 @@ import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from '~/flash'; import projectSelect from '../../project_select'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import initClonePanel from '~/clone_panel'; export default class Project { constructor() { - const $cloneOptions = $('ul.clone-options-dropdown'); - if ($cloneOptions.length) { - const $projectCloneField = $('#project_clone'); - const $cloneBtnLabel = $('.js-git-clone-holder .js-clone-dropdown-label'); - const mobileCloneField = document.querySelector( - '.js-mobile-git-clone .js-clone-dropdown-label', - ); - - const selectedCloneOption = $cloneBtnLabel.text().trim(); - if (selectedCloneOption.length > 0) { - $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active'); - } - - $('a', $cloneOptions).on('click', e => { - e.preventDefault(); - const $this = $(e.currentTarget); - const url = $this.attr('href'); - const cloneType = $this.data('cloneType'); - - $('.is-active', $cloneOptions).removeClass('is-active'); - $(`a[data-clone-type="${cloneType}"]`).each(function() { - const $el = $(this); - const activeText = $el.find('.dropdown-menu-inner-title').text(); - const $container = $el.closest('.project-clone-holder'); - const $label = $container.find('.js-clone-dropdown-label'); - - $el.toggleClass('is-active'); - $label.text(activeText); - }); - - if (mobileCloneField) { - mobileCloneField.dataset.clipboardText = url; - } else { - $projectCloneField.val(url); - } - $('.js-git-empty .js-clone').text(url); - }); - } + initClonePanel(); // Ref switcher if (document.querySelector('.js-project-refs-dropdown')) { diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js index ae2209b0292..22dddb72f98 100644 --- a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js +++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js @@ -1,3 +1,3 @@ import initExpiresAtField from '~/access_tokens'; -document.addEventListener('DOMContentLoaded', initExpiresAtField); +initExpiresAtField(); 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 d18cde4ac87..83bec0092cb 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 @@ -4,6 +4,7 @@ import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; import initVariableList from '~/ci_variable_list'; import initDeployFreeze from '~/deploy_freeze'; import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; +import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -32,4 +33,8 @@ document.addEventListener('DOMContentLoaded', () => { initDeployFreeze(); initSettingsPipelinesTriggers(); + + if (gon?.features?.vueifySharedRunnersToggle) { + initSharedRunnersToggle(); + } }); diff --git a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js index ffc84dc106b..1dc238b56b4 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js @@ -1,3 +1,3 @@ import initForm from '../form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index 0f145dbc170..242c58c4981 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -94,11 +94,7 @@ export default { {{ optionName }} </option> </select> - <gl-icon - name="chevron-down" - aria-hidden="true" - class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" - /> + <gl-icon name="chevron-down" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" /> </div> </div> </template> 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 e50add3b0a4..be197a50775 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 @@ -14,6 +14,7 @@ import { featureAccessLevel, } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone'); @@ -27,7 +28,7 @@ export default { GlLink, GlFormCheckbox, }, - mixins: [settingsMixin], + mixins: [settingsMixin, glFeatureFlagsMixin()], props: { currentSettings: { @@ -137,6 +138,7 @@ export default { snippetsAccessLevel: featureAccessLevel.EVERYONE, pagesAccessLevel: featureAccessLevel.EVERYONE, metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS, + analyticsAccessLevel: featureAccessLevel.EVERYONE, requirementsAccessLevel: featureAccessLevel.EVERYONE, containerRegistryEnabled: true, lfsEnabled: true, @@ -240,6 +242,10 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.metricsDashboardAccessLevel, ); + this.analyticsAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.analyticsAccessLevel, + ); this.requirementsAccessLevel = Math.min( featureAccessLevel.PROJECT_MEMBERS, this.requirementsAccessLevel, @@ -265,6 +271,8 @@ export default { this.snippetsAccessLevel = featureAccessLevel.EVERYONE; if (this.pagesAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.pagesAccessLevel = featureAccessLevel.EVERYONE; + if (this.analyticsAccessLevel > featureAccessLevel.NOT_ENABLED) + this.analyticsAccessLevel = featureAccessLevel.EVERYONE; if (this.metricsDashboardAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.metricsDashboardAccessLevel = featureAccessLevel.EVERYONE; if (this.requirementsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) @@ -341,7 +349,6 @@ export default { </select> <gl-icon name="chevron-down" - aria-hidden="true" data-hidden="true" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" /> @@ -495,6 +502,17 @@ export default { </project-setting-row> </div> <project-setting-row + ref="analytics-settings" + :label="s__('ProjectSettings|Analytics')" + :help-text="s__('ProjectSettings|View project analytics')" + > + <project-feature-setting + v-model="analyticsAccessLevel" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][analytics_access_level]" + /> + </project-setting-row> + <project-setting-row v-if="requirementsAvailable" ref="requirements-settings" :label="s__('ProjectSettings|Requirements')" @@ -573,7 +591,6 @@ export default { </select> <gl-icon name="chevron-down" - aria-hidden="true" data-hidden="true" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" /> @@ -611,5 +628,24 @@ export default { }}</template> </gl-form-checkbox> </project-setting-row> + <project-setting-row + v-if="glFeatures.allowEditingCommitMessages" + ref="allow-editing-commit-messages" + class="gl-mb-4" + > + <input + :value="allowEditingCommitMessages" + type="hidden" + name="project[project_setting_attributes][allow_editing_commit_messages]" + /> + <gl-form-checkbox v-model="allowEditingCommitMessages"> + {{ s__('ProjectSettings|Allow editing commit messages') }} + <template #help>{{ + s__( + 'ProjectSettings|When enabled, commit authors will be able to edit commit messages on unprotected branches.', + ) + }}</template> + </gl-form-checkbox> + </project-setting-row> </div> </template> diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 413b2d01621..cc676b98e49 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,5 +1,5 @@ import initTree from 'ee_else_ce/repository'; -import initBlob from '~/blob_edit/blob_bundle'; +import { initUploadForm } from '~/blob_edit/blob_bundle'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import NotificationsForm from '~/notifications_form'; import UserCallout from '~/user_callout'; @@ -26,7 +26,7 @@ new UserCallout({ // Project show page loads different overview content based on user preferences const treeSlider = document.getElementById('js-tree-list'); if (treeSlider) { - initBlob(); + initUploadForm(); initTree(); } diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js index 88f2f30aad9..b6171e08e01 100644 --- a/app/assets/javascripts/pages/search/show/index.js +++ b/app/assets/javascripts/pages/search/show/index.js @@ -2,6 +2,6 @@ import Search from './search'; import { initSearchApp } from '~/search'; document.addEventListener('DOMContentLoaded', () => { - initSearchApp(); - return new Search(); // Deprecated Dropdown (Projects) + initSearchApp(); // Vue Bootstrap + return new Search(); // Legacy Search Methods }); diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js index 03675f1ce66..b411b637f36 100644 --- a/app/assets/javascripts/pages/search/show/search.js +++ b/app/assets/javascripts/pages/search/show/search.js @@ -1,57 +1,18 @@ import $ from 'jquery'; import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { deprecatedCreateFlash as Flash } from '~/flash'; -import Api from '~/api'; -import { __ } from '~/locale'; import Project from '~/pages/projects/project'; -import { visitUrl, queryToObject } from '~/lib/utils/url_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; import refreshCounts from './refresh_counts'; export default class Search { constructor() { - setHighlightClass(); // Code Highlighting - const $projectDropdown = $('.js-search-project-dropdown'); - this.searchInput = '.js-search-input'; this.searchClear = '.js-search-clear'; - const query = queryToObject(window.location.search); - this.groupId = query?.group_id; - this.eventListeners(); - refreshCounts(); - - initDeprecatedJQueryDropdown($projectDropdown, { - selectable: true, - filterable: true, - filterRemote: true, - fieldName: 'project_id', - search: { - fields: ['name'], - }, - data: (term, callback) => { - this.getProjectsData(term) - .then(data => { - data.unshift({ - name_with_namespace: __('Any'), - }); - data.splice(1, 0, { type: 'divider' }); - - return data; - }) - .then(data => callback(data)) - .catch(() => new Flash(__('Error fetching projects'))); - }, - id(obj) { - return obj.id; - }, - text(obj) { - return obj.name_with_namespace; - }, - clicked: () => Search.submitSearch(), - }); - - Project.initRefSwitcher(); + setHighlightClass(); // Code Highlighting + this.eventListeners(); // Search Form Actions + refreshCounts(); // Other Scope Tab Counts + Project.initRefSwitcher(); // Code Search Branch Picker } eventListeners() { @@ -97,20 +58,4 @@ export default class Search { visitUrl($target.href); ev.stopPropagation(); } - - getProjectsData(term) { - return new Promise(resolve => { - if (this.groupId) { - Api.groupProjects(this.groupId, term, {}, resolve); - } else { - Api.projects( - term, - { - order_by: 'id', - }, - resolve, - ); - } - }); - } } diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js index 816eb9b3a66..069f3c265f3 100644 --- a/app/assets/javascripts/performance/constants.js +++ b/app/assets/javascripts/performance/constants.js @@ -19,16 +19,27 @@ export const SNIPPET_MEASURE_BLOBS_CONTENT = 'snippet-blobs-content'; // Marks export const WEBIDE_MARK_APP_START = 'webide-app-start'; -export const WEBIDE_MARK_TREE_START = 'webide-tree-start'; -export const WEBIDE_MARK_TREE_FINISH = 'webide-tree-finished'; -export const WEBIDE_MARK_FILE_START = 'webide-file-start'; export const WEBIDE_MARK_FILE_CLICKED = 'webide-file-clicked'; export const WEBIDE_MARK_FILE_FINISH = 'webide-file-finished'; +export const WEBIDE_MARK_REPO_EDITOR_START = 'webide-init-editor-start'; +export const WEBIDE_MARK_REPO_EDITOR_FINISH = 'webide-init-editor-finish'; +export const WEBIDE_MARK_FETCH_BRANCH_DATA_START = 'webide-getBranchData-start'; +export const WEBIDE_MARK_FETCH_BRANCH_DATA_FINISH = 'webide-getBranchData-finish'; +export const WEBIDE_MARK_FETCH_FILE_DATA_START = 'webide-getFileData-start'; +export const WEBIDE_MARK_FETCH_FILE_DATA_FINISH = 'webide-getFileData-finish'; +export const WEBIDE_MARK_FETCH_FILES_START = 'webide-getFiles-start'; +export const WEBIDE_MARK_FETCH_FILES_FINISH = 'webide-getFiles-finish'; +export const WEBIDE_MARK_FETCH_PROJECT_DATA_START = 'webide-getProjectData-start'; +export const WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH = 'webide-getProjectData-finish'; // Measures -export const WEBIDE_MEASURE_TREE_FROM_REQUEST = 'webide-tree-loading-from-request'; -export const WEBIDE_MEASURE_FILE_FROM_REQUEST = 'webide-file-loading-from-request'; export const WEBIDE_MEASURE_FILE_AFTER_INTERACTION = 'webide-file-loading-after-interaction'; +export const WEBIDE_MEASURE_FETCH_PROJECT_DATA = 'WebIDE: Project data'; +export const WEBIDE_MEASURE_FETCH_BRANCH_DATA = 'WebIDE: Branch data'; +export const WEBIDE_MEASURE_FETCH_FILE_DATA = 'WebIDE: File data'; +export const WEBIDE_MEASURE_BEFORE_VUE = 'WebIDE: Before Vue app'; +export const WEBIDE_MEASURE_REPO_EDITOR = 'WebIDE: Repo Editor'; +export const WEBIDE_MEASURE_FETCH_FILES = 'WebIDE: Fetch Files'; // // MR Diffs namespace diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index f29b5f42d8f..e0b7f2190ca 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -1,5 +1,6 @@ /* eslint-disable @gitlab/require-i18n-strings */ import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; import axios from '~/lib/utils/axios_utils'; import PerformanceBarService from './services/performance_bar_service'; @@ -7,6 +8,8 @@ import PerformanceBarStore from './stores/performance_bar_store'; import initPerformanceBarLog from './performance_bar_log'; +Vue.use(Translate); + const initPerformanceBar = el => { const performanceBarData = el.dataset; @@ -123,11 +126,23 @@ const initPerformanceBar = el => { }); }; -document.addEventListener('DOMContentLoaded', () => { +let loadedPeekBar = false; +function loadBar() { const jsPeek = document.querySelector('#js-peek'); - if (jsPeek) { + if (!loadedPeekBar && jsPeek) { + loadedPeekBar = true; initPerformanceBar(jsPeek); } +} + +// If js-peek is not loaded when this script is executed, this call will do nothing +// If this is the case, then it will loadBar on DOMContentLoaded. We would prefer it +// to be initialized before the DOMContetLoaded event in order to pick up all the +// requests sent from the page. +loadBar(); + +document.addEventListener('DOMContentLoaded', () => { + loadBar(); }); initPerformanceBarLog(); diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 8c5f45e9d34..d4857a19ff7 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -7,6 +7,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-buy-pipeline-minutes-notification-callout', '.js-token-expiry-callout', '.js-registration-enabled-callout', + '.js-new-user-signups-cap-reached', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue new file mode 100644 index 00000000000..9279273283e --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -0,0 +1,139 @@ +<script> +import { + GlButton, + GlForm, + GlFormCheckbox, + GlFormInput, + GlFormGroup, + GlFormTextarea, + GlSprintf, +} from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlButton, + GlForm, + GlFormCheckbox, + GlFormInput, + GlFormGroup, + GlFormTextarea, + GlSprintf, + }, + props: { + defaultBranch: { + type: String, + required: false, + default: '', + }, + defaultMessage: { + type: String, + required: false, + default: '', + }, + isSaving: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + message: this.defaultMessage, + branch: this.defaultBranch, + openMergeRequest: false, + }; + }, + computed: { + isDefaultBranch() { + return this.branch === this.defaultBranch; + }, + submitDisabled() { + return !(this.message && this.branch); + }, + }, + methods: { + onSubmit() { + this.$emit('submit', { + message: this.message, + branch: this.branch, + openMergeRequest: this.openMergeRequest, + }); + }, + onReset() { + this.$emit('cancel'); + }, + }, + i18n: { + commitMessage: __('Commit message'), + targetBranch: __('Target Branch'), + startMergeRequest: __('Start a %{new_merge_request} with these changes'), + newMergeRequest: __('new merge request'), + commitChanges: __('Commit changes'), + cancel: __('Cancel'), + }, +}; +</script> + +<template> + <div> + <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> + <gl-form-group + id="commit-group" + :label="$options.i18n.commitMessage" + label-cols-sm="2" + label-for="commit-message" + > + <gl-form-textarea + id="commit-message" + v-model="message" + class="gl-font-monospace!" + required + :placeholder="defaultMessage" + /> + </gl-form-group> + <gl-form-group + id="target-branch-group" + :label="$options.i18n.targetBranch" + label-cols-sm="2" + label-for="target-branch-field" + > + <gl-form-input + id="target-branch-field" + v-model="branch" + class="gl-font-monospace!" + required + /> + <gl-form-checkbox + v-if="!isDefaultBranch" + v-model="openMergeRequest" + data-testid="new-mr-checkbox" + class="gl-mt-3" + > + <gl-sprintf :message="$options.i18n.startMergeRequest"> + <template #new_merge_request> + <strong>{{ $options.i18n.newMergeRequest }}</strong> + </template> + </gl-sprintf> + </gl-form-checkbox> + </gl-form-group> + <div + class="gl-display-flex gl-justify-content-space-between gl-p-5 gl-bg-gray-10 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1" + > + <gl-button + type="submit" + class="js-no-auto-disable" + category="primary" + variant="success" + :disabled="submitDisabled" + :loading="isSaving" + > + {{ $options.i18n.commitChanges }} + </gl-button> + <gl-button type="reset" category="secondary" class="gl-mr-3"> + {{ $options.i18n.cancel }} + </gl-button> + </div> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue index 8b37c94de19..0d1c214c5b1 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue @@ -90,7 +90,7 @@ export default { </script> <template> - <div class="col-sm-12 gl-mt-5"> + <div> <gl-alert class="gl-mb-5" :variant="status.variant" diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue index 23808bcb292..23808bcb292 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue index 4929c3206df..4929c3206df 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue index ac0332cb0bd..ac0332cb0bd 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue diff --git a/app/assets/javascripts/pipeline_editor/components/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/text_editor.vue index a925077c906..22f2a32c9ac 100644 --- a/app/assets/javascripts/pipeline_editor/components/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/text_editor.vue @@ -5,22 +5,10 @@ export default { components: { EditorLite, }, - props: { - value: { - type: String, - required: false, - default: '', - }, - }, }; </script> <template> <div class="gl-border-solid gl-border-gray-100 gl-border-1"> - <editor-lite - v-model="value" - file-name="*.yml" - :editor-options="{ readOnly: true }" - @editor-ready="$emit('editor-ready')" - /> + <editor-lite file-name="*.yml" v-bind="$attrs" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js new file mode 100644 index 00000000000..70bab8092c0 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -0,0 +1,2 @@ +export const CI_CONFIG_STATUS_VALID = 'VALID'; +export const CI_CONFIG_STATUS_INVALID = 'INVALID'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql new file mode 100644 index 00000000000..11bca42fd69 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql @@ -0,0 +1,26 @@ +mutation commitCIFileMutation( + $projectPath: ID! + $branch: String! + $startBranch: String + $message: String! + $filePath: String! + $lastCommitId: String! + $content: String +) { + commitCreate( + input: { + projectPath: $projectPath + branch: $branch + startBranch: $startBranch + message: $message + actions: [ + { action: UPDATE, filePath: $filePath, lastCommitId: $lastCommitId, content: $content } + ] + } + ) { + commit { + id + } + errors + } +} diff --git a/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql index 496036f690f..496036f690f 100644 --- a/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql new file mode 100644 index 00000000000..d65d9892260 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql @@ -0,0 +1,11 @@ +#import "~/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql" + +query getCiConfigData($content: String!) { + ciConfig(content: $content) { + errors + status + stages { + ...PipelineStagesConnection + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index 7b8c70ac93e..c1cdb5eb2ee 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -1,4 +1,5 @@ import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; export const resolvers = { Query: { @@ -11,6 +12,32 @@ export const resolvers = { }; }, }, -}; + Mutation: { + lintCI: (_, { endpoint, content, dry_run }) => { + return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({ + valid: data.valid, + errors: data.errors, + warnings: data.warnings, + jobs: data.jobs.map(job => { + const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null; -export default resolvers; + return { + name: job.name, + stage: job.stage, + beforeScript: job.before_script, + script: job.script, + afterScript: job.after_script, + tagList: job.tag_list, + environment: job.environment, + when: job.when, + allowFailure: job.allow_failure, + only, + except: job.except, + __typename: 'CiLintJob', + }; + }), + __typename: 'CiLintContent', + })); + }, + }, +}; diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index ccd7b74064f..8268a907a29 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -10,7 +10,11 @@ import PipelineEditorApp from './pipeline_editor_app.vue'; export const initPipelineEditor = (selector = '#js-pipeline-editor') => { const el = document.querySelector(selector); - const { projectPath, defaultBranch, ciConfigPath } = el?.dataset; + if (!el) { + return null; + } + + const { ciConfigPath, commitId, defaultBranch, newMergeRequestPath, projectPath } = el?.dataset; Vue.use(VueApollo); @@ -24,9 +28,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { render(h) { return h(PipelineEditorApp, { props: { - projectPath, - defaultBranch, ciConfigPath, + commitId, + defaultBranch, + newMergeRequestPath, + projectPath, }, }); }, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 50b946af456..96dc782964b 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,21 +1,38 @@ <script> -import { GlLoadingIcon, GlAlert, GlTabs, GlTab } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; +import { mergeUrlParams, redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import TextEditor from './components/text_editor.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import CommitForm from './components/commit/commit_form.vue'; +import TextEditor from './components/text_editor.vue'; +import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql'; import getBlobContent from './graphql/queries/blob_content.graphql'; +import getCiConfigData from './graphql/queries/ci_config.graphql'; +import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; + +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + +const COMMIT_FAILURE = 'COMMIT_FAILURE'; +const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; +const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE'; +const LOAD_FAILURE_NO_REF = 'LOAD_FAILURE_NO_REF'; +const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; export default { components: { - GlLoadingIcon, + CommitForm, GlAlert, - GlTabs, + GlLoadingIcon, GlTab, - TextEditor, + GlTabs, PipelineGraph, + TextEditor, }, + mixins: [glFeatureFlagsMixin()], props: { projectPath: { type: String, @@ -26,16 +43,31 @@ export default { required: false, default: null, }, + commitId: { + type: String, + required: false, + default: null, + }, ciConfigPath: { type: String, required: true, }, + newMergeRequestPath: { + type: String, + required: true, + }, }, data() { return { - error: null, + ciConfigData: {}, content: '', + contentModel: '', + currentTabIndex: 0, editorIsReady: false, + failureType: null, + failureReasons: [], + isSaving: false, + showFailureAlert: false, }; }, apollo: { @@ -51,58 +83,212 @@ export default { update(data) { return data?.blobContent?.rawData; }, + result({ data }) { + this.contentModel = data?.blobContent?.rawData ?? ''; + }, error(error) { - this.error = error; + this.handleBlobContentError(error); + }, + }, + ciConfigData: { + query: getCiConfigData, + // If content is not loaded, we can't lint the data + skip: ({ contentModel }) => { + return !contentModel; + }, + variables() { + return { + content: this.contentModel, + }; + }, + update(data) { + const { ciConfigData } = data || {}; + const stageNodes = ciConfigData?.stages?.nodes || []; + const stages = unwrapStagesWithNeeds(stageNodes); + + return { ...ciConfigData, stages }; + }, + error() { + this.reportFailure(LOAD_FAILURE_UNKNOWN); }, }, }, computed: { - loading() { + isBlobContentLoading() { return this.$apollo.queries.content.loading; }, - errorMessage() { - const { message: generalReason, networkError } = this.error ?? {}; - - const { data } = networkError?.response ?? {}; - // 404 for missing file uses `message` - // 400 for a missing ref uses `error` - const networkReason = data?.message ?? data?.error; - - const reason = networkReason ?? generalReason ?? this.$options.i18n.unknownError; - return sprintf(this.$options.i18n.errorMessageWithReason, { reason }); + isVisualizationTabLoading() { + return this.$apollo.queries.ciConfigData.loading; + }, + isVisualizeTabActive() { + return this.currentTabIndex === 1; }, - pipelineData() { - // Note data will loaded as part of https://gitlab.com/gitlab-org/gitlab/-/issues/263141 - return {}; + defaultCommitMessage() { + return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath }); + }, + failure() { + switch (this.failureType) { + case LOAD_FAILURE_NO_REF: + return { + text: this.$options.errorTexts[LOAD_FAILURE_NO_REF], + variant: 'danger', + }; + case LOAD_FAILURE_NO_FILE: + return { + text: this.$options.errorTexts[LOAD_FAILURE_NO_FILE], + variant: 'danger', + }; + case LOAD_FAILURE_UNKNOWN: + return { + text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN], + variant: 'danger', + }; + case COMMIT_FAILURE: + return { + text: this.$options.errorTexts[COMMIT_FAILURE], + variant: 'danger', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT_FAILURE], + variant: 'danger', + }; + } }, }, i18n: { - unknownError: __('Unknown Error'), - errorMessageWithReason: s__('Pipelines|CI file could not be loaded: %{reason}'), + defaultCommitMessage: __('Update %{sourcePath} file'), tabEdit: s__('Pipelines|Write pipeline configuration'), tabGraph: s__('Pipelines|Visualize'), }, + errorTexts: { + [LOAD_FAILURE_NO_REF]: s__( + 'Pipelines|Repository does not have a default branch, please set one.', + ), + [LOAD_FAILURE_NO_FILE]: s__('Pipelines|No CI file found in this repository, please add one.'), + [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), + [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), + }, + methods: { + handleBlobContentError(error = {}) { + const { networkError } = error; + + const { response } = networkError; + if (response?.status === 404) { + // 404 for missing CI file + this.reportFailure(LOAD_FAILURE_NO_FILE); + } else if (response?.status === 400) { + // 400 for a missing ref when no default branch is set + this.reportFailure(LOAD_FAILURE_NO_REF); + } else { + this.reportFailure(LOAD_FAILURE_UNKNOWN); + } + }, + dismissFailure() { + this.showFailureAlert = false; + }, + reportFailure(type, reasons = []) { + this.showFailureAlert = true; + this.failureType = type; + this.failureReasons = reasons; + }, + redirectToNewMergeRequest(sourceBranch) { + const url = mergeUrlParams( + { + [MR_SOURCE_BRANCH]: sourceBranch, + [MR_TARGET_BRANCH]: this.defaultBranch, + }, + this.newMergeRequestPath, + ); + redirectTo(url); + }, + async onCommitSubmit(event) { + this.isSaving = true; + const { message, branch, openMergeRequest } = event; + + try { + const { + data: { + commitCreate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: commitCiFileMutation, + variables: { + projectPath: this.projectPath, + branch, + startBranch: this.defaultBranch, + message, + filePath: this.ciConfigPath, + content: this.contentModel, + lastCommitId: this.commitId, + }, + }); + + if (errors?.length) { + this.reportFailure(COMMIT_FAILURE, errors); + return; + } + + if (openMergeRequest) { + this.redirectToNewMergeRequest(branch); + } else { + // Refresh the page to ensure commit is updated + refreshCurrentPage(); + } + } catch (error) { + this.reportFailure(COMMIT_FAILURE, [error?.message]); + } finally { + this.isSaving = false; + } + }, + onCommitCancel() { + this.contentModel = this.content; + }, + }, }; </script> <template> <div class="gl-mt-4"> - <gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert> + <gl-alert + v-if="showFailureAlert" + :variant="failure.variant" + :dismissible="true" + @dismiss="dismissFailure" + > + {{ failure.text }} + <ul v-if="failureReasons.length" class="gl-mb-0"> + <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> + </ul> + </gl-alert> <div class="gl-mt-4"> - <gl-loading-icon v-if="loading" size="lg" /> - <div v-else class="file-editor"> - <gl-tabs> + <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" /> + <div v-else class="file-editor gl-mb-3"> + <gl-tabs v-model="currentTabIndex"> <!-- editor should be mounted when its tab is visible, so the container has a size --> <gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady"> <!-- editor should be mounted only once, when the tab is displayed --> - <text-editor v-model="content" @editor-ready="editorIsReady = true" /> + <text-editor v-model="contentModel" @editor-ready="editorIsReady = true" /> </gl-tab> - <gl-tab :title="$options.i18n.tabGraph"> - <pipeline-graph :pipeline-data="pipelineData" /> + <gl-tab + v-if="glFeatures.ciConfigVisualizationTab" + :title="$options.i18n.tabGraph" + :lazy="!isVisualizeTabActive" + data-testid="visualization-tab" + > + <gl-loading-icon v-if="isVisualizationTabLoading" size="lg" class="gl-m-3" /> + <pipeline-graph v-else :pipeline-data="ciConfigData" /> </gl-tab> </gl-tabs> </div> + <commit-form + :default-branch="defaultBranch" + :default-message="defaultCommitMessage" + :is-saving="isSaving" + @cancel="onCommitCancel" + @submit="onCommitSubmit" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index 6552665100a..f2d68054e80 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -12,14 +12,18 @@ import { GlLink, GlDropdown, GlDropdownItem, + GlDropdownSectionHeader, GlSearchBoxByType, GlSprintf, GlLoadingIcon, } from '@gitlab/ui'; +import * as Sentry from '~/sentry/wrapper'; import { s__, __, n__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; -import { VARIABLE_TYPE, FILE_TYPE } from '../constants'; +import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants'; +import { backOff } from '~/lib/utils/common_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; export default { typeOptions: [ @@ -44,6 +48,7 @@ export default { GlLink, GlDropdown, GlDropdownItem, + GlDropdownSectionHeader, GlSearchBoxByType, GlSprintf, GlLoadingIcon, @@ -57,11 +62,19 @@ export default { type: String, required: true, }, + defaultBranch: { + type: String, + required: true, + }, projectId: { type: String, required: true, }, - refs: { + branches: { + type: Array, + required: true, + }, + tags: { type: Array, required: true, }, @@ -92,7 +105,9 @@ export default { data() { return { searchTerm: '', - refValue: this.refParam, + refValue: { + shortName: this.refParam, + }, form: {}, error: null, warnings: [], @@ -102,9 +117,21 @@ export default { }; }, computed: { - filteredRefs() { - const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.refs.filter(ref => ref.toLowerCase().includes(lowerCasedSearchTerm)); + lowerCasedSearchTerm() { + return this.searchTerm.toLowerCase(); + }, + filteredBranches() { + return this.branches.filter(branch => + branch.shortName.toLowerCase().includes(this.lowerCasedSearchTerm), + ); + }, + filteredTags() { + return this.tags.filter(tag => + tag.shortName.toLowerCase().includes(this.lowerCasedSearchTerm), + ); + }, + hasTags() { + return this.tags.length > 0; }, overMaxWarningsLimit() { return this.totalWarnings > this.maxWarnings; @@ -118,14 +145,27 @@ export default { shouldShowWarning() { return this.warnings.length > 0 && !this.isWarningDismissed; }, + refShortName() { + return this.refValue.shortName; + }, + refFullName() { + return this.refValue.fullName; + }, variables() { - return this.form[this.refValue]?.variables ?? []; + return this.form[this.refFullName]?.variables ?? []; }, descriptions() { - return this.form[this.refValue]?.descriptions ?? {}; + return this.form[this.refFullName]?.descriptions ?? {}; }, }, created() { + // this is needed until we add support for ref type in url query strings + // ensure default branch is called with full ref on load + // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 + if (this.refValue.shortName === this.defaultBranch) { + this.refValue.fullName = `refs/heads/${this.refValue.shortName}`; + } + this.setRefSelected(this.refValue); }, methods: { @@ -168,19 +208,19 @@ export default { setRefSelected(refValue) { this.refValue = refValue; - if (!this.form[refValue]) { - this.fetchConfigVariables(refValue) + if (!this.form[this.refFullName]) { + this.fetchConfigVariables(this.refFullName || this.refShortName) .then(({ descriptions, params }) => { - Vue.set(this.form, refValue, { + Vue.set(this.form, this.refFullName, { variables: [], descriptions, }); // Add default variables from yml - this.setVariableParams(refValue, VARIABLE_TYPE, params); + this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); }) .catch(() => { - Vue.set(this.form, refValue, { + Vue.set(this.form, this.refFullName, { variables: [], descriptions: {}, }); @@ -188,20 +228,19 @@ export default { .finally(() => { // Add/update variables, e.g. from query string if (this.variableParams) { - this.setVariableParams(refValue, VARIABLE_TYPE, this.variableParams); + this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); } if (this.fileParams) { - this.setVariableParams(refValue, FILE_TYPE, this.fileParams); + this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); } // Adds empty var at the end of the form - this.addEmptyVariable(refValue); + this.addEmptyVariable(this.refFullName); }); } }, - isSelected(ref) { - return ref === this.refValue; + return ref.fullName === this.refValue.fullName; }, removeVariable(index) { this.variables.splice(index, 1); @@ -209,34 +248,52 @@ export default { canRemove(index) { return index < this.variables.length - 1; }, - fetchConfigVariables(refValue) { - if (gon?.features?.newPipelineFormPrefilledVars) { - this.isLoading = true; + if (!gon?.features?.newPipelineFormPrefilledVars) { + return Promise.resolve({ params: {}, descriptions: {} }); + } + + this.isLoading = true; - return axios + return backOff((next, stop) => { + axios .get(this.configVariablesPath, { params: { sha: refValue, }, }) - .then(({ data }) => { - const params = {}; - const descriptions = {}; + .then(({ data, status }) => { + if (status === httpStatusCodes.NO_CONTENT) { + next(); + } else { + this.isLoading = false; + stop(data); + } + }) + .catch(error => { + stop(error); + }); + }, CONFIG_VARIABLES_TIMEOUT) + .then(data => { + const params = {}; + const descriptions = {}; - Object.entries(data).forEach(([key, { value, description }]) => { - if (description !== null) { - params[key] = value; - descriptions[key] = description; - } - }); + Object.entries(data).forEach(([key, { value, description }]) => { + if (description !== null) { + params[key] = value; + descriptions[key] = description; + } + }); - this.isLoading = false; + return { params, descriptions }; + }) + .catch(error => { + this.isLoading = false; - return { params, descriptions }; - }); - } - return Promise.resolve({ params: {}, descriptions: {} }); + Sentry.captureException(error); + + return { params: {}, descriptions: {} }; + }); }, createPipeline() { const filteredVariables = this.variables @@ -249,7 +306,9 @@ export default { return axios .post(this.pipelinesPath, { - ref: this.refValue, + // send shortName as fall back for query params + // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 + ref: this.refValue.fullName || this.refShortName, variables_attributes: filteredVariables, }) .then(({ data }) => { @@ -307,20 +366,29 @@ export default { </details> </gl-alert> <gl-form-group :label="s__('Pipeline|Run for')"> - <gl-dropdown :text="refValue" block> - <gl-search-box-by-type - v-model.trim="searchTerm" - :placeholder="__('Search branches and tags')" - /> + <gl-dropdown :text="refShortName" block> + <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search refs')" /> + <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="branch in filteredBranches" + :key="branch.fullName" + class="gl-font-monospace" + is-check-item + :is-checked="isSelected(branch)" + @click="setRefSelected(branch)" + > + {{ branch.shortName }} + </gl-dropdown-item> + <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header> <gl-dropdown-item - v-for="(ref, index) in filteredRefs" - :key="index" + v-for="tag in filteredTags" + :key="tag.fullName" class="gl-font-monospace" is-check-item - :is-checked="isSelected(ref)" - @click="setRefSelected(ref)" + :is-checked="isSelected(tag)" + @click="setRefSelected(tag)" > - {{ ref }} + {{ tag.shortName }} </gl-dropdown-item> </gl-dropdown> @@ -353,7 +421,7 @@ export default { :placeholder="s__('CiVariables|Input variable key')" :class="$options.formElementClasses" data-testid="pipeline-form-ci-variable-key" - @change="addEmptyVariable(refValue)" + @change="addEmptyVariable(refFullName)" /> <gl-form-input v-model="variable.value" diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js index b4ab1143f60..004bbe7daf4 100644 --- a/app/assets/javascripts/pipeline_new/constants.js +++ b/app/assets/javascripts/pipeline_new/constants.js @@ -1,2 +1,5 @@ export const VARIABLE_TYPE = 'env_var'; export const FILE_TYPE = 'file'; +export const CONFIG_VARIABLES_TIMEOUT = 5000; +export const BRANCH_REF_TYPE = 'branch'; +export const TAG_REF_TYPE = 'tag'; diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index ff4f677654e..0b85184ec90 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import PipelineNewForm from './components/pipeline_new_form.vue'; +import formatRefs from './utils/format_refs'; export default () => { const el = document.getElementById('js-new-pipeline'); @@ -7,17 +8,20 @@ export default () => { projectId, pipelinesPath, configVariablesPath, + defaultBranch, refParam, varParam, fileParam, - refNames, + branchRefs, + tagRefs, settingsLink, maxWarnings, } = el?.dataset; const variableParams = JSON.parse(varParam); const fileParams = JSON.parse(fileParam); - const refs = JSON.parse(refNames); + const branches = formatRefs(JSON.parse(branchRefs), 'branch'); + const tags = formatRefs(JSON.parse(tagRefs), 'tag'); return new Vue({ el, @@ -27,10 +31,12 @@ export default () => { projectId, pipelinesPath, configVariablesPath, + defaultBranch, refParam, variableParams, fileParams, - refs, + branches, + tags, settingsLink, maxWarnings: Number(maxWarnings), }, diff --git a/app/assets/javascripts/pipeline_new/utils/format_refs.js b/app/assets/javascripts/pipeline_new/utils/format_refs.js new file mode 100644 index 00000000000..e217cd25413 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/utils/format_refs.js @@ -0,0 +1,18 @@ +import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '../constants'; + +export default (refs, type) => { + let fullName; + + return refs.map(ref => { + if (type === BRANCH_REF_TYPE) { + fullName = `refs/heads/${ref}`; + } else if (type === TAG_REF_TYPE) { + fullName = `refs/tags/${ref}`; + } + + return { + shortName: ref, + fullName, + }; + }); +}; diff --git a/app/assets/javascripts/pipelines/components/graph/accessors.js b/app/assets/javascripts/pipelines/components/graph/accessors.js new file mode 100644 index 00000000000..6ece855bcd8 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/accessors.js @@ -0,0 +1,25 @@ +import { get } from 'lodash'; +import { REST, GRAPHQL } from './constants'; + +const accessors = { + [REST]: { + detailsPath: 'details_path', + groupId: 'id', + hasDetails: 'has_details', + pipelineStatus: ['details', 'status'], + sourceJob: ['source_job', 'name'], + }, + [GRAPHQL]: { + detailsPath: 'detailsPath', + groupId: 'name', + hasDetails: 'hasDetails', + pipelineStatus: 'status', + sourceJob: ['sourceJob', 'name'], + }, +}; + +const accessValue = (dataMethod, prop, item) => { + return get(item, accessors[dataMethod][prop]); +}; + +export { accessors, accessValue }; diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index a580ee11627..4e9b21a5c55 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -87,10 +87,10 @@ export default { :title="tooltipText" :class="cssClass" :disabled="isDisabled" - class="js-ci-action ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" + 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-icon v-else :name="actionIcon" class="gl-mr-0!" /> + <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" /> </gl-button> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js index ba1922b6dae..6f0deccfef6 100644 --- a/app/assets/javascripts/pipelines/components/graph/constants.js +++ b/app/assets/javascripts/pipelines/components/graph/constants.js @@ -1,3 +1,6 @@ export const DOWNSTREAM = 'downstream'; export const MAIN = 'main'; export const UPSTREAM = 'upstream'; + +export const REST = 'rest'; +export const GRAPHQL = 'graphql'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 16ce279a591..67b2ed3b596 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,35 +1,23 @@ <script> -import { escape, capitalize } from 'lodash'; -import { GlLoadingIcon } from '@gitlab/ui'; -import StageColumnComponent from './stage_column_component.vue'; -import GraphWidthMixin from '../../mixins/graph_width_mixin'; +import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; -import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; -import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; +import StageColumnComponent from './stage_column_component.vue'; +import { DOWNSTREAM, MAIN, UPSTREAM } from './constants'; export default { name: 'PipelineGraph', components: { - StageColumnComponent, - GlLoadingIcon, + LinkedGraphWrapper, LinkedPipelinesColumn, + StageColumnComponent, }, - mixins: [GraphWidthMixin, GraphBundleMixin], props: { - isLoading: { - type: Boolean, - required: true, - }, - pipeline: { - type: Object, - required: true, - }, isLinkedPipeline: { type: Boolean, required: false, default: false, }, - mediator: { + pipeline: { type: Object, required: true, }, @@ -39,12 +27,13 @@ export default { default: MAIN, }, }, - upstream: UPSTREAM, - downstream: DOWNSTREAM, + pipelineTypeConstants: { + DOWNSTREAM, + UPSTREAM, + }, data() { return { - downstreamMarginTop: null, - jobName: null, + hoveredJobName: '', pipelineExpanded: { jobName: '', expanded: false, @@ -52,219 +41,86 @@ export default { }; }, computed: { + downstreamPipelines() { + return this.hasDownstreamPipelines ? this.pipeline.downstream : []; + }, graph() { - return this.pipeline.details?.stages; + return this.pipeline.stages; }, - hasUpstream() { - return ( - this.type !== this.$options.downstream && - this.upstreamPipelines && - this.pipeline.triggered_by !== null - ); + hasDownstreamPipelines() { + return Boolean(this.pipeline?.downstream?.length > 0); }, - upstreamPipelines() { - return this.pipeline.triggered_by; + hasUpstreamPipelines() { + return Boolean(this.pipeline?.upstream?.length > 0); }, - hasDownstream() { + // The two show checks prevent upstream / downstream from showing redundant linked columns + showDownstreamPipelines() { return ( - this.type !== this.$options.upstream && - this.downstreamPipelines && - this.pipeline.triggered.length > 0 + this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM ); }, - downstreamPipelines() { - return this.pipeline.triggered; - }, - expandedUpstream() { + showUpstreamPipelines() { return ( - this.pipeline.triggered_by && - Array.isArray(this.pipeline.triggered_by) && - this.pipeline.triggered_by.find(el => el.isExpanded) + this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM ); }, - expandedDownstream() { - return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded); - }, - pipelineTypeUpstream() { - return this.type !== this.$options.downstream && this.expandedUpstream; - }, - pipelineTypeDownstream() { - return this.type !== this.$options.upstream && this.expandedDownstream; - }, - pipelineProjectId() { - return this.pipeline.project.id; + upstreamPipelines() { + return this.hasUpstreamPipelines ? this.pipeline.upstream : []; }, }, methods: { - capitalizeStageName(name) { - const escapedName = escape(name); - return capitalize(escapedName); - }, - isFirstColumn(index) { - return index === 0; - }, - stageConnectorClass(index, stage) { - let className; - - // If it's the first stage column and only has one job - if (this.isFirstColumn(index) && stage.groups.length === 1) { - className = 'no-margin'; - } else if (index > 0) { - // If it is not the first column - className = 'left-margin'; - } - - return className; - }, - refreshPipelineGraph() { - this.$emit('refreshPipelineGraph'); - }, - /** - * CSS class is applied: - * - if pipeline graph contains only one stage column component - * - * @param {number} index - * @returns {boolean} - */ - shouldAddRightMargin(index) { - return !(index === this.graph.length - 1); - }, - handleClickedDownstream(pipeline, clickedIndex, downstreamNode) { - /** - * Calculates the margin top of the clicked downstream pipeline by - * subtracting the clicked downstream pipelines offsetTop by it's parent's - * offsetTop and then subtracting 15 - */ - this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15); - - /** - * If the expanded trigger is defined and the id is different than the - * pipeline we clicked, then it means we clicked on a sibling downstream link - * and we want to reset the pipeline store. Triggering the reset without - * this condition would mean not allowing downstreams of downstreams to expand - */ - if (this.expandedDownstream?.id !== pipeline.id) { - this.$emit('onResetDownstream', this.pipeline, pipeline); - } - - this.$emit('onClickDownstreamPipeline', pipeline); - }, - calculateMarginTop(downstreamNode, pixelDiff) { - return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`; - }, - hasOnlyOneJob(stage) { - return stage.groups.length === 1; - }, - hasUpstreamColumn(index) { - return index === 0 && this.hasUpstream; - }, setJob(jobName) { - this.jobName = jobName; + this.hoveredJobName = jobName; }, - setPipelineExpanded(jobName, expanded) { - if (expanded) { - this.pipelineExpanded = { - jobName, - expanded, - }; - } else { - this.pipelineExpanded = { - expanded, - jobName: '', - }; - } + togglePipelineExpanded(jobName, expanded) { + this.pipelineExpanded = { + expanded, + jobName: expanded ? jobName : '', + }; }, }, }; </script> <template> - <div class="build-content middle-block js-pipeline-graph"> + <div class="js-pipeline-graph"> <div - class="pipeline-visualization pipeline-graph" - :class="{ 'pipeline-tab-content': !isLinkedPipeline }" + class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap" + :class="{ 'gl-py-5': !isLinkedPipeline }" > - <div - :style="{ - paddingLeft: `${graphLeftPadding}px`, - paddingRight: `${graphRightPadding}px`, - }" - > - <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" /> - - <pipeline-graph - v-if="pipelineTypeUpstream" - :type="$options.upstream" - class="d-inline-block upstream-pipeline" - :class="`js-upstream-pipeline-${expandedUpstream.id}`" - :is-loading="false" - :pipeline="expandedUpstream" - :is-linked-pipeline="true" - :mediator="mediator" - @onClickUpstreamPipeline="clickUpstreamPipeline" - @refreshPipelineGraph="requestRefreshPipelineGraph" - /> - - <linked-pipelines-column - v-if="hasUpstream" - :type="$options.upstream" - :linked-pipelines="upstreamPipelines" - :column-title="__('Upstream')" - :project-id="pipelineProjectId" - @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)" - /> - - <ul - v-if="!isLoading" - :class="{ - 'inline js-has-linked-pipelines': hasDownstream || hasUpstream, - }" - class="stage-column-list align-top" - > + <linked-graph-wrapper> + <template #upstream> + <linked-pipelines-column + v-if="showUpstreamPipelines" + :linked-pipelines="upstreamPipelines" + :column-title="__('Upstream')" + :type="$options.pipelineTypeConstants.UPSTREAM" + @error="emit('error', errorType)" + /> + </template> + <template #main> <stage-column-component - v-for="(stage, index) in graph" + v-for="stage in graph" :key="stage.name" - :class="{ - 'has-upstream gl-ml-11': hasUpstreamColumn(index), - 'has-only-one-job': hasOnlyOneJob(stage), - 'gl-mr-26': shouldAddRightMargin(index), - }" - :title="capitalizeStageName(stage.name)" + :title="stage.name" :groups="stage.groups" - :stage-connector-class="stageConnectorClass(index, stage)" - :is-first-column="isFirstColumn(index)" - :has-upstream="hasUpstream" :action="stage.status.action" - :job-hovered="jobName" + :job-hovered="hoveredJobName" :pipeline-expanded="pipelineExpanded" - @refreshPipelineGraph="refreshPipelineGraph" + @refreshPipelineGraph="$emit('refreshPipelineGraph')" /> - </ul> - - <linked-pipelines-column - v-if="hasDownstream" - :type="$options.downstream" - :linked-pipelines="downstreamPipelines" - :column-title="__('Downstream')" - :project-id="pipelineProjectId" - @linkedPipelineClick="handleClickedDownstream" - @downstreamHovered="setJob" - @pipelineExpandToggle="setPipelineExpanded" - /> - - <pipeline-graph - v-if="pipelineTypeDownstream" - :type="$options.downstream" - class="d-inline-block" - :class="`js-downstream-pipeline-${expandedDownstream.id}`" - :is-loading="false" - :pipeline="expandedDownstream" - :is-linked-pipeline="true" - :style="{ 'margin-top': downstreamMarginTop }" - :mediator="mediator" - @onClickDownstreamPipeline="clickDownstreamPipeline" - @refreshPipelineGraph="requestRefreshPipelineGraph" - /> - </div> + </template> + <template #downstream> + <linked-pipelines-column + v-if="showDownstreamPipelines" + :linked-pipelines="downstreamPipelines" + :column-title="__('Downstream')" + :type="$options.pipelineTypeConstants.DOWNSTREAM" + @downstreamHovered="setJob" + @pipelineExpandToggle="togglePipelineExpanded" + @error="emit('error', errorType)" + /> + </template> + </linked-graph-wrapper> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue new file mode 100644 index 00000000000..9ca4dc1e27a --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue @@ -0,0 +1,265 @@ +<script> +import { escape, capitalize } from 'lodash'; +import { GlLoadingIcon } from '@gitlab/ui'; +import StageColumnComponentLegacy from './stage_column_component_legacy.vue'; +import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue'; +import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; +import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; + +export default { + name: 'PipelineGraphLegacy', + components: { + GlLoadingIcon, + LinkedPipelinesColumnLegacy, + StageColumnComponentLegacy, + }, + mixins: [GraphBundleMixin], + props: { + isLoading: { + type: Boolean, + required: true, + }, + pipeline: { + type: Object, + required: true, + }, + isLinkedPipeline: { + type: Boolean, + required: false, + default: false, + }, + mediator: { + type: Object, + required: true, + }, + type: { + type: String, + required: false, + default: MAIN, + }, + }, + upstream: UPSTREAM, + downstream: DOWNSTREAM, + data() { + return { + downstreamMarginTop: null, + jobName: null, + pipelineExpanded: { + jobName: '', + expanded: false, + }, + }; + }, + computed: { + graph() { + return this.pipeline.details?.stages; + }, + hasUpstream() { + return ( + this.type !== this.$options.downstream && + this.upstreamPipelines && + this.pipeline.triggered_by !== null + ); + }, + upstreamPipelines() { + return this.pipeline.triggered_by; + }, + hasDownstream() { + return ( + this.type !== this.$options.upstream && + this.downstreamPipelines && + this.pipeline.triggered.length > 0 + ); + }, + downstreamPipelines() { + return this.pipeline.triggered; + }, + expandedUpstream() { + return ( + this.pipeline.triggered_by && + Array.isArray(this.pipeline.triggered_by) && + this.pipeline.triggered_by.find(el => el.isExpanded) + ); + }, + expandedDownstream() { + return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded); + }, + pipelineTypeUpstream() { + return this.type !== this.$options.downstream && this.expandedUpstream; + }, + pipelineTypeDownstream() { + return this.type !== this.$options.upstream && this.expandedDownstream; + }, + pipelineProjectId() { + return this.pipeline.project.id; + }, + }, + methods: { + capitalizeStageName(name) { + const escapedName = escape(name); + return capitalize(escapedName); + }, + isFirstColumn(index) { + return index === 0; + }, + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (this.isFirstColumn(index) && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; + }, + refreshPipelineGraph() { + this.$emit('refreshPipelineGraph'); + }, + /** + * CSS class is applied: + * - if pipeline graph contains only one stage column component + * + * @param {number} index + * @returns {boolean} + */ + shouldAddRightMargin(index) { + return !(index === this.graph.length - 1); + }, + handleClickedDownstream(pipeline, clickedIndex, downstreamNode) { + /** + * Calculates the margin top of the clicked downstream pipeline by + * subtracting the clicked downstream pipelines offsetTop by it's parent's + * offsetTop and then subtracting 15 + */ + this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15); + + /** + * If the expanded trigger is defined and the id is different than the + * pipeline we clicked, then it means we clicked on a sibling downstream link + * and we want to reset the pipeline store. Triggering the reset without + * this condition would mean not allowing downstreams of downstreams to expand + */ + if (this.expandedDownstream?.id !== pipeline.id) { + this.$emit('onResetDownstream', this.pipeline, pipeline); + } + + this.$emit('onClickDownstreamPipeline', pipeline); + }, + calculateMarginTop(downstreamNode, pixelDiff) { + return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`; + }, + hasOnlyOneJob(stage) { + return stage.groups.length === 1; + }, + hasUpstreamColumn(index) { + return index === 0 && this.hasUpstream; + }, + setJob(jobName) { + this.jobName = jobName; + }, + setPipelineExpanded(jobName, expanded) { + if (expanded) { + this.pipelineExpanded = { + jobName, + expanded, + }; + } else { + this.pipelineExpanded = { + expanded, + jobName: '', + }; + } + }, + }, +}; +</script> +<template> + <div class="build-content middle-block js-pipeline-graph"> + <div + class="pipeline-visualization pipeline-graph" + :class="{ 'pipeline-tab-content': !isLinkedPipeline }" + > + <div class="gl-w-full"> + <div class="container-fluid container-limited"> + <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" /> + <pipeline-graph-legacy + v-if="pipelineTypeUpstream" + :type="$options.upstream" + class="d-inline-block upstream-pipeline" + :class="`js-upstream-pipeline-${expandedUpstream.id}`" + :is-loading="false" + :pipeline="expandedUpstream" + :is-linked-pipeline="true" + :mediator="mediator" + @onClickUpstreamPipeline="clickUpstreamPipeline" + @refreshPipelineGraph="requestRefreshPipelineGraph" + /> + + <linked-pipelines-column-legacy + v-if="hasUpstream" + :type="$options.upstream" + :linked-pipelines="upstreamPipelines" + :column-title="__('Upstream')" + :project-id="pipelineProjectId" + @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)" + /> + + <ul + v-if="!isLoading" + :class="{ + 'inline js-has-linked-pipelines': hasDownstream || hasUpstream, + }" + class="stage-column-list align-top" + > + <stage-column-component-legacy + v-for="(stage, index) in graph" + :key="stage.name" + :class="{ + 'has-upstream gl-ml-11': hasUpstreamColumn(index), + 'has-only-one-job': hasOnlyOneJob(stage), + 'gl-mr-26': shouldAddRightMargin(index), + }" + :title="capitalizeStageName(stage.name)" + :groups="stage.groups" + :stage-connector-class="stageConnectorClass(index, stage)" + :is-first-column="isFirstColumn(index)" + :has-upstream="hasUpstream" + :action="stage.status.action" + :job-hovered="jobName" + :pipeline-expanded="pipelineExpanded" + @refreshPipelineGraph="refreshPipelineGraph" + /> + </ul> + + <linked-pipelines-column-legacy + v-if="hasDownstream" + :type="$options.downstream" + :linked-pipelines="downstreamPipelines" + :column-title="__('Downstream')" + :project-id="pipelineProjectId" + @linkedPipelineClick="handleClickedDownstream" + @downstreamHovered="setJob" + @pipelineExpandToggle="setPipelineExpanded" + /> + + <pipeline-graph-legacy + v-if="pipelineTypeDownstream" + :type="$options.downstream" + class="d-inline-block" + :class="`js-downstream-pipeline-${expandedDownstream.id}`" + :is-loading="false" + :pipeline="expandedDownstream" + :is-linked-pipeline="true" + :style="{ 'margin-top': downstreamMarginTop }" + :mediator="mediator" + @onClickDownstreamPipeline="clickDownstreamPipeline" + @refreshPipelineGraph="requestRefreshPipelineGraph" + /> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue new file mode 100644 index 00000000000..d98e3aad054 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -0,0 +1,106 @@ +<script> +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { DEFAULT, LOAD_FAILURE } from '../../constants'; +import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql'; +import PipelineGraph from './graph_component.vue'; +import { unwrapPipelineData, toggleQueryPollingByVisibility } from './utils'; + +export default { + name: 'PipelineGraphWrapper', + components: { + GlAlert, + GlLoadingIcon, + PipelineGraph, + }, + inject: { + pipelineIid: { + default: '', + }, + pipelineProjectPath: { + default: '', + }, + }, + data() { + return { + pipeline: null, + alertType: null, + showAlert: false, + }; + }, + errorTexts: { + [LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'), + [DEFAULT]: __('An unknown error occurred while loading this graph.'), + }, + apollo: { + pipeline: { + query: getPipelineDetails, + pollInterval: 10000, + variables() { + return { + projectPath: this.pipelineProjectPath, + iid: this.pipelineIid, + }; + }, + update(data) { + return unwrapPipelineData(this.pipelineProjectPath, data); + }, + error() { + this.reportFailure(LOAD_FAILURE); + }, + }, + }, + computed: { + alert() { + switch (this.alertType) { + case LOAD_FAILURE: + return { + text: this.$options.errorTexts[LOAD_FAILURE], + variant: 'danger', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + variant: 'danger', + }; + } + }, + showLoadingIcon() { + /* + Shows the icon only when the graph is empty, not when it is is + being refetched, for instance, on action completion + */ + return this.$apollo.queries.pipeline.loading && !this.pipeline; + }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.pipeline); + }, + methods: { + hideAlert() { + this.showAlert = false; + }, + refreshPipelineGraph() { + this.$apollo.queries.pipeline.refetch(); + }, + reportFailure(type) { + this.showAlert = true; + this.failureType = type; + }, + }, +}; +</script> +<template> + <div> + <gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert"> + {{ alert.text }} + </gl-alert> + <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" /> + <pipeline-graph + v-if="pipeline" + :pipeline="pipeline" + @error="reportFailure" + @refreshPipelineGraph="refreshPipelineGraph" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 49591a80752..203d6a12edd 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -44,17 +44,18 @@ export default { type="button" data-toggle="dropdown" data-display="static" - class="dropdown-menu-toggle build-content" + class="dropdown-menu-toggle build-content gl-build-content" > - <ci-icon :status="group.status" /> + <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> + <span class="gl-display-flex gl-align-items-center gl-min-w-0"> + <ci-icon :status="group.status" :size="24" /> + <span class="gl-text-truncate mw-70p gl-pl-3"> + {{ group.name }} + </span> + </span> - <span - class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom" - > - {{ group.name }} - </span> - - <span class="dropdown-counter-badge"> {{ group.size }} </span> + <span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span> + </div> </button> <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown"> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 4ed0aae0d1e..93ebe02d4e8 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -4,6 +4,8 @@ import ActionComponent from './action_component.vue'; import JobNameComponent from './job_name_component.vue'; import { sprintf } from '~/locale'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import { accessValue } from './accessors'; +import { REST } from './constants'; /** * Renders the badge for the pipeline graph and the job's dropdown. @@ -41,6 +43,11 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [delayedJobMixin], + inject: { + dataMethod: { + default: REST, + }, + }, props: { job: { type: Object, @@ -71,10 +78,15 @@ export default { boundary() { return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; }, + detailsPath() { + return accessValue(this.dataMethod, 'detailsPath', this.status); + }, + hasDetails() { + return accessValue(this.dataMethod, 'hasDetails', this.status); + }, status() { return this.job && this.job.status ? this.job.status : {}; }, - tooltipText() { const textBuilder = []; const { name: jobName } = this.job; @@ -129,19 +141,23 @@ export default { }; </script> <template> - <div class="ci-job-component" data-qa-selector="job_item_container"> + <div + class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" + data-qa-selector="job_item_container" + > <gl-link - v-if="status.has_details" + v-if="hasDetails" v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" - :href="status.details_path" + :href="detailsPath" :title="tooltipText" :class="jobClasses" - class="js-pipeline-graph-job-link qa-job-link menu-item" + class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none + gl-focus-text-decoration-none gl-hover-text-decoration-none" data-testid="job-with-link" @click.stop="hideTooltips" @mouseout="hideTooltips" > - <job-name-component :name="job.name" :status="job.status" /> + <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> </gl-link> <div @@ -149,11 +165,11 @@ export default { v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" :title="tooltipText" :class="jobClasses" - class="js-job-component-tooltip non-details-job-component" + class="js-job-component-tooltip non-details-job-component menu-item" data-testid="job-without-link" @mouseout="hideTooltips" > - <job-name-component :name="job.name" :status="job.status" /> + <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> </div> <action-component diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue index 1b71949784a..23a38fc053e 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -16,18 +16,22 @@ export default { type: String, required: true, }, - status: { type: Object, required: true, }, + iconSize: { + type: Number, + required: false, + default: 16, + }, }, }; </script> <template> - <span class="ci-job-name-component mw-100"> - <ci-icon :status="status" /> - <span class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom"> + <span class="ci-job-name-component mw-100 gl-display-flex gl-align-items-center"> + <ci-icon :size="iconSize" :status="status" /> + <span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block"> {{ name }} </span> </span> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 11f06a25984..1a179de64cd 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -2,7 +2,8 @@ import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; import { __, sprintf } from '~/locale'; -import { UPSTREAM, DOWNSTREAM } from './constants'; +import { accessValue } from './accessors'; +import { DOWNSTREAM, REST, UPSTREAM } from './constants'; export default { directives: { @@ -14,28 +15,43 @@ export default { GlLink, GlLoadingIcon, }, + inject: { + dataMethod: { + default: REST, + }, + }, props: { columnTitle: { type: String, required: true, }, - pipeline: { - type: Object, + expanded: { + type: Boolean, required: true, }, - projectId: { - type: Number, + pipeline: { + type: Object, required: true, }, type: { type: String, required: true, }, - }, - data() { - return { - expanded: false, - }; + /* + The next two props will be removed or required + once the graph transition is done. + See: https://gitlab.com/gitlab-org/gitlab/-/issues/291043 + */ + isLoading: { + type: Boolean, + required: false, + default: false, + }, + projectId: { + type: Number, + required: false, + default: -1, + }, }, computed: { tooltipText() { @@ -46,7 +62,7 @@ export default { return `js-linked-pipeline-${this.pipeline.id}`; }, pipelineStatus() { - return this.pipeline.details.status; + return accessValue(this.dataMethod, 'pipelineStatus', this.pipeline); }, projectName() { return this.pipeline.project.name; @@ -68,6 +84,9 @@ export default { } return __('Multi-project'); }, + pipelineIsLoading() { + return Boolean(this.isLoading || this.pipeline.isLoading); + }, isDownstream() { return this.type === DOWNSTREAM; }, @@ -75,12 +94,15 @@ export default { return this.type === UPSTREAM; }, isSameProject() { - return this.projectId === this.pipeline.project.id; + return this.projectId > -1 + ? this.projectId === this.pipeline.project.id + : !this.pipeline.multiproject; + }, + sourceJobName() { + return accessValue(this.dataMethod, 'sourceJob', this.pipeline); }, sourceJobInfo() { - return this.isDownstream - ? sprintf(__('Created by %{job}'), { job: this.pipeline.source_job.name }) - : ''; + return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : ''; }, expandedIcon() { if (this.isUpstream) { @@ -94,16 +116,15 @@ export default { }, methods: { onClickLinkedPipeline() { - this.$root.$emit('bv::hide::tooltip', this.buttonId); - this.expanded = !this.expanded; + this.hideTooltips(); this.$emit('pipelineClicked', this.$refs.linkedPipeline); - this.$emit('pipelineExpandToggle', this.pipeline.source_job.name, this.expanded); + this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded); }, hideTooltips() { this.$root.$emit('bv::hide::tooltip'); }, onDownstreamHovered() { - this.$emit('downstreamHovered', this.pipeline.source_job.name); + this.$emit('downstreamHovered', this.sourceJobName); }, onDownstreamHoverLeave() { this.$emit('downstreamHovered', ''); @@ -113,10 +134,10 @@ export default { </script> <template> - <li + <div ref="linkedPipeline" v-gl-tooltip - class="linked-pipeline build" + class="linked-pipeline build gl-pipeline-job-width" :title="tooltipText" :class="{ 'downstream-pipeline': isDownstream }" data-qa-selector="child_pipeline" @@ -129,8 +150,9 @@ export default { > <div class="gl-display-flex"> <ci-status - v-if="!pipeline.isLoading" + v-if="!pipelineIsLoading" :status="pipelineStatus" + :size="24" css-classes="gl-top-0 gl-pr-2" /> <div v-else class="gl-pr-2"><gl-loading-icon inline /></div> @@ -153,10 +175,10 @@ export default { class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!" :class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`" :icon="expandedIcon" - data-testid="expandPipelineButton" + data-testid="expand-pipeline-button" data-qa-selector="expand_pipeline_button" @click="onClickLinkedPipeline" /> </div> - </li> + </div> </template> 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 2ca33e6d33e..7d333087874 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -1,10 +1,14 @@ <script> +import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql'; import LinkedPipeline from './linked_pipeline.vue'; +import { LOAD_FAILURE } from '../../constants'; import { UPSTREAM } from './constants'; +import { unwrapPipelineData, toggleQueryPollingByVisibility } from './utils'; export default { components: { LinkedPipeline, + PipelineGraph: () => import('./graph_component.vue'), }, props: { columnTitle: { @@ -19,11 +23,22 @@ export default { type: String, required: true, }, - projectId: { - type: Number, - required: true, - }, }, + data() { + return { + currentPipeline: null, + loadingPipelineId: null, + pipelineExpanded: false, + }; + }, + titleClasses: [ + 'gl-font-weight-bold', + 'gl-pipeline-job-width', + 'gl-text-truncate', + 'gl-line-height-36', + 'gl-pl-3', + 'gl-mb-5', + ], computed: { columnClass() { const positionValues = { @@ -35,14 +50,69 @@ export default { graphPosition() { return this.isUpstream ? 'left' : 'right'; }, - // Refactor string match when BE returns Upstream/Downstream indicators isUpstream() { return this.type === UPSTREAM; }, + computedTitleClasses() { + const positionalClasses = this.isUpstream + ? ['gl-w-full', 'gl-text-right', 'gl-linked-pipeline-padding'] + : []; + + return [...this.$options.titleClasses, ...positionalClasses]; + }, }, methods: { - onPipelineClick(downstreamNode, pipeline, index) { - this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); + getPipelineData(pipeline) { + const projectPath = pipeline.project.fullPath; + + this.$apollo.addSmartQuery('currentPipeline', { + query: getPipelineDetails, + pollInterval: 10000, + variables() { + return { + projectPath, + iid: pipeline.iid, + }; + }, + update(data) { + return unwrapPipelineData(projectPath, data); + }, + result() { + this.loadingPipelineId = null; + }, + error() { + this.$emit('error', LOAD_FAILURE); + }, + }); + + toggleQueryPollingByVisibility(this.$apollo.queries.currentPipeline); + }, + isExpanded(id) { + return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id); + }, + isLoadingPipeline(id) { + return this.loadingPipelineId === id; + }, + onPipelineClick(pipeline) { + /* If the clicked pipeline has been expanded already, close it, clear, exit */ + if (this.currentPipeline?.id === pipeline.id) { + this.pipelineExpanded = false; + this.currentPipeline = null; + return; + } + + /* Set the loading id */ + this.loadingPipelineId = pipeline.id; + + /* + Expand the pipeline. + If this was not a toggle close action, and + it was already showing a different pipeline, then + this will be a no-op, but that doesn't matter. + */ + this.pipelineExpanded = true; + + this.getPipelineData(pipeline); }, onDownstreamHovered(jobName) { this.$emit('downstreamHovered', jobName); @@ -60,25 +130,40 @@ export default { </script> <template> - <div :class="columnClass" class="stage-column linked-pipelines-column"> - <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> - <div v-if="isUpstream" class="cross-project-triangle"></div> - <ul> - <linked-pipeline - v-for="(pipeline, index) in linkedPipelines" - :key="pipeline.id" - :class="{ - active: pipeline.isExpanded, - 'left-connector': pipeline.isExpanded && graphPosition === 'left', - }" - :pipeline="pipeline" - :column-title="columnTitle" - :project-id="projectId" - :type="type" - @pipelineClicked="onPipelineClick($event, pipeline, index)" - @downstreamHovered="onDownstreamHovered" - @pipelineExpandToggle="onPipelineExpandToggle" - /> - </ul> + <div class="gl-display-flex"> + <div :class="columnClass" class="linked-pipelines-column"> + <div data-testid="linked-column-title" class="stage-name" :class="computedTitleClasses"> + {{ columnTitle }} + </div> + <ul class="gl-pl-0"> + <li + v-for="pipeline in linkedPipelines" + :key="pipeline.id" + class="gl-display-flex gl-mb-4" + :class="{ 'gl-flex-direction-row-reverse': isUpstream }" + > + <linked-pipeline + class="gl-display-inline-block" + :is-loading="isLoadingPipeline(pipeline.id)" + :pipeline="pipeline" + :column-title="columnTitle" + :type="type" + :expanded="isExpanded(pipeline.id)" + @downstreamHovered="onDownstreamHovered" + @pipelineClicked="onPipelineClick(pipeline)" + @pipelineExpandToggle="onPipelineExpandToggle" + /> + <div v-if="isExpanded(pipeline.id)" class="gl-display-inline-block"> + <pipeline-graph + v-if="currentPipeline" + :type="type" + class="d-inline-block gl-mt-n2" + :pipeline="currentPipeline" + :is-linked-pipeline="true" + /> + </div> + </li> + </ul> + </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue new file mode 100644 index 00000000000..7d371b33220 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue @@ -0,0 +1,87 @@ +<script> +import LinkedPipeline from './linked_pipeline.vue'; +import { UPSTREAM } from './constants'; + +export default { + components: { + LinkedPipeline, + }, + props: { + columnTitle: { + type: String, + required: true, + }, + linkedPipelines: { + type: Array, + required: true, + }, + type: { + type: String, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + }, + computed: { + columnClass() { + const positionValues = { + right: 'gl-ml-11', + left: 'gl-mr-7', + }; + return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; + }, + graphPosition() { + return this.isUpstream ? 'left' : 'right'; + }, + isExpanded() { + return this.pipeline?.isExpanded || false; + }, + isUpstream() { + return this.type === UPSTREAM; + }, + }, + methods: { + onPipelineClick(downstreamNode, pipeline, index) { + this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); + }, + onDownstreamHovered(jobName) { + this.$emit('downstreamHovered', jobName); + }, + onPipelineExpandToggle(jobName, expanded) { + // Highlighting only applies to downstream pipelines + if (this.isUpstream) { + return; + } + + this.$emit('pipelineExpandToggle', jobName, expanded); + }, + }, +}; +</script> + +<template> + <div :class="columnClass" class="stage-column linked-pipelines-column"> + <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> + <div v-if="isUpstream" class="cross-project-triangle"></div> + <ul> + <li v-for="(pipeline, index) in linkedPipelines" :key="pipeline.id"> + <linked-pipeline + :class="{ + active: pipeline.isExpanded, + 'left-connector': pipeline.isExpanded && graphPosition === 'left', + }" + :pipeline="pipeline" + :column-title="columnTitle" + :project-id="projectId" + :type="type" + :expanded="isExpanded" + @pipelineClicked="onPipelineClick($event, pipeline, index)" + @downstreamHovered="onDownstreamHovered" + @pipelineExpandToggle="onPipelineExpandToggle" + /> + </li> + </ul> + </div> +</template> 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 a75ec585b95..b9bddc94ce4 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -1,17 +1,19 @@ <script> -import { isEmpty, escape } from 'lodash'; -import stageColumnMixin from '../../mixins/stage_column_mixin'; +import { capitalize, escape, isEmpty } from 'lodash'; +import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue'; import JobItem from './job_item.vue'; import JobGroupDropdown from './job_group_dropdown.vue'; import ActionComponent from './action_component.vue'; +import { GRAPHQL } from './constants'; +import { accessValue } from './accessors'; export default { components: { - JobItem, - JobGroupDropdown, ActionComponent, + JobGroupDropdown, + JobItem, + MainGraphWrapper, }, - mixins: [stageColumnMixin], props: { title: { type: String, @@ -21,16 +23,6 @@ export default { type: Array, required: true, }, - isFirstColumn: { - type: Boolean, - required: false, - default: false, - }, - stageConnectorClass: { - type: String, - required: false, - default: '', - }, action: { type: Object, required: false, @@ -47,62 +39,68 @@ export default { default: () => ({}), }, }, + titleClasses: [ + 'gl-font-weight-bold', + 'gl-pipeline-job-width', + 'gl-text-truncate', + 'gl-line-height-36', + 'gl-pl-3', + ], computed: { + formattedTitle() { + return capitalize(escape(this.title)); + }, hasAction() { return !isEmpty(this.action); }, }, methods: { + getGroupId(group) { + return accessValue(GRAPHQL, 'groupId', group); + }, groupId(group) { return `ci-badge-${escape(group.name)}`; }, - pipelineActionRequestComplete() { - this.$emit('refreshPipelineGraph'); - }, }, }; </script> <template> - <li :class="stageConnectorClass" class="stage-column"> - <div class="stage-name position-relative"> - {{ title }} - <action-component - v-if="hasAction" - :action-icon="action.icon" - :tooltip-text="action.title" - :link="action.path" - class="js-stage-action stage-action rounded" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - </div> - - <div class="builds-container"> - <ul> - <li - v-for="(group, index) in groups" - :id="groupId(group)" - :key="group.id" - :class="buildConnnectorClass(index)" - class="build" - > - <div class="curve"></div> - - <job-item - v-if="group.size === 1" - :job="group.jobs[0]" - :job-hovered="jobHovered" - :pipeline-expanded="pipelineExpanded" - css-class-job-name="build-content" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - - <job-group-dropdown - v-if="group.size > 1" - :group="group" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - </li> - </ul> - </div> - </li> + <main-graph-wrapper> + <template #stages> + <div + data-testid="stage-column-title" + class="gl-display-flex gl-justify-content-space-between gl-relative" + :class="$options.titleClasses" + > + <div>{{ formattedTitle }}</div> + <action-component + v-if="hasAction" + :action-icon="action.icon" + :tooltip-text="action.title" + :link="action.path" + class="js-stage-action stage-action rounded" + @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" + /> + </div> + </template> + <template #jobs> + <div + v-for="group in groups" + :id="groupId(group)" + :key="getGroupId(group)" + data-testid="stage-column-group" + class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width" + > + <job-item + v-if="group.size === 1" + :job="group.jobs[0]" + :job-hovered="jobHovered" + :pipeline-expanded="pipelineExpanded" + css-class-job-name="gl-build-content" + @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" + /> + <job-group-dropdown v-else :group="group" /> + </div> + </template> + </main-graph-wrapper> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue new file mode 100644 index 00000000000..258b6bf6b6d --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue @@ -0,0 +1,108 @@ +<script> +import { isEmpty, escape } from 'lodash'; +import stageColumnMixin from '../../mixins/stage_column_mixin'; +import JobItem from './job_item.vue'; +import JobGroupDropdown from './job_group_dropdown.vue'; +import ActionComponent from './action_component.vue'; + +export default { + components: { + JobItem, + JobGroupDropdown, + ActionComponent, + }, + mixins: [stageColumnMixin], + props: { + title: { + type: String, + required: true, + }, + groups: { + type: Array, + required: true, + }, + isFirstColumn: { + type: Boolean, + required: false, + default: false, + }, + stageConnectorClass: { + type: String, + required: false, + default: '', + }, + action: { + type: Object, + required: false, + default: () => ({}), + }, + jobHovered: { + type: String, + required: false, + default: '', + }, + pipelineExpanded: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + hasAction() { + return !isEmpty(this.action); + }, + }, + methods: { + groupId(group) { + return `ci-badge-${escape(group.name)}`; + }, + pipelineActionRequestComplete() { + this.$emit('refreshPipelineGraph'); + }, + }, +}; +</script> +<template> + <li :class="stageConnectorClass" class="stage-column"> + <div class="stage-name position-relative" data-testid="stage-column-title"> + {{ title }} + <action-component + v-if="hasAction" + :action-icon="action.icon" + :tooltip-text="action.title" + :link="action.path" + class="js-stage-action stage-action rounded" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + /> + </div> + + <div class="builds-container"> + <ul> + <li + v-for="(group, index) in groups" + :id="groupId(group)" + :key="group.id" + :class="buildConnnectorClass(index)" + class="build" + > + <div class="curve"></div> + + <job-item + v-if="group.size === 1" + :job="group.jobs[0]" + :job-hovered="jobHovered" + :pipeline-expanded="pipelineExpanded" + css-class-job-name="build-content" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + /> + + <job-group-dropdown + v-if="group.size > 1" + :group="group" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + /> + </li> + </ul> + </div> + </li> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js new file mode 100644 index 00000000000..32588feb426 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -0,0 +1,57 @@ +import Visibility from 'visibilityjs'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { unwrapStagesWithNeeds } from '../unwrapping_utils'; + +const addMulti = (mainPipelineProjectPath, linkedPipeline) => { + return { + ...linkedPipeline, + multiproject: mainPipelineProjectPath !== linkedPipeline.project.fullPath, + }; +}; + +const transformId = linkedPipeline => { + return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) }; +}; + +const unwrapPipelineData = (mainPipelineProjectPath, data) => { + if (!data?.project?.pipeline) { + return null; + } + + const { pipeline } = data.project; + + const { + upstream, + downstream, + stages: { nodes: stages }, + } = pipeline; + + const nodes = unwrapStagesWithNeeds(stages); + + return { + ...pipeline, + id: getIdFromGraphQLId(pipeline.id), + stages: nodes, + upstream: upstream + ? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId) + : [], + downstream: downstream + ? downstream.nodes.map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId) + : [], + }; +}; + +const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => { + const stopStartQuery = query => { + if (!Visibility.hidden()) { + query.startPolling(interval); + } else { + query.stopPolling(); + } + }; + + stopStartQuery(queryRef); + Visibility.change(stopStartQuery.bind(null, queryRef)); +}; + +export { unwrapPipelineData, toggleQueryPollingByVisibility }; diff --git a/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue b/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue new file mode 100644 index 00000000000..fb2280d971a --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue @@ -0,0 +1,7 @@ +<template> + <div class="gl-display-flex"> + <slot name="upstream"></slot> + <slot name="main"></slot> + <slot name="downstream"></slot> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue b/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue new file mode 100644 index 00000000000..1c9e3236d56 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue @@ -0,0 +1,32 @@ +<script> +export default { + props: { + stageClasses: { + type: String, + required: false, + default: '', + }, + jobClasses: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> +<template> + <div> + <div + class="gl-display-flex gl-align-items-center gl-w-full gl-px-8 gl-mb-5" + :class="stageClasses" + > + <slot name="stages"> </slot> + </div> + <div + class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8" + :class="jobClasses" + > + <slot name="jobs"> </slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 741609c908a..af7c0d0ec3f 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -229,6 +229,7 @@ export default { v-if="pipeline.cancelable" :loading="isCanceling" :disabled="isCanceling" + class="gl-ml-3" variant="danger" data-testid="cancelPipeline" @click="cancelPipeline()" diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js index 45940d4a39c..35230e1511b 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js @@ -1,5 +1,5 @@ import * as d3 from 'd3'; -import { createUniqueJobId } from '../../utils'; +import { createUniqueLinkId } from '../../utils'; /** * This function expects its first argument data structure * to be the same shaped as the one generated by `parseData`, @@ -12,13 +12,13 @@ import { createUniqueJobId } from '../../utils'; * @returns {Array} Links that contain all the information about them */ -export const generateLinksData = ({ links }, jobs, containerID) => { +export const generateLinksData = ({ links }, containerID) => { const containerEl = document.getElementById(containerID); return links.map(link => { const path = d3.path(); - const sourceId = jobs[link.source].id; - const targetId = jobs[link.target].id; + const sourceId = link.source; + const targetId = link.target; const sourceNodeEl = document.getElementById(sourceId); const targetNodeEl = document.getElementById(targetId); @@ -89,7 +89,7 @@ export const generateLinksData = ({ links }, jobs, containerID) => { ...link, source: sourceId, target: targetId, - ref: createUniqueJobId(sourceId, targetId), + ref: createUniqueLinkId(sourceId, targetId), path: path.toString(), }; }); diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue deleted file mode 100644 index 3cc76425e2a..00000000000 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue +++ /dev/null @@ -1,76 +0,0 @@ -<script> -import { GlTab, GlTabs } from '@gitlab/ui'; -import jsYaml from 'js-yaml'; -import PipelineGraph from './pipeline_graph.vue'; -import { preparePipelineGraphData } from '../../utils'; - -export default { - FILE_CONTENT_SELECTOR: '#blob-content', - EMPTY_FILE_SELECTOR: '.nothing-here-block', - - components: { - GlTab, - GlTabs, - PipelineGraph, - }, - props: { - blobData: { - required: true, - type: String, - }, - }, - data() { - return { - selectedTabIndex: 0, - pipelineData: {}, - }; - }, - computed: { - isVisualizationTab() { - return this.selectedTabIndex === 1; - }, - }, - async created() { - if (this.blobData) { - // The blobData in this case represents the gitlab-ci.yml data - const json = await jsYaml.load(this.blobData); - this.pipelineData = preparePipelineGraphData(json); - } - }, - methods: { - // This is used because the blob page still uses haml, and we can't make - // our haml hide the unused section so we resort to a standard query here. - toggleFileContent({ isFileTab }) { - const el = document.querySelector(this.$options.FILE_CONTENT_SELECTOR); - const emptySection = document.querySelector(this.$options.EMPTY_FILE_SELECTOR); - - const elementToHide = el || emptySection; - - if (!elementToHide) { - return; - } - - // Checking for the current style display prevents user - // from toggling visiblity on and off when clicking on the tab - if (!isFileTab && elementToHide.style.display !== 'none') { - elementToHide.style.display = 'none'; - } - - if (isFileTab && elementToHide.style.display === 'none') { - elementToHide.style.display = 'block'; - } - }, - }, -}; -</script> -<template> - <div> - <div> - <gl-tabs v-model="selectedTabIndex"> - <gl-tab :title="__('File')" @click="toggleFileContent({ isFileTab: true })" /> - <gl-tab :title="__('Visualization')" @click="toggleFileContent({ isFileTab: false })" /> - </gl-tabs> - </div> - <pipeline-graph v-if="isVisualizationTab" :pipeline-data="pipelineData" /> - </div> -</template> 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 a0c35f54c0e..51a95612d3f 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue @@ -10,10 +10,6 @@ export default { type: String, required: true, }, - jobId: { - type: String, - required: true, - }, isHighlighted: { type: Boolean, required: false, @@ -45,7 +41,7 @@ export default { }, methods: { onMouseEnter() { - this.$emit('on-mouse-enter', this.jobId); + this.$emit('on-mouse-enter', this.jobName); }, onMouseLeave() { this.$emit('on-mouse-leave'); @@ -56,7 +52,7 @@ export default { <template> <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> <div - :id="jobId" + :id="jobName" 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" 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 11ad2f2a3b6..73e5f2542fb 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -6,8 +6,10 @@ import JobPill from './job_pill.vue'; import StagePill from './stage_pill.vue'; import { generateLinksData } from './drawing_utils'; import { parseData } from '../parsing_utils'; -import { DRAW_FAILURE, DEFAULT } from '../../constants'; -import { generateJobNeedsDict } from '../../utils'; +import { unwrapArrayOfJobs } from '../unwrapping_utils'; +import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants'; +import { createJobsHash, generateJobNeedsDict } from '../../utils'; +import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; export default { components: { @@ -22,6 +24,12 @@ export default { [DRAW_FAILURE]: __('Could not draw the lines for job relationships'), [DEFAULT]: __('An unknown error occurred.'), }, + warningTexts: { + [EMPTY_PIPELINE_DATA]: __( + 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', + ), + [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'), + }, props: { pipelineData: { required: true, @@ -40,18 +48,51 @@ export default { }, computed: { isPipelineDataEmpty() { - return isEmpty(this.pipelineData); + return !this.isInvalidCiConfig && isEmpty(this.pipelineData?.stages); + }, + isInvalidCiConfig() { + return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID; + }, + showAlert() { + return this.hasError || this.hasWarning; }, hasError() { return this.failureType; }, + hasWarning() { + return this.warning; + }, hasHighlightedJob() { return Boolean(this.highlightedJob); }, + alert() { + if (this.hasError) { + return this.failure; + } + + return this.warning; + }, failure() { const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT]; - return { text, variant: 'danger' }; + return { text, variant: 'danger', dismissible: true }; + }, + warning() { + if (this.isPipelineDataEmpty) { + return { + text: this.$options.warningTexts[EMPTY_PIPELINE_DATA], + variant: 'tip', + dismissible: false, + }; + } else if (this.isInvalidCiConfig) { + return { + text: this.$options.warningTexts[INVALID_CI_CONFIG], + variant: 'danger', + dismissible: false, + }; + } + + return null; }, viewBox() { return [0, 0, this.width, this.height]; @@ -80,19 +121,21 @@ export default { }, }, mounted() { - if (!this.isPipelineDataEmpty) { - this.getGraphDimensions(); - this.drawJobLinks(); + if (!this.isPipelineDataEmpty && !this.isInvalidCiConfig) { + // This guarantee that all sub-elements are rendered + // https://v3.vuejs.org/api/options-lifecycle-hooks.html#mounted + this.$nextTick(() => { + this.getGraphDimensions(); + this.prepareLinkData(); + }); } }, methods: { - drawJobLinks() { - const { stages, jobs } = this.pipelineData; - const unwrappedGroups = this.unwrapPipelineData(stages); - + prepareLinkData() { try { - const parsedData = parseData(unwrappedGroups); - this.links = generateLinksData(parsedData, jobs, this.$options.CONTAINER_ID); + const arrayOfJobs = unwrapArrayOfJobs(this.pipelineData); + const parsedData = parseData(arrayOfJobs); + this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID); } catch { this.reportFailure(DRAW_FAILURE); } @@ -119,7 +162,8 @@ export default { // The first time we hover, we create the object where // we store all the data to properly highlight the needs. if (!this.needsObject) { - this.needsObject = generateJobNeedsDict(this.pipelineData) ?? {}; + const jobs = createJobsHash(this.pipelineData); + this.needsObject = generateJobNeedsDict(jobs) ?? {}; } this.highlightedJob = uniqueJobId; @@ -127,18 +171,9 @@ export default { removeHighlightNeeds() { this.highlightedJob = null; }, - unwrapPipelineData(stages) { - return stages - .map(({ name, groups }) => { - return groups.map(group => { - return { category: name, ...group }; - }); - }) - .flat(2); - }, getGraphDimensions() { - this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}px`; - this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}px`; + this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`; + this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`; }, reportFailure(errorType) { this.failureType = errorType; @@ -163,21 +198,20 @@ export default { </script> <template> <div> - <gl-alert v-if="hasError" :variant="failure.variant" @dismiss="resetFailure"> - {{ failure.text }} - </gl-alert> - <gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false"> - {{ - __( - 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', - ) - }} + <gl-alert + v-if="showAlert" + :variant="alert.variant" + :dismissible="alert.dismissible" + @dismiss="alert.dismissible ? resetFailure : null" + > + {{ alert.text }} </gl-alert> <div - v-else + v-if="!hasWarning" :id="$options.CONTAINER_ID" :ref="$options.CONTAINER_REF" class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7" + data-testid="graph-container" > <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute"> <template> @@ -210,10 +244,9 @@ export default { <job-pill v-for="group in stage.groups" :key="group.name" - :job-id="group.id" :job-name="group.name" - :is-highlighted="hasHighlightedJob && isJobHighlighted(group.id)" - :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.id)" + :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)" + :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)" @on-mouse-enter="highlightNeeds" @on-mouse-leave="removeHighlightNeeds" /> 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 c5f30c8aef0..78b69073cd3 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -29,11 +29,13 @@ export default { </div> <div class="col-12"> - <div class="text-content"> + <div class="gl-text-content"> <template v-if="canSetCi"> - <h4 class="text-center">{{ s__('Pipelines|Build with confidence') }}</h4> + <h4 class="gl-text-center" data-testid="header-text"> + {{ s__('Pipelines|Build with confidence') }} + </h4> - <p> + <p data-testid="info-text"> {{ s__(`Pipelines|Continuous Integration can help catch bugs by running your tests automatically, @@ -42,12 +44,11 @@ export default { }} </p> - <div class="text-center"> + <div class="gl-text-center"> <gl-button :href="helpPagePath" variant="info" category="primary" - class="js-get-started-pipelines" data-testid="get-started-pipelines" > {{ s__('Pipelines|Get started with Pipelines') }} @@ -55,7 +56,7 @@ export default { </div> </template> - <p v-else class="text-center"> + <p v-else class="gl-text-center"> {{ s__('Pipelines|This project is not currently set up to run pipelines.') }} </p> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index 63262cc79fd..bde0dd53aac 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -25,6 +25,11 @@ export default { required: true, }, }, + inject: { + targetProjectFullPath: { + default: '', + }, + }, computed: { user() { return this.pipeline.user; @@ -32,6 +37,12 @@ export default { isScheduled() { return this.pipeline.source === SCHEDULE_ORIGIN; }, + isInFork() { + return Boolean( + this.targetProjectFullPath && + this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`, + ); + }, }, }; </script> @@ -52,9 +63,8 @@ export default { :title="__('This pipeline was triggered by a schedule.')" class="badge badge-info" data-testid="pipeline-url-scheduled" + >{{ __('Scheduled') }}</span > - {{ __('Scheduled') }} - </span> </gl-link> <span v-if="pipeline.flags.latest" @@ -62,27 +72,24 @@ export default { :title="__('Latest pipeline for the most recent commit on this branch')" class="js-pipeline-url-latest badge badge-success" data-testid="pipeline-url-latest" + >{{ __('latest') }}</span > - {{ __('latest') }} - </span> <span v-if="pipeline.flags.yaml_errors" v-gl-tooltip :title="pipeline.yaml_errors" class="js-pipeline-url-yaml badge badge-danger" data-testid="pipeline-url-yaml" + >{{ __('yaml invalid') }}</span > - {{ __('yaml invalid') }} - </span> <span v-if="pipeline.flags.failure_reason" v-gl-tooltip :title="pipeline.failure_reason" class="js-pipeline-url-failure badge badge-danger" data-testid="pipeline-url-failure" + >{{ __('error') }}</span > - {{ __('error') }} - </span> <gl-link v-if="pipeline.flags.auto_devops" :id="`pipeline-url-autodevops-${pipeline.id}`" @@ -112,17 +119,16 @@ export default { </gl-sprintf> </div> </template> - <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow"> - {{ __('Learn more about Auto DevOps') }} - </gl-link> + <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">{{ + __('Learn more about Auto DevOps') + }}</gl-link> </gl-popover> <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning" data-testid="pipeline-url-stuck" + >{{ __('stuck') }}</span > - {{ __('stuck') }} - </span> <span v-if="pipeline.flags.detached_merge_request_pipeline" v-gl-tooltip @@ -133,9 +139,16 @@ export default { " class="js-pipeline-url-detached badge badge-info" data-testid="pipeline-url-detached" + >{{ __('detached') }}</span + > + <span + v-if="isInFork" + v-gl-tooltip + :title="__('Pipeline ran in fork of project')" + class="badge badge-info" + data-testid="pipeline-url-fork" + >{{ __('fork') }}</span > - {{ __('detached') }} - </span> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 9ee427d01fd..ff27226b408 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -62,7 +62,7 @@ export default { type: String, required: true, }, - autoDevopsPath: { + autoDevopsHelpPath: { type: String, required: true, }, @@ -342,7 +342,7 @@ export default { :pipelines="state.pipelines" :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" - :auto-devops-help-path="autoDevopsPath" + :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" /> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue index e52afe08336..1ea71610897 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue @@ -32,7 +32,7 @@ export default { if (action.scheduled_at) { const confirmationMessage = sprintf( s__( - "DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.", + 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.', ), { jobName: action.name }, ); diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 1d117cfe34a..5548a1021f5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -53,12 +53,12 @@ export default { <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div> <div class="table-mobile-content"> <p v-if="hasDuration" class="duration"> - <gl-icon name="timer" class="gl-vertical-align-baseline!" aria-hidden="true" /> + <gl-icon name="timer" class="gl-vertical-align-baseline!" /> {{ durationFormatted }} </p> <p v-if="hasFinishedTime" class="finished-at d-none d-md-block"> - <gl-icon name="calendar" class="gl-vertical-align-baseline!" aria-hidden="true" /> + <gl-icon name="calendar" class="gl-vertical-align-baseline!" /> <time v-gl-tooltip diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 7afbb59cbd6..4b4fb6082c6 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -1,6 +1,13 @@ <script> -import { mapGetters } from 'vuex'; -import { GlModalDirective, GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui'; +import { mapState, mapGetters, mapActions } from 'vuex'; +import { + GlModalDirective, + GlTooltipDirective, + GlFriendlyWrap, + GlIcon, + GlButton, + GlPagination, +} from '@gitlab/ui'; import { __ } from '~/locale'; import TestCaseDetails from './test_case_details.vue'; @@ -10,6 +17,7 @@ export default { GlIcon, GlFriendlyWrap, GlButton, + GlPagination, TestCaseDetails, }, directives: { @@ -24,11 +32,15 @@ export default { }, }, computed: { - ...mapGetters(['getSuiteTests']), + ...mapState(['pageInfo']), + ...mapGetters(['getSuiteTests', 'getSuiteTestCount']), hasSuites() { return this.getSuiteTests.length > 0; }, }, + methods: { + ...mapActions(['setPage']), + }, wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'], }; </script> @@ -129,6 +141,14 @@ export default { </div> </div> </div> + + <gl-pagination + v-model="pageInfo.page" + class="gl-display-flex gl-justify-content-center" + :per-page="pageInfo.perPage" + :total-items="getSuiteTestCount" + @input="setPage" + /> </div> <div v-else> diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js new file mode 100644 index 00000000000..aa33f622ce6 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js @@ -0,0 +1,53 @@ +/** + * This function takes the stages and add the stage name + * at the group level as `category` to have an easier + * implementation while constructions nodes with D3 + * @param {Array} stages + * @returns {Array} - Array of stages with stage name at the group level as `category` + */ +export const unwrapArrayOfJobs = (stages = []) => { + return stages + .map(({ name, groups }) => { + return groups.map(group => { + return { category: name, ...group }; + }); + }) + .flat(2); +}; + +const unwrapGroups = stages => { + return stages.map(stage => { + const { + groups: { nodes: groups }, + } = stage; + return { ...stage, groups }; + }); +}; + +const unwrapNodesWithName = (jobArray, prop, field = 'name') => { + return jobArray.map(job => { + return { ...job, [prop]: job[prop].nodes.map(item => item[field]) }; + }); +}; + +const unwrapJobWithNeeds = denodedJobArray => { + return unwrapNodesWithName(denodedJobArray, 'needs'); +}; + +const unwrapStagesWithNeeds = denodedStages => { + const unwrappedNestedGroups = unwrapGroups(denodedStages); + + const nodes = unwrappedNestedGroups.map(node => { + const { groups } = node; + const groupsWithJobs = groups.map(group => { + const jobs = unwrapJobWithNeeds(group.jobs.nodes); + return { ...group, jobs }; + }); + + return { ...node, groups: groupsWithJobs }; + }); + + return nodes; +}; + +export { unwrapGroups, unwrapNodesWithName, unwrapJobWithNeeds, unwrapStagesWithNeeds }; diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 607e7a66f44..757d285ef19 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -28,6 +28,8 @@ export const RAW_TEXT_WARNING = s__( export const DEFAULT = 'default'; export const DELETE_FAILURE = 'delete_pipeline_failure'; export const DRAW_FAILURE = 'draw_failure'; +export const EMPTY_PIPELINE_DATA = 'empty_data'; +export const INVALID_CI_CONFIG = 'invalid_ci_config'; export const LOAD_FAILURE = 'load_failure'; export const PARSE_FAILURE = 'parse_failure'; export const POST_FAILURE = 'post_failure'; diff --git a/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql b/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql new file mode 100644 index 00000000000..3bf6d8dc9d8 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql @@ -0,0 +1,17 @@ +fragment LinkedPipelineData on Pipeline { + id + iid + path + status: detailedStatus { + group + label + icon + } + sourceJob { + name + } + project { + name + fullPath + } +} diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql new file mode 100644 index 00000000000..25aede49631 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql @@ -0,0 +1,65 @@ +#import "../fragments/linked_pipelines.fragment.graphql" + +query getPipelineDetails($projectPath: ID!, $iid: ID!) { + project(fullPath: $projectPath) { + pipeline(iid: $iid) { + id + iid + downstream { + nodes { + ...LinkedPipelineData + } + } + upstream { + ...LinkedPipelineData + } + stages { + nodes { + name + status: detailedStatus { + action { + icon + path + title + } + } + groups { + nodes { + status: detailedStatus { + label + group + icon + } + name + size + jobs { + nodes { + name + scheduledAt + needs { + nodes { + name + } + } + status: detailedStatus { + icon + tooltip + hasDetails + detailsPath + group + action { + buttonTitle + icon + path + title + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql index 06083daeca0..1b3f80b1f18 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql @@ -2,6 +2,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { project(fullPath: $fullPath) { pipeline(iid: $iid) { id + iid status retryable cancelable diff --git a/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql b/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql new file mode 100644 index 00000000000..1da4fa0a72b --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql @@ -0,0 +1,20 @@ +fragment PipelineStagesConnection on CiConfigStageConnection { + nodes { + name + groups { + nodes { + name + jobs { + nodes { + name + needs { + nodes { + name + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js deleted file mode 100644 index 2dbaa5a5c9a..00000000000 --- a/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js +++ /dev/null @@ -1,50 +0,0 @@ -import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; -import { LAYOUT_CHANGE_DELAY } from '~/pipelines/constants'; - -export default { - debouncedResize: null, - sidebarMutationObserver: null, - data() { - return { - graphLeftPadding: 0, - graphRightPadding: 0, - }; - }, - beforeDestroy() { - window.removeEventListener('resize', this.$options.debouncedResize); - - if (this.$options.sidebarMutationObserver) { - this.$options.sidebarMutationObserver.disconnect(); - } - }, - created() { - this.$options.debouncedResize = debounceByAnimationFrame(this.setGraphPadding); - window.addEventListener('resize', this.$options.debouncedResize); - }, - mounted() { - this.setGraphPadding(); - - this.$options.sidebarMutationObserver = new MutationObserver(this.handleLayoutChange); - this.$options.sidebarMutationObserver.observe(document.querySelector('.layout-page'), { - attributes: true, - childList: false, - subtree: false, - }); - }, - methods: { - setGraphPadding() { - // only add padding to main graph (not inline upstream/downstream graphs) - if (this.type && this.type !== 'main') return; - - const container = document.querySelector('.js-pipeline-container'); - if (!container) return; - - this.graphLeftPadding = container.offsetLeft; - this.graphRightPadding = window.innerWidth - container.offsetLeft - container.offsetWidth; - }, - handleLayoutChange() { - // wait until animations finish, then recalculate padding - window.setTimeout(this.setGraphPadding, LAYOUT_CHANGE_DELAY); - }, - }, -}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 29dec2309a7..27f71d2b878 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,7 +3,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; -import pipelineGraph from './components/graph/graph_component.vue'; +import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; import createDagApp from './pipeline_details_dag'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import legacyPipelineHeader from './components/legacy_header_component.vue'; @@ -28,7 +28,7 @@ const createLegacyPipelinesDetailApp = mediator => { new Vue({ el: SELECTORS.PIPELINE_GRAPH, components: { - pipelineGraph, + PipelineGraphLegacy, }, mixins: [GraphBundleMixin], data() { @@ -37,7 +37,7 @@ const createLegacyPipelinesDetailApp = mediator => { }; }, render(createElement) { - return createElement('pipeline-graph', { + return createElement('pipeline-graph-legacy', { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, @@ -149,7 +149,9 @@ export default async function() { const { createPipelinesDetailApp } = await import( /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph' ); - createPipelinesDetailApp(); + + const { pipelineProjectPath, pipelineIid } = dataset; + createPipelinesDetailApp(SELECTORS.PIPELINE_DETAILS, pipelineProjectPath, pipelineIid); } catch { Flash(__('An error occurred while loading the pipeline.')); } diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js index 880855cf21d..1b296c305cb 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_graph.js +++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js @@ -1,7 +1,37 @@ -const createPipelinesDetailApp = () => { - // Placeholder. See: https://gitlab.com/gitlab-org/gitlab/-/issues/223262 - // eslint-disable-next-line no-useless-return - return; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue'; +import { GRAPHQL } from './components/graph/constants'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + batchMax: 2, + }, + ), +}); + +const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => { + // eslint-disable-next-line no-new + new Vue({ + el: selector, + components: { + PipelineGraphWrapper, + }, + apolloProvider, + provide: { + pipelineProjectPath, + pipelineIid, + dataMethod: GRAPHQL, + }, + render(createElement) { + return createElement(PipelineGraphWrapper); + }, + }); }; export { createPipelinesDetailApp }; diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js new file mode 100644 index 00000000000..4575a99f60f --- /dev/null +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -0,0 +1,75 @@ +import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { + parseBoolean, + historyReplaceState, + buildUrlWithCurrentLocation, +} from '~/lib/utils/common_utils'; +import Translate from '~/vue_shared/translate'; +import Pipelines from './components/pipelines_list/pipelines.vue'; +import PipelinesStore from './stores/pipelines_store'; + +Vue.use(Translate); +Vue.use(GlToast); + +export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { + const el = document.querySelector(selector); + if (!el) { + return null; + } + + const { + endpoint, + pipelineScheduleUrl, + helpPagePath, + emptyStateSvgPath, + errorStateSvgPath, + noPipelinesSvgPath, + autoDevopsHelpPath, + newPipelinePath, + canCreatePipeline, + hasGitlabCi, + ciLintPath, + resetCachePath, + projectId, + params, + } = el.dataset; + + return new Vue({ + el, + data() { + return { + store: new PipelinesStore(), + }; + }, + created() { + if (doesHashExistInUrl('delete_success')) { + this.$toast.show(__('The pipeline has been deleted')); + historyReplaceState(buildUrlWithCurrentLocation()); + } + }, + render(createElement) { + return createElement(Pipelines, { + props: { + store: this.store, + endpoint, + pipelineScheduleUrl, + helpPagePath, + emptyStateSvgPath, + errorStateSvgPath, + noPipelinesSvgPath, + autoDevopsHelpPath, + newPipelinePath, + canCreatePipeline: parseBoolean(canCreatePipeline), + hasGitlabCi: parseBoolean(hasGitlabCi), + ciLintPath, + resetCachePath, + projectId, + params: JSON.parse(params), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index f10bbeec77c..3c664457756 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -47,6 +47,7 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => { }); }; +export const setPage = ({ commit }, page) => commit(types.SET_PAGE, page); export const setSelectedSuiteIndex = ({ commit }, data) => commit(types.SET_SELECTED_SUITE_INDEX, data); export const removeSelectedSuiteIndex = ({ commit }) => diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js index c123014756d..56f769c00fa 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -14,5 +14,10 @@ export const getSelectedSuite = state => export const getSuiteTests = state => { const { test_cases: testCases = [] } = getSelectedSuite(state); - return testCases.map(addIconStatus); + const { page, perPage } = state.pageInfo; + const start = (page - 1) * perPage; + + return testCases.map(addIconStatus).slice(start, start + perPage); }; + +export const getSuiteTestCount = state => getSelectedSuite(state)?.test_cases?.length || 0; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js index 52345888cb0..803f6bf60b1 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -1,3 +1,4 @@ +export const SET_PAGE = 'SET_PAGE'; export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX'; export const SET_SUMMARY = 'SET_SUMMARY'; export const SET_SUITE = 'SET_SUITE'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js index 3652a12a6ba..cf0bf8483dd 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -1,6 +1,14 @@ import * as types from './mutation_types'; export default { + [types.SET_PAGE](state, page) { + Object.assign(state, { + pageInfo: Object.assign(state.pageInfo, { + page, + }), + }); + }, + [types.SET_SUITE](state, { suite = {}, index = null }) { state.testReports.test_suites[index] = { ...suite, hasFullSuite: true }; }, diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js index af79521d68a..7f5da549a9d 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/state.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -4,4 +4,8 @@ export default ({ summaryEndpoint = '', suiteEndpoint = '' }) => ({ testReports: {}, selectedSuiteIndex: null, isLoading: false, + pageInfo: { + page: 1, + perPage: 20, + }, }); diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 7d1a1762e0d..28d6c0edb0f 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -5,66 +5,42 @@ export const validateParams = params => { return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); }; -export const createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`; +export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`; /** - * This function takes a json payload that comes from a yml - * file converted to json through `jsyaml` library. Because we - * naively convert the entire yaml to json, some keys (like `includes`) - * are irrelevant to rendering the graph and must be removed. We also - * restructure the data to have the structure from an API response for the - * pipeline data. - * @param {Object} jsonData - * @returns {Array} - Array of stages containing all jobs + * This function takes the stages array and transform it + * into a hash where each key is a job name and the job data + * is associated to that key. + * @param {Array} stages + * @returns {Object} - Hash of jobs */ -export const preparePipelineGraphData = jsonData => { - const jsonKeys = Object.keys(jsonData); - const jobNames = jsonKeys.filter(job => jsonData[job]?.stage); - // Creates an object with only the valid jobs - const jobs = jsonKeys.reduce((acc, val) => { - if (jobNames.includes(val)) { - return { - ...acc, - [val]: { ...jsonData[val], id: createUniqueJobId(jsonData[val].stage, val) }, - }; - } - return { ...acc }; - }, {}); - - // We merge both the stages from the "stages" key in the yaml and the stage associated - // with each job to show the user both the stages they explicitly defined, and those - // that they added under jobs. We also remove duplicates. - const jobStages = jobNames.map(job => jsonData[job].stage); - const userDefinedStages = jsonData?.stages ?? []; - - // The order is important here. We always show the stages in order they were - // defined in the `stages` key first, and then stages that are under the jobs. - const stages = Array.from(new Set([...userDefinedStages, ...jobStages])); - - const arrayOfJobsByStage = stages.map(val => { - return jobNames.filter(job => { - return jsonData[job].stage === val; - }); - }); +export const createJobsHash = (stages = []) => { + const jobsHash = {}; - const pipelineData = stages.map((stage, index) => { - const stageJobs = arrayOfJobsByStage[index]; - return { - name: stage, - groups: stageJobs.map(job => { - return { - name: job, - jobs: [{ ...jsonData[job] }], - id: createUniqueJobId(stage, job), - }; - }), - }; + stages.forEach(stage => { + if (stage.groups.length > 0) { + stage.groups.forEach(group => { + group.jobs.forEach(job => { + jobsHash[job.name] = job; + }); + }); + } }); - return { stages: pipelineData, jobs }; + return jobsHash; }; -export const generateJobNeedsDict = ({ jobs }) => { +/** + * This function takes the jobs hash generated by + * `createJobsHash` function and returns an easier + * structure to work with for needs relationship + * where the key is the job name and the value is an + * array of all the needs this job has recursively + * (includes the needs of the needs) + * @param {Object} jobs + * @returns {Object} - Hash of jobs and array of needs + */ +export const generateJobNeedsDict = (jobs = {}) => { const arrOfJobNames = Object.keys(jobs); return arrOfJobNames.reduce((acc, value) => { @@ -75,13 +51,12 @@ export const generateJobNeedsDict = ({ jobs }) => { return jobs[jobName].needs .map(job => { - const { id } = jobs[job]; // If we already have the needs of a job in the accumulator, // then we use the memoized data instead of the recursive call // to save some performance. - const newNeeds = acc[id] ?? recursiveNeeds(job); + const newNeeds = acc[job] ?? recursiveNeeds(job); - return [id, ...newNeeds]; + return [job, ...newNeeds]; }) .flat(Infinity); }; @@ -91,6 +66,6 @@ export const generateJobNeedsDict = ({ jobs }) => { // duplicates from the array. const uniqueValues = Array.from(new Set(recursiveNeeds(value))); - return { ...acc, [jobs[value].id]: uniqueValues }; + return { ...acc, [value]: uniqueValues }; }, {}); }; diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index 2f35c4485f9..0e12c219e45 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -55,6 +55,7 @@ export default class ProjectFindFile { } initEvent() { + // eslint-disable-next-line @gitlab/no-global-event-off this.inputElement.off('keyup'); this.inputElement.on('keyup', event => { const target = $(event.target); diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index db2b0856e1b..f7d823802b6 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -4,110 +4,116 @@ import $ from 'jquery'; import Api from './api'; import ProjectSelectComboButton from './project_select_combo_button'; import { s__ } from './locale'; +import { loadCSSFile } from './lib/utils/css_utils'; const projectSelect = () => { - $('.ajax-project-select').each(function(i, select) { - let placeholder; - const simpleFilter = $(select).data('simpleFilter') || false; - const isInstantiated = $(select).data('select2'); - this.groupId = $(select).data('groupId'); - this.userId = $(select).data('userId'); - this.includeGroups = $(select).data('includeGroups'); - this.allProjects = $(select).data('allProjects') || false; - this.orderBy = $(select).data('orderBy') || 'id'; - this.withIssuesEnabled = $(select).data('withIssuesEnabled'); - this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); - this.withShared = - $(select).data('withShared') === undefined ? true : $(select).data('withShared'); - this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; - this.allowClear = $(select).data('allowClear') || false; + loadCSSFile(gon.select2_css_path) + .then(() => { + $('.ajax-project-select').each(function(i, select) { + let placeholder; + const simpleFilter = $(select).data('simpleFilter') || false; + const isInstantiated = $(select).data('select2'); + this.groupId = $(select).data('groupId'); + this.userId = $(select).data('userId'); + this.includeGroups = $(select).data('includeGroups'); + this.allProjects = $(select).data('allProjects') || false; + this.orderBy = $(select).data('orderBy') || 'id'; + this.withIssuesEnabled = $(select).data('withIssuesEnabled'); + this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); + this.withShared = + $(select).data('withShared') === undefined ? true : $(select).data('withShared'); + this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; + this.allowClear = $(select).data('allowClear') || false; - placeholder = s__('ProjectSelect|Search for project'); - if (this.includeGroups) { - placeholder += s__('ProjectSelect| or group'); - } - - $(select).select2({ - placeholder, - minimumInputLength: 0, - query: query => { - let projectsCallback; - const finalCallback = function(projects) { - const data = { - results: projects, - }; - return query.callback(data); - }; + placeholder = s__('ProjectSelect|Search for project'); if (this.includeGroups) { - projectsCallback = function(projects) { - const groupsCallback = function(groups) { - const data = groups.concat(projects); - return finalCallback(data); - }; - return Api.groups(query.term, {}, groupsCallback); - }; - } else { - projectsCallback = finalCallback; - } - if (this.groupId) { - return Api.groupProjects( - this.groupId, - query.term, - { - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - with_shared: this.withShared, - include_subgroups: this.includeProjectsInSubgroups, - order_by: 'similarity', - }, - projectsCallback, - ); - } else if (this.userId) { - return Api.userProjects( - this.userId, - query.term, - { - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - with_shared: this.withShared, - include_subgroups: this.includeProjectsInSubgroups, - }, - projectsCallback, - ); + placeholder += s__('ProjectSelect| or group'); } - return Api.projects( - query.term, - { - order_by: this.orderBy, - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - membership: !this.allProjects, + + $(select).select2({ + placeholder, + minimumInputLength: 0, + query: query => { + let projectsCallback; + const finalCallback = function(projects) { + const data = { + results: projects, + }; + return query.callback(data); + }; + if (this.includeGroups) { + projectsCallback = function(projects) { + const groupsCallback = function(groups) { + const data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(query.term, {}, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (this.groupId) { + return Api.groupProjects( + this.groupId, + query.term, + { + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + with_shared: this.withShared, + include_subgroups: this.includeProjectsInSubgroups, + order_by: 'similarity', + }, + projectsCallback, + ); + } else if (this.userId) { + return Api.userProjects( + this.userId, + query.term, + { + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + with_shared: this.withShared, + include_subgroups: this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } + return Api.projects( + query.term, + { + order_by: this.orderBy, + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + membership: !this.allProjects, + }, + projectsCallback, + ); + }, + id(project) { + if (simpleFilter) return project.id; + return JSON.stringify({ + name: project.name, + url: project.web_url, + }); + }, + text(project) { + return project.name_with_namespace || project.name; }, - projectsCallback, - ); - }, - id(project) { - if (simpleFilter) return project.id; - return JSON.stringify({ - name: project.name, - url: project.web_url, - }); - }, - text(project) { - return project.name_with_namespace || project.name; - }, - initSelection(el, callback) { - return Api.project(el.val()).then(({ data }) => callback(data)); - }, + initSelection(el, callback) { + // eslint-disable-next-line promise/no-nesting + return Api.project(el.val()).then(({ data }) => callback(data)); + }, - allowClear: this.allowClear, + allowClear: this.allowClear, - dropdownCssClass: 'ajax-project-dropdown', - }); - if (isInstantiated || simpleFilter) return select; - return new ProjectSelectComboButton(select); - }); + dropdownCssClass: 'ajax-project-dropdown', + }); + if (isInstantiated || simpleFilter) return select; + return new ProjectSelectComboButton(select); + }); + }) + .catch(() => {}); }; export default () => { diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js index d3b5f532dc1..865dd23bd80 100644 --- a/app/assets/javascripts/project_select_combo_button.js +++ b/app/assets/javascripts/project_select_combo_button.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import AccessorUtilities from './lib/utils/accessor'; +import { loadCSSFile } from './lib/utils/css_utils'; export default class ProjectSelectComboButton { constructor(select) { @@ -46,9 +47,14 @@ export default class ProjectSelectComboButton { openDropdown(event) { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - $(event.currentTarget) - .siblings('.project-item-select') - .select2('open'); + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + $(event.currentTarget) + .siblings('.project-item-select') + .select2('open'); + }) + .catch(() => {}); }) .catch(() => {}); } diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index a6019e9c01b..bc3b29cde0a 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -1,6 +1,10 @@ import { s__ } from '~/locale'; export default { + sample: { + text: s__('ProjectTemplates|Sample GitLab Project'), + icon: '.template-option .icon-sample', + }, rails: { text: s__('ProjectTemplates|Ruby on Rails'), icon: '.template-option .icon-rails', diff --git a/app/assets/javascripts/projects/default_sample_data_templates.js b/app/assets/javascripts/projects/default_sample_data_templates.js deleted file mode 100644 index 7c45e7ac62f..00000000000 --- a/app/assets/javascripts/projects/default_sample_data_templates.js +++ /dev/null @@ -1,12 +0,0 @@ -import { s__ } from '~/locale'; - -export default { - basic: { - text: s__('ProjectTemplates|Basic'), - icon: '.template-option .icon-basic', - }, - serenity_valley: { - text: s__('ProjectTemplates|Serenity Valley'), - icon: '.template-option .icon-serenity_valley', - }, -}; diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue index f404e6030f4..2e16071e563 100644 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue @@ -12,6 +12,7 @@ import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg'; const BLANK_PANEL = 'blank_project'; const CI_CD_PANEL = 'cicd_for_external_repo'; +const LAST_ACTIVE_TAB_KEY = 'new_project_last_active_tab'; const PANELS = [ { name: BLANK_PANEL, @@ -105,7 +106,7 @@ export default { this.handleLocationHashChange(); if (this.hasErrors) { - this.activeTab = BLANK_PANEL; + this.activeTab = localStorage.getItem(LAST_ACTIVE_TAB_KEY) || BLANK_PANEL; } window.addEventListener('hashchange', () => { @@ -127,6 +128,9 @@ export default { handleLocationHashChange() { this.activeTab = window.location.hash.substring(1) || null; + if (this.activeTab) { + localStorage.setItem(LAST_ACTIVE_TAB_KEY, this.activeTab); + } }, }, diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index c6e2b2e1140..4bf837faed1 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -1,66 +1,203 @@ <script> import dateFormat from 'dateformat'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; -import { __, sprintf } from '~/locale'; +import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import { getDateInPast } from '~/lib/utils/datetime_utility'; +import getPipelineCountByStatus from '../graphql/queries/get_pipeline_count_by_status.query.graphql'; +import getProjectPipelineStatistics from '../graphql/queries/get_project_pipeline_statistics.query.graphql'; import StatisticsList from './statistics_list.vue'; import PipelinesAreaChart from './pipelines_area_chart.vue'; import { CHART_CONTAINER_HEIGHT, - INNER_CHART_HEIGHT, - X_AXIS_LABEL_ROTATION, - X_AXIS_TITLE_OFFSET, CHART_DATE_FORMAT, + DEFAULT, + INNER_CHART_HEIGHT, + LOAD_ANALYTICS_FAILURE, + LOAD_PIPELINES_FAILURE, ONE_WEEK_AGO_DAYS, ONE_MONTH_AGO_DAYS, + PARSE_FAILURE, + UNSUPPORTED_DATA, + X_AXIS_LABEL_ROTATION, + X_AXIS_TITLE_OFFSET, } from '../constants'; +const defaultCountValues = { + totalPipelines: { + count: 0, + }, + successfulPipelines: { + count: 0, + }, +}; + +const defaultAnalyticsValues = { + weekPipelinesTotals: [], + weekPipelinesLabels: [], + weekPipelinesSuccessful: [], + monthPipelinesLabels: [], + monthPipelinesTotals: [], + monthPipelinesSuccessful: [], + yearPipelinesLabels: [], + yearPipelinesTotals: [], + yearPipelinesSuccessful: [], + pipelineTimesLabels: [], + pipelineTimesValues: [], +}; + export default { components: { - StatisticsList, + GlAlert, GlColumnChart, + GlSkeletonLoader, + StatisticsList, PipelinesAreaChart, }, - props: { - counts: { - type: Object, - required: true, - }, - timesChartData: { - type: Object, - required: true, - }, - lastWeekChartData: { - type: Object, - required: true, - }, - lastMonthChartData: { - type: Object, - required: true, - }, - lastYearChartData: { - type: Object, - required: true, + inject: { + projectPath: { + type: String, + default: '', }, }, data() { return { - timesChartTransformedData: [ - { - name: 'full', - data: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values), - }, - ], + counts: { + ...defaultCountValues, + }, + analytics: { + ...defaultAnalyticsValues, + }, + showFailureAlert: false, + failureType: null, }; }, + apollo: { + counts: { + query: getPipelineCountByStatus, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + return data?.project; + }, + error() { + this.reportFailure(LOAD_PIPELINES_FAILURE); + }, + }, + analytics: { + query: getProjectPipelineStatistics, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + return data?.project?.pipelineAnalytics; + }, + error() { + this.reportFailure(LOAD_ANALYTICS_FAILURE); + }, + }, + }, computed: { + failure() { + switch (this.failureType) { + case LOAD_ANALYTICS_FAILURE: + return { + text: this.$options.errorTexts[LOAD_ANALYTICS_FAILURE], + variant: 'danger', + }; + case PARSE_FAILURE: + return { + text: this.$options.errorTexts[PARSE_FAILURE], + variant: 'danger', + }; + case UNSUPPORTED_DATA: + return { + text: this.$options.errorTexts[UNSUPPORTED_DATA], + variant: 'info', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + variant: 'danger', + }; + } + }, + successRatio() { + const { successfulPipelines, failedPipelines } = this.counts; + const successfulCount = successfulPipelines?.count; + const failedCount = failedPipelines?.count; + const ratio = (successfulCount / (successfulCount + failedCount)) * 100; + + return failedCount === 0 ? 100 : ratio; + }, + formattedCounts() { + const { + totalPipelines, + successfulPipelines, + failedPipelines, + totalPipelineDuration, + } = this.counts; + + return { + total: totalPipelines?.count, + success: successfulPipelines?.count, + failed: failedPipelines?.count, + successRatio: this.successRatio, + totalDuration: totalPipelineDuration, + }; + }, areaCharts() { const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles; + let areaChartsData = []; + try { + areaChartsData = [ + this.buildAreaChartData(lastWeek, this.lastWeekChartData), + this.buildAreaChartData(lastMonth, this.lastMonthChartData), + this.buildAreaChartData(lastYear, this.lastYearChartData), + ]; + } catch { + areaChartsData = []; + this.reportFailure(PARSE_FAILURE); + } + + return areaChartsData; + }, + lastWeekChartData() { + return { + labels: this.analytics.weekPipelinesLabels, + totals: this.analytics.weekPipelinesTotals, + success: this.analytics.weekPipelinesSuccessful, + }; + }, + lastMonthChartData() { + return { + labels: this.analytics.monthPipelinesLabels, + totals: this.analytics.monthPipelinesTotals, + success: this.analytics.monthPipelinesSuccessful, + }; + }, + lastYearChartData() { + return { + labels: this.analytics.yearPipelinesLabels, + totals: this.analytics.yearPipelinesTotals, + success: this.analytics.yearPipelinesSuccessful, + }; + }, + timesChartTransformedData() { return [ - this.buildAreaChartData(lastWeek, this.lastWeekChartData), - this.buildAreaChartData(lastMonth, this.lastMonthChartData), - this.buildAreaChartData(lastYear, this.lastYearChartData), + { + name: 'full', + data: this.mergeLabelsAndValues( + this.analytics.pipelineTimesLabels, + this.analytics.pipelineTimesValues, + ), + }, ]; }, }, @@ -85,6 +222,13 @@ export default { ], }; }, + hideAlert() { + this.showFailureAlert = false; + }, + reportFailure(type) { + this.showFailureAlert = true; + this.failureType = type; + }, }, chartContainerHeight: CHART_CONTAINER_HEIGHT, timesChartOptions: { @@ -96,6 +240,16 @@ export default { nameGap: X_AXIS_TITLE_OFFSET, }, }, + errorTexts: { + [LOAD_ANALYTICS_FAILURE]: s__( + 'PipelineCharts|An error has ocurred when retrieving the analytics data', + ), + [LOAD_PIPELINES_FAILURE]: s__( + 'PipelineCharts|An error has ocurred when retrieving the pipelines data', + ), + [PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'), + [DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'), + }, get chartTitles() { const today = dateFormat(new Date(), CHART_DATE_FORMAT); const pastDate = timeScale => @@ -116,13 +270,17 @@ export default { </script> <template> <div> - <div class="mb-3"> + <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert"> + {{ failure.text }} + </gl-alert> + <div class="gl-mb-3"> <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3> </div> - <h4 class="my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4> + <h4 class="gl-my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4> <div class="row"> <div class="col-md-6"> - <statistics-list :counts="counts" /> + <gl-skeleton-loader v-if="$apollo.queries.counts.loading" :lines="5" /> + <statistics-list v-else :counts="formattedCounts" /> </div> <div class="col-md-6"> <strong> @@ -139,7 +297,7 @@ export default { </div> </div> <hr /> - <h4 class="my-4">{{ __('Pipelines charts') }}</h4> + <h4 class="gl-my-4">{{ __('Pipelines charts') }}</h4> <pipelines-area-chart v-for="(chart, index) in areaCharts" :key="index" diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue b/app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue new file mode 100644 index 00000000000..c6e2b2e1140 --- /dev/null +++ b/app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue @@ -0,0 +1,151 @@ +<script> +import dateFormat from 'dateformat'; +import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import { __, sprintf } from '~/locale'; +import { getDateInPast } from '~/lib/utils/datetime_utility'; +import StatisticsList from './statistics_list.vue'; +import PipelinesAreaChart from './pipelines_area_chart.vue'; +import { + CHART_CONTAINER_HEIGHT, + INNER_CHART_HEIGHT, + X_AXIS_LABEL_ROTATION, + X_AXIS_TITLE_OFFSET, + CHART_DATE_FORMAT, + ONE_WEEK_AGO_DAYS, + ONE_MONTH_AGO_DAYS, +} from '../constants'; + +export default { + components: { + StatisticsList, + GlColumnChart, + PipelinesAreaChart, + }, + props: { + counts: { + type: Object, + required: true, + }, + timesChartData: { + type: Object, + required: true, + }, + lastWeekChartData: { + type: Object, + required: true, + }, + lastMonthChartData: { + type: Object, + required: true, + }, + lastYearChartData: { + type: Object, + required: true, + }, + }, + data() { + return { + timesChartTransformedData: [ + { + name: 'full', + data: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values), + }, + ], + }; + }, + computed: { + areaCharts() { + const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles; + + return [ + this.buildAreaChartData(lastWeek, this.lastWeekChartData), + this.buildAreaChartData(lastMonth, this.lastMonthChartData), + this.buildAreaChartData(lastYear, this.lastYearChartData), + ]; + }, + }, + methods: { + mergeLabelsAndValues(labels, values) { + return labels.map((label, index) => [label, values[index]]); + }, + buildAreaChartData(title, data) { + const { labels, totals, success } = data; + + return { + title, + data: [ + { + name: 'all', + data: this.mergeLabelsAndValues(labels, totals), + }, + { + name: 'success', + data: this.mergeLabelsAndValues(labels, success), + }, + ], + }; + }, + }, + chartContainerHeight: CHART_CONTAINER_HEIGHT, + timesChartOptions: { + height: INNER_CHART_HEIGHT, + xAxis: { + axisLabel: { + rotate: X_AXIS_LABEL_ROTATION, + }, + nameGap: X_AXIS_TITLE_OFFSET, + }, + }, + get chartTitles() { + const today = dateFormat(new Date(), CHART_DATE_FORMAT); + const pastDate = timeScale => + dateFormat(getDateInPast(new Date(), timeScale), CHART_DATE_FORMAT); + return { + lastWeek: sprintf(__('Pipelines for last week (%{oneWeekAgo} - %{today})'), { + oneWeekAgo: pastDate(ONE_WEEK_AGO_DAYS), + today, + }), + lastMonth: sprintf(__('Pipelines for last month (%{oneMonthAgo} - %{today})'), { + oneMonthAgo: pastDate(ONE_MONTH_AGO_DAYS), + today, + }), + lastYear: __('Pipelines for last year'), + }; + }, +}; +</script> +<template> + <div> + <div class="mb-3"> + <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3> + </div> + <h4 class="my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4> + <div class="row"> + <div class="col-md-6"> + <statistics-list :counts="counts" /> + </div> + <div class="col-md-6"> + <strong> + {{ __('Duration for the last 30 commits') }} + </strong> + <gl-column-chart + :height="$options.chartContainerHeight" + :option="$options.timesChartOptions" + :bars="timesChartTransformedData" + :y-axis-title="__('Minutes')" + :x-axis-title="__('Commit')" + x-axis-type="category" + /> + </div> + </div> + <hr /> + <h4 class="my-4">{{ __('Pipelines charts') }}</h4> + <pipelines-area-chart + v-for="(chart, index) in areaCharts" + :key="index" + :chart-data="chart.data" + > + {{ chart.title }} + </pipelines-area-chart> + </div> +</template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue index aa59717ddcd..94cecd2e479 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue @@ -1,7 +1,10 @@ <script> import { formatTime } from '~/lib/utils/datetime_utility'; +import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; import { s__, n__ } from '~/locale'; +const defaultPrecision = 2; + export default { props: { counts: { @@ -14,6 +17,8 @@ export default { return formatTime(this.counts.totalDuration); }, statistics() { + const formatter = getFormatter(SUPPORTED_FORMATS.percentHundred); + return [ { title: s__('PipelineCharts|Total:'), @@ -29,7 +34,7 @@ export default { }, { title: s__('PipelineCharts|Success ratio:'), - value: `${this.counts.successRatio}%`, + value: formatter(this.counts.successRatio, defaultPrecision), }, { title: s__('PipelineCharts|Total duration:'), diff --git a/app/assets/javascripts/projects/pipelines/charts/constants.js b/app/assets/javascripts/projects/pipelines/charts/constants.js index 5dbe3c01100..079e23943c1 100644 --- a/app/assets/javascripts/projects/pipelines/charts/constants.js +++ b/app/assets/javascripts/projects/pipelines/charts/constants.js @@ -11,3 +11,9 @@ export const ONE_WEEK_AGO_DAYS = 7; export const ONE_MONTH_AGO_DAYS = 31; export const CHART_DATE_FORMAT = 'dd mmm'; + +export const DEFAULT = 'default'; +export const PARSE_FAILURE = 'parse_failure'; +export const LOAD_ANALYTICS_FAILURE = 'load_analytics_failure'; +export const LOAD_PIPELINES_FAILURE = 'load_analytics_failure'; +export const UNSUPPORTED_DATA = 'unsupported_data'; diff --git a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql new file mode 100644 index 00000000000..eb0dbf8dd16 --- /dev/null +++ b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql @@ -0,0 +1,14 @@ +query getPipelineCountByStatus($projectPath: ID!) { + project(fullPath: $projectPath) { + totalPipelines: pipelines { + count + } + successfulPipelines: pipelines(status: SUCCESS) { + count + } + failedPipelines: pipelines(status: FAILED) { + count + } + totalPipelineDuration + } +} diff --git a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql new file mode 100644 index 00000000000..18b645f8831 --- /dev/null +++ b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql @@ -0,0 +1,17 @@ +query getProjectPipelineStatistics($projectPath: ID!) { + project(fullPath: $projectPath) { + pipelineAnalytics { + weekPipelinesTotals + weekPipelinesLabels + weekPipelinesSuccessful + monthPipelinesLabels + monthPipelinesTotals + monthPipelinesSuccessful + yearPipelinesLabels + yearPipelinesTotals + yearPipelinesSuccessful + pipelineTimesLabels + pipelineTimesValues + } + } +} diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js index eef1bc2d28b..f6e79f0ab51 100644 --- a/app/assets/javascripts/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -1,8 +1,20 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import ProjectPipelinesChartsLegacy from './components/app_legacy.vue'; import ProjectPipelinesCharts from './components/app.vue'; -export default () => { - const el = document.querySelector('#js-project-pipelines-charts-app'); +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +const mountPipelineChartsApp = el => { + // Not all of the values will be defined since some them will be + // empty depending on the value of the graphql_pipeline_analytics + // feature flag, once the rollout of the feature flag is completed + // the undefined values will be deleted const { countsFailed, countsSuccess, @@ -20,22 +32,48 @@ export default () => { lastYearChartLabels, lastYearChartTotals, lastYearChartSuccess, + projectPath, } = el.dataset; - const parseAreaChartData = (labels, totals, success) => ({ - labels: JSON.parse(labels), - totals: JSON.parse(totals), - success: JSON.parse(success), - }); + const parseAreaChartData = (labels, totals, success) => { + let parsedData = {}; + + try { + parsedData = { + labels: JSON.parse(labels), + totals: JSON.parse(totals), + success: JSON.parse(success), + }; + } catch { + parsedData = {}; + } + + return parsedData; + }; + + if (gon?.features?.graphqlPipelineAnalytics) { + return new Vue({ + el, + name: 'ProjectPipelinesChartsApp', + components: { + ProjectPipelinesCharts, + }, + apolloProvider, + provide: { + projectPath, + }, + render: createElement => createElement(ProjectPipelinesCharts, {}), + }); + } return new Vue({ el, - name: 'ProjectPipelinesChartsApp', + name: 'ProjectPipelinesChartsAppLegacy', components: { - ProjectPipelinesCharts, + ProjectPipelinesChartsLegacy, }, render: createElement => - createElement(ProjectPipelinesCharts, { + createElement(ProjectPipelinesChartsLegacy, { props: { counts: { failed: countsFailed, @@ -67,3 +105,8 @@ export default () => { }), }); }; + +export default () => { + const el = document.querySelector('#js-project-pipelines-charts-app'); + return !el ? {} : mountPipelineChartsApp(el); +}; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index d74a2d06786..d54a48cc444 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; -import DEFAULT_SAMPLE_DATA_TEMPLATES from '~/projects/default_sample_data_templates'; import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; import { convertToTitleCase, @@ -26,12 +25,14 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr }; const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { + // eslint-disable-next-line @gitlab/no-global-event-off $projectNameInput.off('keyup change').on('keyup change', () => { onProjectNameChange($projectNameInput, $projectPathInput); hasUserDefinedProjectName = $projectNameInput.val().trim().length > 0; hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0; }); + // eslint-disable-next-line @gitlab/no-global-event-off $projectPathInput.off('keyup change').on('keyup change', () => { onProjectPathChange($projectNameInput, $projectPathInput, hasUserDefinedProjectName); hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0; @@ -137,6 +138,7 @@ const bindEvents = () => { target.focus(); }) .on('hide.bs.popover', () => { + // eslint-disable-next-line @gitlab/no-global-event-off $(document).off('click.popover touchstart.popover'); }); } @@ -147,8 +149,7 @@ const bindEvents = () => { $selectedIcon.empty(); const value = $(this).val(); - const selectedTemplate = - DEFAULT_PROJECT_TEMPLATES[value] || DEFAULT_SAMPLE_DATA_TEMPLATES[value]; + const selectedTemplate = DEFAULT_PROJECT_TEMPLATES[value]; $selectedTemplateText.text(selectedTemplate.text); $(selectedTemplate.icon) .clone() diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index 3ca5bca4bf2..cb4fd5265da 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -48,11 +48,12 @@ export default class AccessDropdown { clicked: options => { const { $el, e } = options; const item = options.selectedObj; + const fossWithMergeAccess = !this.hasLicense && this.accessLevel === ACCESS_LEVELS.MERGE; e.preventDefault(); - if (!this.hasLicense) { - // We're not multiselecting quite yet with FOSS: + if (fossWithMergeAccess) { + // We're not multiselecting quite yet in "Merge" access dropdown, on FOSS: // remove all preselected items before selecting this item // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37499 this.accessLevelsData.forEach(level => { @@ -62,7 +63,7 @@ export default class AccessDropdown { if ($el.is('.is-active')) { if (this.noOneObj) { - if (item.id === this.noOneObj.id && this.hasLicense) { + if (item.id === this.noOneObj.id && !fossWithMergeAccess) { // remove all others selected items this.accessLevelsData.forEach(level => { if (level.id !== item.id) { diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue new file mode 100644 index 00000000000..a4924033c1e --- /dev/null +++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue @@ -0,0 +1,79 @@ +<script> +import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; + +const DEFAULT_ERROR_MESSAGE = __('An error occurred while updating the configuration.'); + +export default { + components: { + GlAlert, + GlToggle, + GlTooltip, + }, + props: { + isDisabledAndUnoverridable: { + type: Boolean, + required: true, + }, + isEnabled: { + type: Boolean, + required: true, + }, + updatePath: { + type: String, + required: true, + }, + }, + data() { + return { + isLoading: false, + isSharedRunnerEnabled: false, + errorMessage: null, + }; + }, + created() { + this.isSharedRunnerEnabled = this.isEnabled; + }, + methods: { + toggleSharedRunners() { + this.isLoading = true; + this.errorMessage = null; + + axios + .post(this.updatePath) + .then(() => { + this.isLoading = false; + this.isSharedRunnerEnabled = !this.isSharedRunnerEnabled; + }) + .catch(error => { + this.isLoading = false; + this.errorMessage = error.response?.data?.error || DEFAULT_ERROR_MESSAGE; + }); + }, + }, +}; +</script> + +<template> + <div> + <section class="gl-mt-5"> + <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> + <gl-tooltip v-if="isDisabledAndUnoverridable" :target="() => $refs.sharedRunnersToggle"> + {{ __('Shared runners are disabled on group level') }} + </gl-tooltip> + </section> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js new file mode 100644 index 00000000000..c5d45fe6fed --- /dev/null +++ b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import SharedRunnersToggle from '~/projects/settings/components/shared_runners_toggle.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default (containerId = 'toggle-shared-runners-form') => { + const containerEl = document.getElementById(containerId); + const { isDisabledAndUnoverridable, isEnabled, updatePath } = containerEl.dataset; + + return new Vue({ + el: containerEl, + render(createElement) { + return createElement(SharedRunnersToggle, { + props: { + isDisabledAndUnoverridable: parseBoolean(isDisabledAndUnoverridable), + isEnabled: parseBoolean(isEnabled), + 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 df7d9b56aed..a07c57c42cb 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 @@ -30,6 +30,10 @@ export default { required: false, default: '', }, + customEmailEnabled: { + type: Boolean, + required: false, + }, selectedTemplate: { type: String, required: false, @@ -140,6 +144,7 @@ export default { :is-enabled="isEnabled" :incoming-email="incomingEmail" :custom-email="updatedCustomEmail" + :custom-email-enabled="customEmailEnabled" :initial-selected-template="selectedTemplate" :initial-outgoing-name="outgoingName" :initial-project-key="projectKey" 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 5d120fd0b3f..2896cb491b5 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 @@ -31,6 +31,10 @@ export default { required: false, default: '', }, + customEmailEnabled: { + type: Boolean, + required: false, + }, initialSelectedTemplate: { type: String, required: false, @@ -69,7 +73,7 @@ export default { return [''].concat(this.templates); }, hasProjectKeySupport() { - return Boolean(this.glFeatures.serviceDeskCustomAddress); + return Boolean(this.customEmailEnabled); }, email() { return this.customEmail || this.incomingEmail; diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index c73163788ef..8f9828dd73d 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -18,6 +18,7 @@ export default () => { endpoint: dataset.endpoint, incomingEmail: dataset.incomingEmail, customEmail: dataset.customEmail, + customEmailEnabled: parseBoolean(dataset.customEmailEnabled), selectedTemplate: dataset.selectedTemplate, outgoingName: dataset.outgoingName, projectKey: dataset.projectKey, @@ -31,6 +32,7 @@ export default () => { endpoint: this.endpoint, incomingEmail: this.incomingEmail, customEmail: this.customEmail, + customEmailEnabled: this.customEmailEnabled, selectedTemplate: this.selectedTemplate, outgoingName: this.outgoingName, projectKey: this.projectKey, diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue index ff613daf7fa..3eeb7b29386 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue @@ -1,15 +1,29 @@ <script> import { GlSprintf } from '@gitlab/ui'; +import { sprintf } from '~/locale'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; -import { DETAILS_PAGE_TITLE } from '../../constants/index'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { DETAILS_PAGE_TITLE, UPDATED_AT } from '../../constants/index'; export default { - components: { GlSprintf, TitleArea }, + components: { GlSprintf, TitleArea, MetadataItem }, + mixins: [timeagoMixin], props: { - imageName: { - type: String, - required: false, - default: '', + image: { + type: Object, + required: true, + }, + }, + computed: { + visibilityIcon() { + return this.image?.project?.visibility === 'public' ? 'eye' : 'eye-slash'; + }, + timeAgo() { + return this.timeFormatted(this.image.updatedAt); + }, + updatedText() { + return sprintf(UPDATED_AT, { time: this.timeAgo }); }, }, i18n: { @@ -23,9 +37,17 @@ export default { <template #title> <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> <template #imageName> - {{ imageName }} + {{ image.name }} </template> </gl-sprintf> </template> + <template #metadata-updated> + <metadata-item + :icon="visibilityIcon" + :text="updatedText" + size="xl" + data-testid="updated-and-visibility" + /> + </template> </title-area> </template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue index 2844b4ffde3..ad39a898e7b 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue @@ -34,7 +34,7 @@ export default { return this.tags.some(tag => this.selectedItems[tag.name]); }, showMultiDeleteButton() { - return this.tags.some(tag => tag.destroy_path) && !this.isMobile; + return this.tags.some(tag => tag.canDelete) && !this.isMobile; }, }, methods: { diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue index 2edeac1144f..5aeafd318aa 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -63,7 +63,7 @@ export default { }, computed: { formattedSize() { - return this.tag.total_size ? numberToHumanSize(this.tag.total_size) : NOT_AVAILABLE_SIZE; + return this.tag.totalSize ? numberToHumanSize(this.tag.totalSize) : NOT_AVAILABLE_SIZE; }, layers() { return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : ''; @@ -76,10 +76,10 @@ export default { return this.tag.digest?.substring(7, 14) ?? NOT_AVAILABLE_TEXT; }, publishedDate() { - return formatDate(this.tag.created_at, 'isoDate'); + return formatDate(this.tag.createdAt, 'isoDate'); }, publishedTime() { - return formatDate(this.tag.created_at, 'hh:MM Z'); + return formatDate(this.tag.createdAt, 'hh:MM Z'); }, formattedRevision() { // to be removed when API response is adjusted @@ -101,7 +101,7 @@ export default { <list-item v-bind="$attrs" :selected="selected"> <template #left-action> <gl-form-checkbox - v-if="Boolean(tag.destroy_path)" + v-if="tag.canDelete" :disabled="invalidTag" class="gl-m-0" :checked="selected" @@ -148,7 +148,7 @@ export default { <span data-testid="time"> <gl-sprintf :message="$options.i18n.CREATED_AT_LABEL"> <template #timeInfo> - <time-ago-tooltip :time="tag.created_at" /> + <time-ago-tooltip :time="tag.createdAt" /> </template> </gl-sprintf> </span> @@ -162,10 +162,10 @@ export default { </template> <template #right-action> <delete-button - :disabled="!tag.destroy_path || invalidTag" + :disabled="!tag.canDelete || invalidTag" :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP" - :tooltip-disabled="Boolean(tag.destroy_path)" + :tooltip-disabled="tag.canDelete" data-testid="single-delete-button" @delete="$emit('delete')" /> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue index ba55822f0ca..319666210d6 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue @@ -1,6 +1,5 @@ <script> import { GlDropdown } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; import Tracking from '~/tracking'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; import { @@ -20,6 +19,7 @@ export default { GlDropdown, CodeInstruction, }, + inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], mixins: [Tracking.mixin({ label: trackingLabel })], trackingLabel, i18n: { @@ -31,9 +31,6 @@ export default { PUSH_COMMAND_LABEL, COPY_PUSH_TITLE, }, - computed: { - ...mapGetters(['dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand']), - }, }; </script> <template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue index 80cc392f86a..26e9fee63af 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue @@ -1,17 +1,14 @@ <script> import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; -import { mapState } from 'vuex'; export default { name: 'GroupEmptyState', + inject: ['config'], components: { GlEmptyState, GlSprintf, GlLink, }, - computed: { - ...mapState(['config']), - }, }; </script> <template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue index d1b9894da0e..f8b3233438f 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue @@ -1,11 +1,11 @@ <script> -import { GlPagination } from '@gitlab/ui'; +import { GlKeysetPagination } from '@gitlab/ui'; import ImageListRow from './image_list_row.vue'; export default { name: 'ImageList', components: { - GlPagination, + GlKeysetPagination, ImageListRow, }, props: { @@ -13,19 +13,14 @@ export default { type: Array, required: true, }, - pagination: { + pageInfo: { type: Object, required: true, }, }, computed: { - currentPage: { - get() { - return this.pagination.page; - }, - set(page) { - this.$emit('pageChange', page); - }, + showPagination() { + return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; }, }, }; @@ -40,13 +35,15 @@ export default { :first="index === 0" @delete="$emit('delete', $event)" /> - - <gl-pagination - v-model="currentPage" - :per-page="pagination.perPage" - :total-items="pagination.total" - align="center" - class="w-100 gl-mt-3" - /> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + :has-next-page="pageInfo.hasNextPage" + :has-previous-page="pageInfo.hasPreviousPage" + class="gl-mt-3" + @prev="$emit('prev-page')" + @next="$emit('next-page')" + /> + </div> </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 b0a7c4824bd..3fe61dc231a 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 @@ -1,6 +1,8 @@ <script> import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui'; import { n__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import DeleteButton from '../delete_button.vue'; @@ -11,6 +13,8 @@ import { REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, CLEANUP_TIMED_OUT_ERROR_MESSAGE, + IMAGE_DELETE_SCHEDULED_STATUS, + IMAGE_FAILED_DELETED_STATUS, } from '../../constants/index'; export default { @@ -38,19 +42,29 @@ export default { }, computed: { disabledDelete() { - return !this.item.destroy_path || this.item.deleting; + return !this.item.canDelete || this.deleting; + }, + id() { + return getIdFromGraphQLId(this.item.id); + }, + deleting() { + return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS; + }, + failedDelete() { + return this.item.status === IMAGE_FAILED_DELETED_STATUS; }, tagsCountText() { return n__( 'ContainerRegistry|%{count} Tag', 'ContainerRegistry|%{count} Tags', - this.item.tags_count, + this.item.tagsCount, ); }, warningIconText() { - if (this.item.failedDelete) { + if (this.failedDelete) { return ASYNC_DELETE_IMAGE_ERROR_MESSAGE; - } else if (this.item.cleanup_policy_started_at) { + } + if (this.item.expirationPolicyStartedAt) { return CLEANUP_TIMED_OUT_ERROR_MESSAGE; } return null; @@ -63,23 +77,23 @@ export default { <list-item v-gl-tooltip="{ placement: 'left', - disabled: !item.deleting, + disabled: !deleting, title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, }" v-bind="$attrs" - :disabled="item.deleting" + :disabled="deleting" > <template #left-primary> <router-link class="gl-text-body gl-font-weight-bold" data-testid="details-link" - :to="{ name: 'details', params: { id: item.id } }" + :to="{ name: 'details', params: { id } }" > {{ item.path }} </router-link> <clipboard-button v-if="item.location" - :disabled="item.deleting" + :disabled="deleting" :text="item.location" :title="item.location" category="tertiary" @@ -97,7 +111,7 @@ export default { <gl-icon name="tag" class="gl-mr-2" /> <gl-sprintf :message="tagsCountText"> <template #count> - {{ item.tags_count }} + {{ item.tagsCount }} </template> </gl-sprintf> </span> @@ -106,7 +120,7 @@ export default { <delete-button :title="$options.i18n.REMOVE_REPOSITORY_LABEL" :disabled="disabledDelete" - :tooltip-disabled="Boolean(item.destroy_path)" + :tooltip-disabled="item.canDelete" :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" @delete="$emit('delete', item)" /> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue index 35eb0b11e40..5308b025cc0 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue @@ -1,6 +1,5 @@ <script> import { GlEmptyState, GlSprintf, GlLink, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; -import { mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { @@ -20,6 +19,7 @@ export default { GlFormInputGroup, GlFormInput, }, + inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], i18n: { quickStart: QUICK_START, copyLoginTitle: COPY_LOGIN_TITLE, @@ -35,10 +35,6 @@ export default { 'ContainerRegistry|You can add an image to this registry with the following commands:', ), }, - computed: { - ...mapState(['config']), - ...mapGetters(['dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand']), - }, }; </script> <template> diff --git a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue index 666d8b042da..1cedcc41b2b 100644 --- a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue +++ b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue @@ -1,9 +1,11 @@ <script> +/* eslint-disable vue/no-v-html */ +// We are forced to use `v-html` untill this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079 +// then we can re-write this to use gl-breadcrumb import { initial, first, last } from 'lodash'; -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { sanitize } from '~/lib/dompurify'; export default { - directives: { SafeHtml }, props: { crumbs: { type: Array, @@ -11,6 +13,9 @@ export default { }, }, computed: { + parsedCrumbs() { + return this.crumbs.map(c => ({ ...c, innerHTML: sanitize(c.innerHTML) })); + }, rootRoute() { return this.$router.options.routes.find(r => r.meta.root); }, @@ -18,11 +23,11 @@ export default { return this.$route.name === this.rootRoute.name; }, rootCrumbs() { - return initial(this.crumbs); + return initial(this.parsedCrumbs); }, divider() { const { classList, tagName, innerHTML } = first(this.crumbs).querySelector('svg'); - return { classList: [...classList], tagName, innerHTML }; + return { classList: [...classList], tagName, innerHTML: sanitize(innerHTML) }; }, lastCrumb() { const { children } = last(this.crumbs); @@ -30,7 +35,7 @@ export default { return { tagName, className, - text: this.$route.meta.nameGenerator(this.$store.state), + text: this.$route.meta.nameGenerator(), path: { to: this.$route.name }, }; }, @@ -43,14 +48,14 @@ export default { <li v-for="(crumb, index) in rootCrumbs" :key="index" - v-safe-html="crumb.innerHTML" :class="crumb.className" + v-html="crumb.innerHTML" ></li> <li v-if="!isRootRoute"> <router-link ref="rootRouteLink" :to="rootRoute.path"> - {{ rootRoute.meta.nameGenerator($store.state) }} + {{ rootRoute.meta.nameGenerator() }} </router-link> - <component :is="divider.tagName" v-safe-html="divider.innerHTML" :class="divider.classList" /> + <component :is="divider.tagName" :class="divider.classList" v-html="divider.innerHTML" /> </li> <li> <component :is="lastCrumb.tagName" ref="lastCrumb" :class="lastCrumb.className"> diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index 306e6903a4f..1babaaa93da 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -56,6 +56,8 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( 'ContainerRegistry|Invalid tag: missing manifest digest', ); +export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}'); + export const NOT_AVAILABLE_TEXT = __('N/A'); export const NOT_AVAILABLE_SIZE = __('0 bytes'); // Parameters diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js index 39f63d2a153..37ced72861e 100644 --- a/app/assets/javascripts/registry/explorer/constants/list.js +++ b/app/assets/javascripts/registry/explorer/constants/list.js @@ -44,5 +44,6 @@ export const EMPTY_RESULT_MESSAGE = s__( // Parameters -export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; -export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; +export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED'; +export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED'; +export const GRAPHQL_PAGE_SIZE = 10; diff --git a/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql b/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql new file mode 100644 index 00000000000..9a3579ee8e0 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql @@ -0,0 +1,11 @@ +fragment ContainerRepositoryFields on ContainerRepository { + id + name + path + status + location + canDelete + createdAt + tagsCount + expirationPolicyStartedAt +} diff --git a/app/assets/javascripts/registry/explorer/graphql/index.js b/app/assets/javascripts/registry/explorer/graphql/index.js new file mode 100644 index 00000000000..16152eb81f6 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), +}); diff --git a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql new file mode 100644 index 00000000000..4c88b726ee5 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql @@ -0,0 +1,9 @@ +mutation destroyContainerRepository($id: ContainerRepositoryID!) { + destroyContainerRepository(input: { id: $id }) { + containerRepository { + id + status + } + errors + } +} diff --git a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql new file mode 100644 index 00000000000..a31f2829e13 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql @@ -0,0 +1,5 @@ +mutation destroyContainerRepositoryTags($id: ContainerRepositoryID!, $tagNames: [String!]!) { + destroyContainerRepositoryTags(input: { id: $id, tagNames: $tagNames }) { + errors + } +} diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql new file mode 100644 index 00000000000..b40200e020b --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql @@ -0,0 +1,41 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getContainerRepositoryDetails( + $id: ID! + $first: Int + $last: Int + $after: String + $before: String +) { + containerRepository(id: $id) { + id + name + path + status + location + canDelete + createdAt + updatedAt + tagsCount + expirationPolicyStartedAt + tags(after: $after, before: $before, first: $first, last: $last) { + nodes { + digest + location + path + name + revision + shortRevision + createdAt + totalSize + canDelete + } + pageInfo { + ...PageInfo + } + } + project { + visibility + } + } +} diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql new file mode 100644 index 00000000000..348eda97ea7 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql @@ -0,0 +1,23 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/container_repository.fragment.graphql" + +query getGroupContainerRepositories( + $fullPath: ID! + $name: String + $first: Int + $last: Int + $after: String + $before: String +) { + group(fullPath: $fullPath) { + containerRepositoriesCount + containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) { + nodes { + ...ContainerRepositoryFields + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql new file mode 100644 index 00000000000..338e27745f7 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql @@ -0,0 +1,23 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/container_repository.fragment.graphql" + +query getProjectContainerRepositories( + $fullPath: ID! + $name: String + $first: Int + $last: Int + $after: String + $before: String +) { + project(fullPath: $fullPath) { + containerRepositoriesCount + containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) { + nodes { + ...ContainerRepositoryFields + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js index 2bba3ee4ff9..d887b6a1b15 100644 --- a/app/assets/javascripts/registry/explorer/index.js +++ b/app/assets/javascripts/registry/explorer/index.js @@ -1,10 +1,11 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; import Translate from '~/vue_shared/translate'; +import { parseBoolean } from '~/lib/utils/common_utils'; import RegistryExplorer from './pages/index.vue'; import RegistryBreadcrumb from './components/registry_breadcrumb.vue'; -import { createStore } from './stores'; import createRouter from './router'; +import { apolloProvider } from './graphql/index'; Vue.use(Translate); Vue.use(GlToast); @@ -16,20 +17,42 @@ export default () => { return null; } - const { endpoint } = el.dataset; + const { endpoint, expirationPolicy, isGroupPage, isAdmin, ...config } = el.dataset; - const store = createStore(); - const router = createRouter(endpoint); - store.dispatch('setInitialState', el.dataset); + // This is a mini state to help the breadcrumb have the correct name in the details page + const breadCrumbState = Vue.observable({ + name: '', + updateName(value) { + this.name = value; + }, + }); + + const router = createRouter(endpoint, breadCrumbState); const attachMainComponent = () => new Vue({ el, - store, router, + apolloProvider, components: { RegistryExplorer, }, + provide() { + return { + breadCrumbState, + config: { + ...config, + expirationPolicy: expirationPolicy ? JSON.parse(expirationPolicy) : undefined, + isGroupPage: parseBoolean(isGroupPage), + isAdmin: parseBoolean(isAdmin), + }, + /* eslint-disable @gitlab/require-i18n-strings */ + dockerBuildCommand: `docker build -t ${config.repositoryUrl} .`, + dockerPushCommand: `docker push ${config.repositoryUrl}`, + dockerLoginCommand: `docker login ${config.registryHostUrlWithPort}`, + /* eslint-enable @gitlab/require-i18n-strings */ + }; + }, render(createElement) { return createElement('registry-explorer'); }, @@ -40,8 +63,8 @@ export default () => { const crumbs = [...document.querySelectorAll('.js-breadcrumbs-list li')]; return new Vue({ el: breadCrumbEl, - store, router, + apolloProvider, components: { RegistryBreadcrumb, }, diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index a60ef5c4982..540f02d58d4 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -1,8 +1,9 @@ <script> -import { mapState, mapActions } from 'vuex'; -import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui'; +import { GlKeysetPagination, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import createFlash from '~/flash'; import Tracking from '~/tracking'; +import { joinPaths } from '~/lib/utils/url_utility'; import DeleteAlert from '../components/details_page/delete_alert.vue'; import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue'; import DeleteModal from '../components/details_page/delete_modal.vue'; @@ -11,11 +12,16 @@ import TagsList from '../components/details_page/tags_list.vue'; import TagsLoader from '../components/details_page/tags_loader.vue'; import EmptyTagsState from '../components/details_page/empty_tags_state.vue'; +import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; +import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; + import { ALERT_SUCCESS_TAG, ALERT_DANGER_TAG, ALERT_SUCCESS_TAGS, ALERT_DANGER_TAGS, + GRAPHQL_PAGE_SIZE, + FETCH_IMAGES_LIST_ERROR_MESSAGE, } from '../constants/index'; export default { @@ -23,28 +29,61 @@ export default { DeleteAlert, PartialCleanupAlert, DetailsHeader, - GlPagination, + GlKeysetPagination, DeleteModal, TagsList, TagsLoader, EmptyTagsState, }, + inject: ['breadCrumbState', 'config'], directives: { GlResizeObserver: GlResizeObserverDirective, }, mixins: [Tracking.mixin()], + apollo: { + image: { + query: getContainerRepositoryDetailsQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data.containerRepository; + }, + result({ data }) { + this.tagsPageInfo = data.containerRepository?.tags?.pageInfo; + this.breadCrumbState.updateName(data.containerRepository?.name); + }, + error() { + createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + }, + }, + }, data() { return { + image: {}, + tagsPageInfo: {}, itemsToBeDeleted: [], isMobile: false, + mutationLoading: false, deleteAlertType: null, dismissPartialCleanupWarning: false, }; }, computed: { - ...mapState(['tagsPagination', 'isLoading', 'config', 'tags', 'imageDetails']), + queryVariables() { + return { + id: joinPaths(this.config.gidPrefix, `${this.$route.params.id}`), + first: GRAPHQL_PAGE_SIZE, + }; + }, + isLoading() { + return this.$apollo.queries.image.loading || this.mutationLoading; + }, + tags() { + return this.image?.tags?.nodes || []; + }, showPartialCleanupWarning() { - return this.imageDetails?.cleanup_policy_started_at && !this.dismissPartialCleanupWarning; + return this.image?.expirationPolicyStartedAt && !this.dismissPartialCleanupWarning; }, tracking() { return { @@ -52,66 +91,78 @@ export default { this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete', }; }, - currentPage: { - get() { - return this.tagsPagination.page; - }, - set(page) { - this.requestTagsList({ page }); - }, + showPagination() { + return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage; }, }, - mounted() { - this.requestImageDetailsAndTagsList(this.$route.params.id); - }, methods: { - ...mapActions([ - 'requestTagsList', - 'requestDeleteTag', - 'requestDeleteTags', - 'requestImageDetailsAndTagsList', - ]), deleteTags(toBeDeleted) { this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]); this.track('click_button'); this.$refs.deleteModal.show(); }, - handleSingleDelete() { - const [itemToDelete] = this.itemsToBeDeleted; - this.itemsToBeDeleted = []; - return this.requestDeleteTag({ tag: itemToDelete }) - .then(() => { - this.deleteAlertType = ALERT_SUCCESS_TAG; - }) - .catch(() => { - this.deleteAlertType = ALERT_DANGER_TAG; - }); - }, - handleMultipleDelete() { + async handleDelete() { + this.track('confirm_delete'); const { itemsToBeDeleted } = this; this.itemsToBeDeleted = []; - - return this.requestDeleteTags({ - ids: itemsToBeDeleted.map(x => x.name), - }) - .then(() => { - this.deleteAlertType = ALERT_SUCCESS_TAGS; - }) - .catch(() => { - this.deleteAlertType = ALERT_DANGER_TAGS; + this.mutationLoading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: deleteContainerRepositoryTagsMutation, + variables: { + id: this.queryVariables.id, + tagNames: itemsToBeDeleted.map(i => i.name), + }, + awaitRefetchQueries: true, + refetchQueries: [ + { + query: getContainerRepositoryDetailsQuery, + variables: this.queryVariables, + }, + ], }); - }, - onDeletionConfirmed() { - this.track('confirm_delete'); - if (this.itemsToBeDeleted.length > 1) { - this.handleMultipleDelete(); - } else { - this.handleSingleDelete(); + + if (data?.destroyContainerRepositoryTags?.errors[0]) { + throw new Error(); + } + this.deleteAlertType = + itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS; + } catch (e) { + this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS; } + + this.mutationLoading = false; }, handleResize() { this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs'; }, + fetchNextPage() { + if (this.tagsPageInfo?.hasNextPage) { + this.$apollo.queries.image.fetchMore({ + variables: { + after: this.tagsPageInfo?.endCursor, + first: GRAPHQL_PAGE_SIZE, + }, + updateQuery(previousResult, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + } + }, + fetchPreviousPage() { + if (this.tagsPageInfo?.hasPreviousPage) { + this.$apollo.queries.image.fetchMore({ + variables: { + first: null, + before: this.tagsPageInfo?.startCursor, + last: GRAPHQL_PAGE_SIZE, + }, + updateQuery(previousResult, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + } + }, }, }; </script> @@ -132,28 +183,30 @@ export default { @dismiss="dismissPartialCleanupWarning = true" /> - <details-header :image-name="imageDetails.name" /> + <details-header :image="image" /> <tags-loader v-if="isLoading" /> <template v-else> <empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" /> - <tags-list v-else :tags="tags" :is-mobile="isMobile" @delete="deleteTags" /> + <template v-else> + <tags-list :tags="tags" :is-mobile="isMobile" @delete="deleteTags" /> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + :has-next-page="tagsPageInfo.hasNextPage" + :has-previous-page="tagsPageInfo.hasPreviousPage" + class="gl-mt-3" + @prev="fetchPreviousPage" + @next="fetchNextPage" + /> + </div> + </template> </template> - <gl-pagination - v-if="!isLoading" - ref="pagination" - v-model="currentPage" - :per-page="tagsPagination.perPage" - :total-items="tagsPagination.total" - align="center" - class="gl-w-full gl-mt-3" - /> - <delete-modal ref="deleteModal" :items-to-be-deleted="itemsToBeDeleted" - @confirmDelete="onDeletionConfirmed" + @confirmDelete="handleDelete" @cancel="track('cancel_delete')" /> </div> diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/registry/explorer/pages/index.vue index 4ac0bca84c1..dca63e1a569 100644 --- a/app/assets/javascripts/registry/explorer/pages/index.vue +++ b/app/assets/javascripts/registry/explorer/pages/index.vue @@ -1,7 +1,3 @@ -<script> -export default {}; -</script> - <template> <div> <router-view ref="router-view" /> diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 81e47073fe9..3192ba82db8 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -1,5 +1,4 @@ <script> -import { mapState, mapActions } from 'vuex'; import { GlEmptyState, GlTooltipDirective, @@ -11,6 +10,7 @@ import { GlSearchBoxByClick, } from '@gitlab/ui'; import Tracking from '~/tracking'; +import createFlash from '~/flash'; import ProjectEmptyState from '../components/list_page/project_empty_state.vue'; import GroupEmptyState from '../components/list_page/group_empty_state.vue'; @@ -18,6 +18,10 @@ import RegistryHeader from '../components/list_page/registry_header.vue'; import ImageList from '../components/list_page/image_list.vue'; import CliCommands from '../components/list_page/cli_commands.vue'; +import getProjectContainerRepositoriesQuery from '../graphql/queries/get_project_container_repositories.query.graphql'; +import getGroupContainerRepositoriesQuery from '../graphql/queries/get_group_container_repositories.query.graphql'; +import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql'; + import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, @@ -29,6 +33,8 @@ import { IMAGE_REPOSITORY_LIST_LABEL, EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, + GRAPHQL_PAGE_SIZE, + FETCH_IMAGES_LIST_ERROR_MESSAGE, } from '../constants/index'; export default { @@ -47,6 +53,7 @@ export default { RegistryHeader, CliCommands, }, + inject: ['config'], directives: { GlTooltip: GlTooltipDirective, }, @@ -66,21 +73,62 @@ export default { EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, }, + apollo: { + images: { + query() { + return this.graphQlQuery; + }, + variables() { + return this.queryVariables; + }, + update(data) { + return data[this.graphqlResource]?.containerRepositories.nodes; + }, + result({ data }) { + this.pageInfo = data[this.graphqlResource]?.containerRepositories?.pageInfo; + this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount; + }, + error() { + createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + }, + }, + }, data() { return { + images: [], + pageInfo: {}, + containerRepositoriesCount: 0, itemToDelete: {}, deleteAlertType: null, - search: null, - isEmpty: false, + searchValue: null, + name: null, + mutationLoading: false, }; }, computed: { - ...mapState(['config', 'isLoading', 'images', 'pagination']), + graphqlResource() { + return this.config.isGroupPage ? 'group' : 'project'; + }, + graphQlQuery() { + return this.config.isGroupPage + ? getGroupContainerRepositoriesQuery + : getProjectContainerRepositoriesQuery; + }, + queryVariables() { + return { + name: this.name, + fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath, + first: GRAPHQL_PAGE_SIZE, + }; + }, tracking() { return { label: 'registry_repository_delete', }; }, + isLoading() { + return this.$apollo.queries.images.loading || this.mutationLoading; + }, showCommands() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, @@ -93,19 +141,7 @@ export default { : DELETE_IMAGE_ERROR_MESSAGE; }, }, - mounted() { - this.loadImageList(this.$route.name); - }, methods: { - ...mapActions(['requestImagesList', 'requestDeleteImage']), - loadImageList(fromName) { - if (!fromName || !this.images?.length) { - return this.requestImagesList().then(() => { - this.isEmpty = this.images.length === 0; - }); - } - return Promise.resolve(); - }, deleteImage(item) { this.track('click_button'); this.itemToDelete = item; @@ -113,18 +149,59 @@ export default { }, handleDeleteImage() { this.track('confirm_delete'); - return this.requestDeleteImage(this.itemToDelete) - .then(() => { - this.deleteAlertType = 'success'; + this.mutationLoading = true; + return this.$apollo + .mutate({ + mutation: deleteContainerRepositoryMutation, + variables: { + id: this.itemToDelete.id, + }, + }) + .then(({ data }) => { + if (data?.destroyContainerRepository?.errors[0]) { + this.deleteAlertType = 'danger'; + } else { + this.deleteAlertType = 'success'; + } }) .catch(() => { this.deleteAlertType = 'danger'; + }) + .finally(() => { + this.mutationLoading = false; }); }, dismissDeleteAlert() { this.deleteAlertType = null; this.itemToDelete = {}; }, + fetchNextPage() { + if (this.pageInfo?.hasNextPage) { + this.$apollo.queries.images.fetchMore({ + variables: { + after: this.pageInfo?.endCursor, + first: GRAPHQL_PAGE_SIZE, + }, + updateQuery(previousResult, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + } + }, + fetchPreviousPage() { + if (this.pageInfo?.hasPreviousPage) { + this.$apollo.queries.images.fetchMore({ + variables: { + first: null, + before: this.pageInfo?.startCursor, + last: GRAPHQL_PAGE_SIZE, + }, + updateQuery(previousResult, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + } + }, }, }; </script> @@ -134,7 +211,7 @@ export default { <gl-alert v-if="showDeleteAlert" :variant="deleteAlertType" - class="mt-2" + class="gl-mt-5" dismissible @dismiss="dismissDeleteAlert" > @@ -165,7 +242,7 @@ export default { <template v-else> <registry-header - :images-count="pagination.total" + :images-count="containerRepositoriesCount" :expiration-policy="config.expirationPolicy" :help-page-path="config.helpPagePath" :expiration-policy-help-page-path="config.expirationPolicyHelpPagePath" @@ -176,7 +253,7 @@ export default { </template> </registry-header> - <div v-if="isLoading" class="mt-2"> + <div v-if="isLoading" class="gl-mt-5"> <gl-skeleton-loader v-for="index in $options.loader.repeat" :key="index" @@ -190,16 +267,17 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <template v-if="!isEmpty"> + <template v-if="images.length > 0 || name"> <div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader"> <div class="gl-flex-fill-1"> <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5> </div> <div> <gl-search-box-by-click - v-model="search" + v-model="searchValue" :placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT" - @submit="requestImagesList({ name: $event })" + @clear="name = null" + @submit="name = $event" /> </div> </div> @@ -207,9 +285,10 @@ export default { <image-list v-if="images.length" :images="images" - :pagination="pagination" - @pageChange="requestImagesList({ pagination: { page: $event }, name: search })" + :page-info="pageInfo" @delete="deleteImage" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" /> <gl-empty-state diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js index dcf1c77329d..d8903cf0931 100644 --- a/app/assets/javascripts/registry/explorer/router.js +++ b/app/assets/javascripts/registry/explorer/router.js @@ -6,7 +6,7 @@ import { CONTAINER_REGISTRY_TITLE } from './constants/index'; Vue.use(VueRouter); -export default function createRouter(base) { +export default function createRouter(base, breadCrumbState) { const router = new VueRouter({ base, mode: 'history', @@ -25,7 +25,7 @@ export default function createRouter(base) { path: '/:id', component: Details, meta: { - nameGenerator: ({ imageDetails }) => imageDetails?.name, + nameGenerator: () => breadCrumbState.name, }, }, ], diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js deleted file mode 100644 index c1883095097..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ /dev/null @@ -1,119 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; -import Api from '~/api'; -import * as types from './mutation_types'; -import { - FETCH_IMAGES_LIST_ERROR_MESSAGE, - DEFAULT_PAGE, - DEFAULT_PAGE_SIZE, - FETCH_TAGS_LIST_ERROR_MESSAGE, - FETCH_IMAGE_DETAILS_ERROR_MESSAGE, -} from '../constants/index'; -import { pathGenerator } from '../utils'; - -export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); -export const setShowGarbageCollectionTip = ({ commit }, data) => - commit(types.SET_SHOW_GARBAGE_COLLECTION_TIP, data); - -export const receiveImagesListSuccess = ({ commit }, { data, headers }) => { - commit(types.SET_IMAGES_LIST_SUCCESS, data); - commit(types.SET_PAGINATION, headers); -}; - -export const receiveTagsListSuccess = ({ commit }, { data, headers }) => { - commit(types.SET_TAGS_LIST_SUCCESS, data); - commit(types.SET_TAGS_PAGINATION, headers); -}; - -export const requestImagesList = ( - { commit, dispatch, state }, - { pagination = {}, name = null } = {}, -) => { - commit(types.SET_MAIN_LOADING, true); - const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; - - return axios - .get(state.config.endpoint, { params: { page, per_page: perPage, name } }) - .then(({ data, headers }) => { - dispatch('receiveImagesListSuccess', { data, headers }); - }) - .catch(() => { - createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); - }) - .finally(() => { - commit(types.SET_MAIN_LOADING, false); - }); -}; - -export const requestTagsList = ({ commit, dispatch, state: { imageDetails } }, pagination = {}) => { - commit(types.SET_MAIN_LOADING, true); - const tagsPath = pathGenerator(imageDetails); - - const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; - return axios - .get(tagsPath, { params: { page, per_page: perPage } }) - .then(({ data, headers }) => { - dispatch('receiveTagsListSuccess', { data, headers }); - }) - .catch(() => { - createFlash({ message: FETCH_TAGS_LIST_ERROR_MESSAGE }); - }) - .finally(() => { - commit(types.SET_MAIN_LOADING, false); - }); -}; - -export const requestImageDetailsAndTagsList = ({ dispatch, commit }, id) => { - commit(types.SET_MAIN_LOADING, true); - return Api.containerRegistryDetails(id) - .then(({ data }) => { - commit(types.SET_IMAGE_DETAILS, data); - dispatch('requestTagsList'); - }) - .catch(() => { - createFlash({ message: FETCH_IMAGE_DETAILS_ERROR_MESSAGE }); - commit(types.SET_MAIN_LOADING, false); - }); -}; - -export const requestDeleteTag = ({ commit, dispatch, state }, { tag }) => { - commit(types.SET_MAIN_LOADING, true); - return axios - .delete(tag.destroy_path) - .then(() => { - dispatch('setShowGarbageCollectionTip', true); - - return dispatch('requestTagsList', state.tagsPagination); - }) - .finally(() => { - commit(types.SET_MAIN_LOADING, false); - }); -}; - -export const requestDeleteTags = ({ commit, dispatch, state }, { ids }) => { - commit(types.SET_MAIN_LOADING, true); - - const tagsPath = pathGenerator(state.imageDetails, '/bulk_destroy'); - - return axios - .delete(tagsPath, { params: { ids } }) - .then(() => { - dispatch('setShowGarbageCollectionTip', true); - return dispatch('requestTagsList', state.tagsPagination); - }) - .finally(() => { - commit(types.SET_MAIN_LOADING, false); - }); -}; - -export const requestDeleteImage = ({ commit }, image) => { - commit(types.SET_MAIN_LOADING, true); - return axios - .delete(image.destroy_path) - .then(() => { - commit(types.UPDATE_IMAGE, { ...image, deleting: true }); - }) - .finally(() => { - commit(types.SET_MAIN_LOADING, false); - }); -}; diff --git a/app/assets/javascripts/registry/explorer/stores/getters.js b/app/assets/javascripts/registry/explorer/stores/getters.js deleted file mode 100644 index 7b5d1bd6da3..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/getters.js +++ /dev/null @@ -1,18 +0,0 @@ -export const dockerBuildCommand = state => { - /* eslint-disable @gitlab/require-i18n-strings */ - return `docker build -t ${state.config.repositoryUrl} .`; -}; - -export const dockerPushCommand = state => { - /* eslint-disable @gitlab/require-i18n-strings */ - return `docker push ${state.config.repositoryUrl}`; -}; - -export const dockerLoginCommand = state => { - /* eslint-disable @gitlab/require-i18n-strings */ - return `docker login ${state.config.registryHostUrlWithPort}`; -}; - -export const showGarbageCollection = state => { - return state.showGarbageCollectionTip && state.config.isAdmin; -}; diff --git a/app/assets/javascripts/registry/explorer/stores/index.js b/app/assets/javascripts/registry/explorer/stores/index.js deleted file mode 100644 index 18e3351ed13..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -Vue.use(Vuex); - -export const createStore = () => - new Vuex.Store({ - state, - getters, - actions, - mutations, - }); diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js deleted file mode 100644 index 5dd0cec52eb..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js +++ /dev/null @@ -1,10 +0,0 @@ -export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; - -export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; -export const UPDATE_IMAGE = 'UPDATE_IMAGE'; -export const SET_PAGINATION = 'SET_PAGINATION'; -export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; -export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION'; -export const SET_TAGS_LIST_SUCCESS = 'SET_TAGS_LIST_SUCCESS'; -export const SET_SHOW_GARBAGE_COLLECTION_TIP = 'SET_SHOW_GARBAGE_COLLECTION_TIP'; -export const SET_IMAGE_DETAILS = 'SET_IMAGE_DETAILS'; diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js deleted file mode 100644 index 5bdb431ad2e..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/mutations.js +++ /dev/null @@ -1,54 +0,0 @@ -import * as types from './mutation_types'; -import { parseIntPagination, normalizeHeaders, parseBoolean } from '~/lib/utils/common_utils'; -import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants/index'; - -export default { - [types.SET_INITIAL_STATE](state, config) { - state.config = { - ...config, - expirationPolicy: config.expirationPolicy ? JSON.parse(config.expirationPolicy) : undefined, - isGroupPage: parseBoolean(config.isGroupPage), - isAdmin: parseBoolean(config.isAdmin), - }; - }, - - [types.SET_IMAGES_LIST_SUCCESS](state, images) { - state.images = images.map(i => ({ - ...i, - status: undefined, - deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS, - failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS, - })); - }, - - [types.UPDATE_IMAGE](state, image) { - const index = state.images.findIndex(i => i.id === image.id); - state.images.splice(index, 1, { ...image }); - }, - - [types.SET_TAGS_LIST_SUCCESS](state, tags) { - state.tags = tags; - }, - - [types.SET_MAIN_LOADING](state, isLoading) { - state.isLoading = isLoading; - }, - - [types.SET_SHOW_GARBAGE_COLLECTION_TIP](state, showGarbageCollectionTip) { - state.showGarbageCollectionTip = showGarbageCollectionTip; - }, - - [types.SET_PAGINATION](state, headers) { - const normalizedHeaders = normalizeHeaders(headers); - state.pagination = parseIntPagination(normalizedHeaders); - }, - - [types.SET_TAGS_PAGINATION](state, headers) { - const normalizedHeaders = normalizeHeaders(headers); - state.tagsPagination = parseIntPagination(normalizedHeaders); - }, - - [types.SET_IMAGE_DETAILS](state, details) { - state.imageDetails = details; - }, -}; diff --git a/app/assets/javascripts/registry/explorer/stores/state.js b/app/assets/javascripts/registry/explorer/stores/state.js deleted file mode 100644 index 66ee56eb47b..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/state.js +++ /dev/null @@ -1,10 +0,0 @@ -export default () => ({ - isLoading: false, - showGarbageCollectionTip: false, - config: {}, - images: [], - imageDetails: {}, - tags: [], - pagination: {}, - tagsPagination: {}, -}); diff --git a/app/assets/javascripts/registry/explorer/utils.js b/app/assets/javascripts/registry/explorer/utils.js deleted file mode 100644 index a48da51caae..00000000000 --- a/app/assets/javascripts/registry/explorer/utils.js +++ /dev/null @@ -1,25 +0,0 @@ -import { joinPaths } from '~/lib/utils/url_utility'; - -export const pathGenerator = (imageDetails, ending = '?format=json') => { - // this method is a temporary workaround, to be removed with graphql implementation - // https://gitlab.com/gitlab-org/gitlab/-/issues/276432 - - const splitPath = imageDetails.path.split('/').reverse(); - const splitName = imageDetails.name ? imageDetails.name.split('/').reverse() : []; - const basePath = splitPath - .reduce((acc, curr, index) => { - if (splitPath[index] !== splitName[index]) { - acc.unshift(curr); - } - return acc; - }, []) - .join('/'); - - return joinPaths( - window.gon.relative_url_root, - `/${basePath}`, - '/registry/repository/', - `${imageDetails.id}`, - `tags${ending}`, - ); -}; diff --git a/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue b/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue new file mode 100644 index 00000000000..d75fb31fd98 --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue @@ -0,0 +1,50 @@ +<script> +import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; + +export default { + components: { + GlFormGroup, + GlFormSelect, + }, + props: { + formOptions: { + type: Array, + required: false, + default: () => [], + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + value: { + type: String, + required: false, + default: '', + }, + name: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <gl-form-group :id="`${name}-form-group`" :label-for="name" :label="label"> + <gl-form-select :id="name" :value="value" :disabled="disabled" @input="$emit('input', $event)"> + <option + v-for="option in formOptions" + :key="option.key" + :value="option.key" + data-testid="option" + > + {{ option.label }} + </option> + </gl-form-select> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/registry/settings/components/expiration_input.vue b/app/assets/javascripts/registry/settings/components/expiration_input.vue new file mode 100644 index 00000000000..2dbd9d26f60 --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_input.vue @@ -0,0 +1,110 @@ +<script> +import { GlFormGroup, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; +import { NAME_REGEX_LENGTH, TEXT_AREA_INVALID_FEEDBACK } from '../constants'; + +export default { + components: { + GlFormGroup, + GlFormInput, + GlSprintf, + GlLink, + }, + inject: ['tagsRegexHelpPagePath'], + props: { + error: { + type: String, + required: false, + default: '', + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + value: { + type: String, + required: false, + default: '', + }, + name: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + placeholder: { + type: String, + required: false, + default: '', + }, + description: { + type: String, + required: true, + }, + }, + computed: { + textAreaLengthErrorMessage() { + return this.isInputValid(this.value) ? '' : TEXT_AREA_INVALID_FEEDBACK; + }, + inputValidation() { + const nameRegexErrors = this.error || this.textAreaLengthErrorMessage; + return { + state: nameRegexErrors === null ? null : !nameRegexErrors, + message: nameRegexErrors, + }; + }, + internalValue: { + get() { + return this.value; + }, + set(value) { + this.$emit('input', value); + this.$emit('validation', this.isInputValid(value)); + }, + }, + }, + methods: { + isInputValid(value) { + return !value || value.length <= NAME_REGEX_LENGTH; + }, + }, +}; +</script> + +<template> + <gl-form-group + :id="`${name}-form-group`" + :label-for="name" + :state="inputValidation.state" + :invalid-feedback="inputValidation.message" + > + <template #label> + <span data-testid="label"> + <gl-sprintf :message="label"> + <template #italic="{content}"> + <i>{{ content }}</i> + </template> + </gl-sprintf> + </span> + </template> + <gl-form-input + :id="name" + v-model="internalValue" + :placeholder="placeholder" + :state="inputValidation.state" + :disabled="disabled" + trim + /> + <template #description> + <span data-testid="description" class="gl-text-gray-400"> + <gl-sprintf :message="description"> + <template #link="{content}"> + <gl-link :href="tagsRegexHelpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </template> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/registry/settings/components/expiration_run_text.vue b/app/assets/javascripts/registry/settings/components/expiration_run_text.vue new file mode 100644 index 00000000000..fd9ca6a54c5 --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_run_text.vue @@ -0,0 +1,46 @@ +<script> +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { NEXT_CLEANUP_LABEL, NOT_SCHEDULED_POLICY_TEXT } from '~/registry/settings/constants'; + +export default { + components: { + GlFormGroup, + GlFormInput, + }, + props: { + value: { + type: String, + required: false, + default: NOT_SCHEDULED_POLICY_TEXT, + }, + enabled: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + parsedValue() { + return this.enabled ? this.value : NOT_SCHEDULED_POLICY_TEXT; + }, + }, + i18n: { + NEXT_CLEANUP_LABEL, + }, +}; +</script> + +<template> + <gl-form-group + id="expiration-policy-info-text-group" + :label="$options.i18n.NEXT_CLEANUP_LABEL" + label-for="expiration-policy-info-text" + > + <gl-form-input + id="expiration-policy-info-text" + class="gl-pl-0!" + plaintext + :value="parsedValue" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue new file mode 100644 index 00000000000..7f045244926 --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue @@ -0,0 +1,52 @@ +<script> +import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui'; +import { ENABLED_TOGGLE_DESCRIPTION, DISABLED_TOGGLE_DESCRIPTION } from '../constants'; + +export default { + components: { + GlFormGroup, + GlToggle, + GlSprintf, + }, + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + value: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + enabled: { + get() { + return this.value; + }, + set(value) { + this.$emit('input', value); + }, + }, + toggleText() { + return this.enabled ? ENABLED_TOGGLE_DESCRIPTION : DISABLED_TOGGLE_DESCRIPTION; + }, + }, +}; +</script> + +<template> + <gl-form-group id="expiration-policy-toggle-group" label-for="expiration-policy-toggle"> + <div class="gl-display-flex"> + <gl-toggle id="expiration-policy-toggle" v-model="enabled" :disabled="disabled" /> + <span class="gl-ml-5 gl-line-height-24" data-testid="description"> + <gl-sprintf :message="toggleText"> + <template #strong="{content}"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </span> + </div> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index 264d39a406a..35c7a8be4ea 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,17 +1,17 @@ <script> import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { isEqual, get } from 'lodash'; -import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql'; -import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants'; - -import SettingsForm from './settings_form.vue'; +import { isEqual, get, isEmpty } from 'lodash'; +import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.query.graphql'; import { + FETCH_SETTINGS_ERROR_MESSAGE, UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, UNAVAILABLE_USER_FEATURE_TEXT, UNAVAILABLE_ADMIN_FEATURE_TEXT, } from '../constants'; +import SettingsForm from './settings_form.vue'; + export default { components: { SettingsForm, @@ -60,6 +60,9 @@ export default { return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, isEdited() { + if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) { + return false; + } return !isEqual(this.containerExpirationPolicy, this.workingCopy); }, }, diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index fe4aee6806e..1f374c7b60e 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,21 +1,41 @@ <script> -import { GlCard, GlButton } from '@gitlab/ui'; +import { GlCard, GlButton, GlSprintf } from '@gitlab/ui'; import Tracking from '~/tracking'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, -} from '../../shared/constants'; -import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue'; -import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants'; -import { formOptionsGenerator } from '~/registry/shared/utils'; -import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql'; -import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update'; + SET_CLEANUP_POLICY_BUTTON, + KEEP_HEADER_TEXT, + KEEP_INFO_TEXT, + KEEP_N_LABEL, + NAME_REGEX_KEEP_LABEL, + NAME_REGEX_KEEP_DESCRIPTION, + REMOVE_HEADER_TEXT, + REMOVE_INFO_TEXT, + EXPIRATION_SCHEDULE_LABEL, + NAME_REGEX_LABEL, + NAME_REGEX_PLACEHOLDER, + NAME_REGEX_DESCRIPTION, + CADENCE_LABEL, + EXPIRATION_POLICY_FOOTER_NOTE, +} from '~/registry/settings/constants'; +import { formOptionsGenerator } from '~/registry/settings/utils'; +import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql'; +import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update'; +import ExpirationDropdown from './expiration_dropdown.vue'; +import ExpirationInput from './expiration_input.vue'; +import ExpirationToggle from './expiration_toggle.vue'; +import ExpirationRunText from './expiration_run_text.vue'; export default { components: { GlCard, GlButton, - ExpirationPolicyFields, + GlSprintf, + ExpirationDropdown, + ExpirationInput, + ExpirationToggle, + ExpirationRunText, }, mixins: [Tracking.mixin()], inject: ['projectPath'], @@ -35,22 +55,31 @@ export default { default: false, }, }, - labelsConfig: { - cols: 3, - align: 'right', - }, + formOptions: formOptionsGenerator(), i18n: { - CLEANUP_POLICY_CARD_HEADER, + KEEP_HEADER_TEXT, + KEEP_INFO_TEXT, + KEEP_N_LABEL, + NAME_REGEX_KEEP_LABEL, SET_CLEANUP_POLICY_BUTTON, + NAME_REGEX_KEEP_DESCRIPTION, + REMOVE_HEADER_TEXT, + REMOVE_INFO_TEXT, + EXPIRATION_SCHEDULE_LABEL, + NAME_REGEX_LABEL, + NAME_REGEX_PLACEHOLDER, + NAME_REGEX_DESCRIPTION, + CADENCE_LABEL, + EXPIRATION_POLICY_FOOTER_NOTE, }, data() { return { tracking: { label: 'docker_container_retention_and_expiration_policies', }, - fieldsAreValid: true, - apiErrors: null, + apiErrors: {}, + localErrors: {}, mutationLoading: false, }; }, @@ -66,12 +95,18 @@ export default { showLoadingIcon() { return this.isLoading || this.mutationLoading; }, + fieldsAreValid() { + return Object.values(this.localErrors).every(error => error); + }, isSubmitButtonDisabled() { return !this.fieldsAreValid || this.showLoadingIcon; }, isCancelButtonDisabled() { return !this.isEdited || this.isLoading || this.mutationLoading; }, + isFieldDisabled() { + return this.showLoadingIcon || !this.value.enabled; + }, mutationVariables() { return { projectPath: this.projectPath, @@ -90,7 +125,8 @@ export default { }, reset() { this.track('reset_form'); - this.apiErrors = null; + this.apiErrors = {}; + this.localErrors = {}; this.$emit('reset'); }, setApiErrors(response) { @@ -101,9 +137,15 @@ export default { return acc; }, {}); }, + setLocalErrors(state, model) { + this.localErrors = { + ...this.localErrors, + [model]: state, + }; + }, submit() { this.track('submit_form'); - this.apiErrors = null; + this.apiErrors = {}; this.mutationLoading = true; return this.$apollo .mutate({ @@ -129,11 +171,9 @@ export default { this.mutationLoading = false; }); }, - onModelChange(changePayload) { - this.$emit('input', changePayload.newValue); - if (this.apiErrors) { - this.apiErrors[changePayload.modified] = undefined; - } + onModelChange(newValue, model) { + this.$emit('input', { ...this.value, [model]: newValue }); + this.apiErrors[model] = undefined; }, }, }; @@ -141,42 +181,133 @@ export default { <template> <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset"> - <gl-card> + <expiration-toggle + :value="prefilledForm.enabled" + :disabled="showLoadingIcon" + class="gl-mb-0!" + data-testid="enable-toggle" + @input="onModelChange($event, 'enabled')" + /> + + <div class="gl-display-flex gl-mt-7"> + <expiration-dropdown + v-model="prefilledForm.cadence" + :disabled="isFieldDisabled" + :form-options="$options.formOptions.cadence" + :label="$options.i18n.CADENCE_LABEL" + name="cadence" + class="gl-mr-7 gl-mb-0!" + data-testid="cadence-dropdown" + @input="onModelChange($event, 'cadence')" + /> + <expiration-run-text + :value="prefilledForm.nextRunAt" + :enabled="prefilledForm.enabled" + class="gl-mb-0!" + /> + </div> + <gl-card class="gl-mt-7"> <template #header> - {{ $options.i18n.CLEANUP_POLICY_CARD_HEADER }} + {{ $options.i18n.KEEP_HEADER_TEXT }} </template> <template #default> - <expiration-policy-fields - :value="prefilledForm" - :form-options="$options.formOptions" - :is-loading="isLoading" - :api-errors="apiErrors" - @validated="fieldsAreValid = true" - @invalidated="fieldsAreValid = false" - @input="onModelChange" - /> + <div> + <p> + <gl-sprintf :message="$options.i18n.KEEP_INFO_TEXT"> + <template #strong="{content}"> + <strong>{{ content }}</strong> + </template> + <template #secondStrong="{content}"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <expiration-dropdown + v-model="prefilledForm.keepN" + :disabled="isFieldDisabled" + :form-options="$options.formOptions.keepN" + :label="$options.i18n.KEEP_N_LABEL" + name="keep-n" + data-testid="keep-n-dropdown" + @input="onModelChange($event, 'keepN')" + /> + <expiration-input + v-model="prefilledForm.nameRegexKeep" + :error="apiErrors.nameRegexKeep" + :disabled="isFieldDisabled" + :label="$options.i18n.NAME_REGEX_KEEP_LABEL" + :description="$options.i18n.NAME_REGEX_KEEP_DESCRIPTION" + name="keep-regex" + data-testid="keep-regex-input" + @input="onModelChange($event, 'nameRegexKeep')" + @validation="setLocalErrors($event, 'nameRegexKeep')" + /> + </div> </template> - <template #footer> - <gl-button - ref="cancel-button" - type="reset" - class="gl-mr-3 gl-display-block float-right" - :disabled="isCancelButtonDisabled" - > - {{ __('Cancel') }} - </gl-button> - <gl-button - ref="save-button" - type="submit" - :disabled="isSubmitButtonDisabled" - :loading="showLoadingIcon" - variant="success" - category="primary" - class="js-no-auto-disable" - > - {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} - </gl-button> + </gl-card> + <gl-card class="gl-mt-7"> + <template #header> + {{ $options.i18n.REMOVE_HEADER_TEXT }} + </template> + <template #default> + <div> + <p> + <gl-sprintf :message="$options.i18n.REMOVE_INFO_TEXT"> + <template #strong="{content}"> + <strong>{{ content }}</strong> + </template> + <template #secondStrong="{content}"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <expiration-dropdown + v-model="prefilledForm.olderThan" + :disabled="isFieldDisabled" + :form-options="$options.formOptions.olderThan" + :label="$options.i18n.EXPIRATION_SCHEDULE_LABEL" + name="older-than" + data-testid="older-than-dropdown" + @input="onModelChange($event, 'olderThan')" + /> + <expiration-input + v-model="prefilledForm.nameRegex" + :error="apiErrors.nameRegex" + :disabled="isFieldDisabled" + :label="$options.i18n.NAME_REGEX_LABEL" + :placeholder="$options.i18n.NAME_REGEX_PLACEHOLDER" + :description="$options.i18n.NAME_REGEX_DESCRIPTION" + name="remove-regex" + data-testid="remove-regex-input" + @input="onModelChange($event, 'nameRegex')" + @validation="setLocalErrors($event, 'nameRegex')" + /> + </div> </template> </gl-card> + <div class="gl-mt-7 gl-display-flex gl-align-items-center"> + <gl-button + data-testid="save-button" + type="submit" + :disabled="isSubmitButtonDisabled" + :loading="showLoadingIcon" + variant="success" + category="primary" + class="js-no-auto-disable gl-mr-4" + > + {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} + </gl-button> + <gl-button + data-testid="cancel-button" + type="reset" + :disabled="isCancelButtonDisabled" + class="gl-mr-4" + > + {{ __('Cancel') }} + </gl-button> + <span class="gl-font-style-italic gl-text-gray-400">{{ + $options.i18n.EXPIRATION_POLICY_FOOTER_NOTE + }}</span> + </div> </form> </template> diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js index e790658f491..21c54299632 100644 --- a/app/assets/javascripts/registry/settings/constants.js +++ b/app/assets/javascripts/registry/settings/constants.js @@ -1,7 +1,6 @@ import { s__, __ } from '~/locale'; -export const SET_CLEANUP_POLICY_BUTTON = s__('ContainerRegistry|Set cleanup policy'); -export const CLEANUP_POLICY_CARD_HEADER = s__('ContainerRegistry|Tag expiration policy'); +export const SET_CLEANUP_POLICY_BUTTON = __('Save'); export const UNAVAILABLE_FEATURE_TITLE = s__( `ContainerRegistry|Cleanup policy for tags is disabled`, ); @@ -12,3 +11,81 @@ export const UNAVAILABLE_USER_FEATURE_TEXT = __(`Please contact your administrat export const UNAVAILABLE_ADMIN_FEATURE_TEXT = s__( `ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`, ); + +export const TEXT_AREA_INVALID_FEEDBACK = s__( + 'ContainerRegistry|The value of this input should be less than 256 characters', +); + +export const KEEP_HEADER_TEXT = s__('ContainerRegistry|Keep these tags'); +export const KEEP_INFO_TEXT = s__( + 'ContainerRegistry|Tags that match these rules are %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag is always kept.', +); +export const KEEP_N_LABEL = s__('ContainerRegistry|Keep the most recent:'); +export const NAME_REGEX_KEEP_LABEL = s__('ContainerRegistry|Keep tags matching:'); +export const NAME_REGEX_KEEP_DESCRIPTION = s__( + 'ContainerRegistry|Tags with names that match this regex pattern are kept. %{linkStart}More information%{linkEnd}', +); + +export const REMOVE_HEADER_TEXT = s__('ContainerRegistry|Remove these tags'); +export const REMOVE_INFO_TEXT = s__( + 'ContainerRegistry|Tags that match these rules are %{strongStart}removed%{strongEnd}, unless a rule above says to keep them.', +); +export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Remove tags older than:'); +export const NAME_REGEX_LABEL = s__('ContainerRegistry|Remove tags matching:'); +export const NAME_REGEX_PLACEHOLDER = '.*'; +export const NAME_REGEX_DESCRIPTION = s__( + 'ContainerRegistry|Tags with names that match this regex pattern are removed. %{linkStart}More information%{linkEnd}', +); + +export const ENABLED_TOGGLE_DESCRIPTION = s__( + 'ContainerRegistry|%{strongStart}Enabled%{strongEnd} - Tags that match the rules on this page are automatically scheduled for deletion.', +); +export const DISABLED_TOGGLE_DESCRIPTION = s__( + 'ContainerRegistry|%{strongStart}Disabled%{strongEnd} - Tags will not be automatically deleted.', +); + +export const CADENCE_LABEL = s__('ContainerRegistry|Run cleanup:'); + +export const NEXT_CLEANUP_LABEL = s__('ContainerRegistry|Next cleanup scheduled to run on:'); +export const NOT_SCHEDULED_POLICY_TEXT = s__('ContainerRegistry|Not yet scheduled'); +export const EXPIRATION_POLICY_FOOTER_NOTE = s__( + 'ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time', +); + +export const KEEP_N_OPTIONS = [ + { key: 'ONE_TAG', variable: 1, default: false }, + { key: 'FIVE_TAGS', variable: 5, default: false }, + { key: 'TEN_TAGS', variable: 10, default: true }, + { key: 'TWENTY_FIVE_TAGS', variable: 25, default: false }, + { key: 'FIFTY_TAGS', variable: 50, default: false }, + { key: 'ONE_HUNDRED_TAGS', variable: 100, default: false }, +]; + +export const CADENCE_OPTIONS = [ + { key: 'EVERY_DAY', label: __('Every day'), default: true }, + { key: 'EVERY_WEEK', label: __('Every week'), default: false }, + { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false }, + { key: 'EVERY_MONTH', label: __('Every month'), default: false }, + { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false }, +]; + +export const OLDER_THAN_OPTIONS = [ + { key: 'SEVEN_DAYS', variable: 7, default: false }, + { key: 'FOURTEEN_DAYS', variable: 14, default: false }, + { key: 'THIRTY_DAYS', variable: 30, default: false }, + { key: 'NINETY_DAYS', variable: 90, default: true }, +]; + +export const FETCH_SETTINGS_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while fetching the cleanup policy.', +); + +export const UPDATE_SETTINGS_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while updating the cleanup policy.', +); + +export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|Cleanup policy successfully saved.', +); + +export const NAME_REGEX_LENGTH = 255; diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql index 224e0ed9472..1d6c89133af 100644 --- a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql +++ b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql @@ -5,4 +5,5 @@ fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy { nameRegex nameRegexKeep olderThan + nextRunAt } diff --git a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql index c40cd115ab0..c40cd115ab0 100644 --- a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql +++ b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql diff --git a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql index c171be0ad07..c171be0ad07 100644 --- a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql +++ b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js index 88067d52b51..05b4125a2fc 100644 --- a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js +++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js @@ -1,5 +1,5 @@ import { produce } from 'immer'; -import expirationPolicyQuery from '../queries/get_expiration_policy.graphql'; +import expirationPolicyQuery from '../queries/get_expiration_policy.query.graphql'; export const updateContainerExpirationPolicy = projectPath => (client, { data: updatedData }) => { const queryAndParams = { diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index f7b1c5abd3a..6a4584b1b28 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -13,7 +13,13 @@ export default () => { if (!el) { return null; } - const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset; + const { + isAdmin, + enableHistoricEntries, + projectPath, + adminSettingsPath, + tagsRegexHelpPagePath, + } = el.dataset; return new Vue({ el, apolloProvider, @@ -21,10 +27,11 @@ export default () => { RegistrySettingsApp, }, provide: { - projectPath, isAdmin: parseBoolean(isAdmin), - adminSettingsPath, enableHistoricEntries: parseBoolean(enableHistoricEntries), + projectPath, + adminSettingsPath, + tagsRegexHelpPagePath, }, render(createElement) { return createElement('registry-settings-app', {}); diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/settings/utils.js index bdf1ab9507d..51b4fb6bdb8 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/settings/utils.js @@ -6,27 +6,7 @@ export const findDefaultOption = options => { return item ? item.key : null; }; -export const mapComputedToEvent = (list, root) => { - const result = {}; - list.forEach(e => { - result[e] = { - get() { - return this[root][e]; - }, - set(value) { - this.$emit('input', { newValue: { ...this[root], [e]: value }, modified: e }); - }, - }; - }); - return result; -}; - -export const olderThanTranslationGenerator = variable => - n__( - '%d day until tags are automatically removed', - '%d days until tags are automatically removed', - variable, - ); +export const olderThanTranslationGenerator = variable => n__('%d day', '%d days', variable); export const keepNTranslationGenerator = variable => n__('%d tag per image name', '%d tags per image name', variable); diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue deleted file mode 100644 index 2b8e9f6ff64..00000000000 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ /dev/null @@ -1,258 +0,0 @@ -<script> -import { uniqueId } from 'lodash'; -import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlSprintf } from '@gitlab/ui'; -import { - NAME_REGEX_LENGTH, - ENABLED_TEXT, - DISABLED_TEXT, - TEXT_AREA_INVALID_FEEDBACK, - EXPIRATION_INTERVAL_LABEL, - EXPIRATION_SCHEDULE_LABEL, - KEEP_N_LABEL, - NAME_REGEX_LABEL, - NAME_REGEX_PLACEHOLDER, - NAME_REGEX_DESCRIPTION, - NAME_REGEX_KEEP_LABEL, - NAME_REGEX_KEEP_PLACEHOLDER, - NAME_REGEX_KEEP_DESCRIPTION, - ENABLE_TOGGLE_LABEL, - ENABLE_TOGGLE_DESCRIPTION, -} from '../constants'; -import { mapComputedToEvent } from '../utils'; - -export default { - components: { - GlFormGroup, - GlToggle, - GlFormSelect, - GlFormTextarea, - GlSprintf, - }, - props: { - formOptions: { - type: Object, - required: false, - default: () => ({}), - }, - apiErrors: { - type: Object, - required: false, - default: null, - }, - isLoading: { - type: Boolean, - required: false, - default: false, - }, - value: { - type: Object, - required: false, - default: () => ({}), - }, - labelCols: { - type: [Number, String], - required: false, - default: 3, - }, - labelAlign: { - type: String, - required: false, - default: 'right', - }, - }, - i18n: { - ENABLE_TOGGLE_LABEL, - ENABLE_TOGGLE_DESCRIPTION, - }, - selectList: [ - { - name: 'expiration-policy-interval', - label: EXPIRATION_INTERVAL_LABEL, - model: 'olderThan', - }, - { - name: 'expiration-policy-schedule', - label: EXPIRATION_SCHEDULE_LABEL, - model: 'cadence', - }, - { - name: 'expiration-policy-latest', - label: KEEP_N_LABEL, - model: 'keepN', - }, - ], - textAreaList: [ - { - name: 'expiration-policy-name-matching', - label: NAME_REGEX_LABEL, - model: 'nameRegex', - placeholder: NAME_REGEX_PLACEHOLDER, - description: NAME_REGEX_DESCRIPTION, - }, - { - name: 'expiration-policy-keep-name', - label: NAME_REGEX_KEEP_LABEL, - model: 'nameRegexKeep', - placeholder: NAME_REGEX_KEEP_PLACEHOLDER, - description: NAME_REGEX_KEEP_DESCRIPTION, - }, - ], - data() { - return { - uniqueId: uniqueId(), - }; - }, - computed: { - ...mapComputedToEvent( - ['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'], - 'value', - ), - policyEnabledText() { - return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; - }, - textAreaValidation() { - const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex); - const nameKeepRegexErrors = - this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep); - - return { - /* - * The state has this form: - * null: gray border, no message - * true: green border, no message ( because none is configured) - * false: red border, error message - * So in this function we keep null if the are no message otherwise we 'invert' the error message - */ - nameRegex: { - state: nameRegexErrors === null ? null : !nameRegexErrors, - message: nameRegexErrors, - }, - nameRegexKeep: { - state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors, - message: nameKeepRegexErrors, - }, - }; - }, - fieldsValidity() { - return ( - this.textAreaValidation.nameRegex.state !== false && - this.textAreaValidation.nameRegexKeep.state !== false - ); - }, - isFormElementDisabled() { - return !this.enabled || this.isLoading; - }, - }, - watch: { - fieldsValidity: { - immediate: true, - handler(valid) { - if (valid) { - this.$emit('validated'); - } else { - this.$emit('invalidated'); - } - }, - }, - }, - methods: { - validateRegexLength(value) { - if (!value) { - return null; - } - return value.length <= NAME_REGEX_LENGTH ? '' : TEXT_AREA_INVALID_FEEDBACK; - }, - idGenerator(id) { - return `${id}_${this.uniqueId}`; - }, - updateModel(value, key) { - this[key] = value; - }, - }, -}; -</script> - -<template> - <div ref="form-elements" class="gl-line-height-20"> - <gl-form-group - :id="idGenerator('expiration-policy-toggle-group')" - :label-cols="labelCols" - :label-align="labelAlign" - :label-for="idGenerator('expiration-policy-toggle')" - :label="$options.i18n.ENABLE_TOGGLE_LABEL" - > - <div class="gl-display-flex"> - <gl-toggle - :id="idGenerator('expiration-policy-toggle')" - v-model="enabled" - :disabled="isLoading" - /> - <span class="gl-mb-3 gl-ml-3 gl-line-height-20"> - <gl-sprintf :message="$options.i18n.ENABLE_TOGGLE_DESCRIPTION"> - <template #toggleStatus> - <strong>{{ policyEnabledText }}</strong> - </template> - </gl-sprintf> - </span> - </div> - </gl-form-group> - - <gl-form-group - v-for="select in $options.selectList" - :id="idGenerator(`${select.name}-group`)" - :key="select.name" - :label-cols="labelCols" - :label-align="labelAlign" - :label-for="idGenerator(select.name)" - :label="select.label" - > - <gl-form-select - :id="idGenerator(select.name)" - :value="value[select.model]" - :disabled="isFormElementDisabled" - @input="updateModel($event, select.model)" - > - <option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key"> - {{ option.label }} - </option> - </gl-form-select> - </gl-form-group> - - <gl-form-group - v-for="textarea in $options.textAreaList" - :id="idGenerator(`${textarea.name}-group`)" - :key="textarea.name" - :label-cols="labelCols" - :label-align="labelAlign" - :label-for="idGenerator(textarea.name)" - :state="textAreaValidation[textarea.model].state" - :invalid-feedback="textAreaValidation[textarea.model].message" - > - <template #label> - <gl-sprintf :message="textarea.label"> - <template #italic="{content}"> - <i>{{ content }}</i> - </template> - </gl-sprintf> - </template> - <gl-form-textarea - :id="idGenerator(textarea.name)" - :value="value[textarea.model]" - :placeholder="textarea.placeholder" - :state="textAreaValidation[textarea.model].state" - :disabled="isFormElementDisabled" - trim - @input="updateModel($event, textarea.model)" - /> - <template #description> - <span ref="regex-description"> - <gl-sprintf :message="textarea.description"> - <template #code="{content}"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </span> - </template> - </gl-form-group> - </div> -</template> diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js deleted file mode 100644 index d1e3d93938b..00000000000 --- a/app/assets/javascripts/registry/shared/constants.js +++ /dev/null @@ -1,69 +0,0 @@ -import { s__, __ } from '~/locale'; - -export const FETCH_SETTINGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the cleanup policy.', -); - -export const UPDATE_SETTINGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while updating the cleanup policy.', -); - -export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__( - 'ContainerRegistry|Cleanup policy successfully saved.', -); - -export const NAME_REGEX_LENGTH = 255; - -export const ENABLED_TEXT = __('Enabled'); -export const DISABLED_TEXT = __('Disabled'); - -export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Cleanup policy:'); -export const ENABLE_TOGGLE_DESCRIPTION = s__( - 'ContainerRegistry|%{toggleStatus} - Tags matching the patterns defined below will be scheduled for deletion', -); - -export const TEXT_AREA_INVALID_FEEDBACK = s__( - 'ContainerRegistry|The value of this input should be less than 256 characters', -); - -export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:'); -export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Expiration schedule:'); -export const KEEP_N_LABEL = s__('ContainerRegistry|Number of tags to retain:'); -export const NAME_REGEX_LABEL = s__( - 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}', -); -export const NAME_REGEX_PLACEHOLDER = ''; -export const NAME_REGEX_DESCRIPTION = s__( - 'ContainerRegistry|Wildcards such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', -); -export const NAME_REGEX_KEEP_LABEL = s__( - 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}', -); -export const NAME_REGEX_KEEP_PLACEHOLDER = ''; -export const NAME_REGEX_KEEP_DESCRIPTION = s__( - 'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', -); - -export const KEEP_N_OPTIONS = [ - { variable: 1, key: 'ONE_TAG', default: false }, - { variable: 5, key: 'FIVE_TAGS', default: false }, - { variable: 10, key: 'TEN_TAGS', default: true }, - { variable: 25, key: 'TWENTY_FIVE_TAGS', default: false }, - { variable: 50, key: 'FIFTY_TAGS', default: false }, - { variable: 100, key: 'ONE_HUNDRED_TAGS', default: false }, -]; - -export const CADENCE_OPTIONS = [ - { key: 'EVERY_DAY', label: __('Every day'), default: true }, - { key: 'EVERY_WEEK', label: __('Every week'), default: false }, - { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false }, - { key: 'EVERY_MONTH', label: __('Every month'), default: false }, - { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false }, -]; - -export const OLDER_THAN_OPTIONS = [ - { key: 'SEVEN_DAYS', variable: 7, default: false }, - { key: 'FOURTEEN_DAYS', variable: 14, default: false }, - { key: 'THIRTY_DAYS', variable: 30, default: false }, - { key: 'NINETY_DAYS', variable: 90, default: true }, -]; diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue index 7f12c10f6a1..9665ed173b9 100644 --- a/app/assets/javascripts/related_issues/components/issue_token.vue +++ b/app/assets/javascripts/related_issues/components/issue_token.vue @@ -114,7 +114,7 @@ export default { class="js-issue-token-remove-button" @click="onRemoveRequest" > - <gl-icon name="close" aria-hidden="true" /> + <gl-icon name="close" /> </button> </div> </template> diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index 9809b228308..b05a873e939 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -97,7 +97,9 @@ export default { }, beforeDestroy() { const $input = $(this.$refs.input); + // eslint-disable-next-line @gitlab/no-global-event-off $input.off('shown-issues.atwho'); + // eslint-disable-next-line @gitlab/no-global-event-off $input.off('hidden-issues.atwho'); $input.off('inserted-issues.atwho', this.onInput); }, diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue index 6f68b25b6fb..73ea13ddc40 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -204,7 +204,16 @@ export default { onInput({ untouchedRawReferences, touchedReference }) { this.store.addPendingReferences(untouchedRawReferences); - this.inputValue = `${touchedReference}`; + this.formatInput(touchedReference); + }, + formatInput(touchedReference = '') { + const startsWithNumber = String(touchedReference).match(/^[0-9]/) !== null; + + if (startsWithNumber) { + this.inputValue = `#${touchedReference}`; + } else { + this.inputValue = `${touchedReference}`; + } }, onBlur(newValue) { this.processAllReferences(newValue); diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index f245e2bfd2f..0e9975ea81f 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -3,7 +3,7 @@ import { __ } from '~/locale'; import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; import Popover from '~/vue_shared/components/help_popover.vue'; import IssuesList from './issues_list.vue'; -import { status } from '../constants'; +import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants'; export default { name: 'ReportSection', @@ -152,12 +152,12 @@ export default { }, slotName() { if (this.isSuccess) { - return 'success'; + return SLOT_SUCCESS; } else if (this.isLoading) { - return 'loading'; + return SLOT_LOADING; } - return 'error'; + return SLOT_ERROR; }, }, methods: { diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue index 5e9a5b03543..69b0dcf881d 100644 --- a/app/assets/javascripts/reports/components/test_issue_body.vue +++ b/app/assets/javascripts/reports/components/test_issue_body.vue @@ -1,13 +1,13 @@ <script> import { mapActions } from 'vuex'; -import { GlBadge } from '@gitlab/ui'; -import { n__ } from '~/locale'; +import { GlBadge, GlSprintf } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'TestIssueBody', components: { GlBadge, + GlSprintf, }, mixins: [glFeatureFlagsMixin()], props: { @@ -28,18 +28,15 @@ export default { }, computed: { showRecentFailures() { - return this.glFeatures.testFailureHistory && this.issue.recent_failures; + return ( + this.glFeatures.testFailureHistory && + this.issue.recent_failures?.count && + this.issue.recent_failures?.base_branch + ); }, }, methods: { ...mapActions(['openModal']), - recentFailuresText(count) { - return n__( - 'Failed %d time in the last 14 days', - 'Failed %d times in the last 14 days', - count, - ); - }, }, }; </script> @@ -53,7 +50,18 @@ export default { > <gl-badge v-if="isNew" variant="danger" class="gl-mr-2">{{ s__('New') }}</gl-badge> <gl-badge v-if="showRecentFailures" variant="warning" class="gl-mr-2"> - {{ recentFailuresText(issue.recent_failures) }} + <gl-sprintf + :message=" + n__( + 'Reports|Failed %{count} time in %{base_branch} in the last 14 days', + 'Reports|Failed %{count} times in %{base_branch} in the last 14 days', + issue.recent_failures.count, + ) + " + > + <template #count>{{ issue.recent_failures.count }}</template> + <template #base_branch>{{ issue.recent_failures.base_branch }}</template> + </gl-sprintf> </gl-badge> {{ issue.name }} </button> diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index b3905cbfcfb..9250bfd7678 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -18,10 +18,18 @@ export const ICON_SUCCESS = 'success'; export const ICON_NOTFOUND = 'notfound'; export const status = { - LOADING: 'LOADING', - ERROR: 'ERROR', - SUCCESS: 'SUCCESS', + LOADING, + ERROR, + SUCCESS, }; export const ACCESSIBILITY_ISSUE_ERROR = 'error'; export const ACCESSIBILITY_ISSUE_WARNING = 'warning'; + +/** + * Slot names for the ReportSection component, corresponding to the success, + * loading and error statuses. + */ +export const SLOT_SUCCESS = 'success'; +export const SLOT_LOADING = 'loading'; +export const SLOT_ERROR = 'error'; diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js index fd6f4933cfa..2d32daee9d0 100644 --- a/app/assets/javascripts/reports/store/utils.js +++ b/app/assets/javascripts/reports/store/utils.js @@ -62,12 +62,8 @@ export const recentFailuresTextBuilder = (summary = {}) => { } return sprintf( n__( - s__( - 'Reports|%{recentlyFailed} out of %{failed} failed tests has failed more than once in the last 14 days', - ), - s__( - 'Reports|%{recentlyFailed} out of %{failed} failed tests have failed more than once in the last 14 days', - ), + 'Reports|%{recentlyFailed} out of %{failed} failed tests has failed more than once in the last 14 days', + 'Reports|%{recentlyFailed} out of %{failed} failed tests have failed more than once in the last 14 days', recentlyFailed, ), { recentlyFailed, failed }, @@ -83,7 +79,10 @@ export const countRecentlyFailedTests = subject => { return ( [report.new_failures, report.existing_failures, report.resolved_failures] // only count tests which have failed more than once - .map(failureArray => failureArray.filter(failure => failure.recent_failures > 1).length) + .map( + failureArray => + failureArray.filter(failure => failure.recent_failures?.count > 1).length, + ) .reduce((total, count) => total + count, 0) ); }) diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index c9c5aa37645..e2c3f3b81ee 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -52,7 +52,7 @@ export default { <article class="file-holder limited-width-container readme-holder"> <div class="js-file-title file-title-flex-parent"> <div class="file-header-content"> - <gl-icon name="doc-text" aria-hidden="true" /> + <gl-icon name="doc-text" /> <gl-link :href="blob.webPath"> <strong>{{ blob.name }}</strong> </gl-link> diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 87c8aa541d8..6f43f837374 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -23,8 +23,11 @@ Sidebar.initialize = function() { Sidebar.prototype.removeListeners = function() { this.sidebar.off('click', '.sidebar-collapsed-icon'); + // eslint-disable-next-line @gitlab/no-global-event-off this.sidebar.off('hidden.gl.dropdown'); + // eslint-disable-next-line @gitlab/no-global-event-off $('.dropdown').off('loading.gl.dropdown'); + // eslint-disable-next-line @gitlab/no-global-event-off $('.dropdown').off('loaded.gl.dropdown'); $(document).off('click', '.js-sidebar-toggle'); }; diff --git a/app/assets/javascripts/search/group_filter/components/group_filter.vue b/app/assets/javascripts/search/group_filter/components/group_filter.vue deleted file mode 100644 index 4b7963c5187..00000000000 --- a/app/assets/javascripts/search/group_filter/components/group_filter.vue +++ /dev/null @@ -1,124 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownItem, - GlSearchBoxByType, - GlLoadingIcon, - GlIcon, - GlSkeletonLoader, - GlTooltipDirective, -} from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import { isEmpty } from 'lodash'; -import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; -import { ANY_GROUP, GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM } from '../constants'; - -export default { - name: 'GroupFilter', - components: { - GlDropdown, - GlDropdownItem, - GlSearchBoxByType, - GlLoadingIcon, - GlIcon, - GlSkeletonLoader, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - initialGroup: { - type: Object, - required: false, - default: () => ({}), - }, - }, - data() { - return { - groupSearch: '', - }; - }, - computed: { - ...mapState(['groups', 'fetchingGroups']), - selectedGroup: { - get() { - return isEmpty(this.initialGroup) ? ANY_GROUP : this.initialGroup; - }, - set(group) { - visitUrl(setUrlParams({ [GROUP_QUERY_PARAM]: group.id, [PROJECT_QUERY_PARAM]: null })); - }, - }, - }, - methods: { - ...mapActions(['fetchGroups']), - isGroupSelected(group) { - return group.id === this.selectedGroup.id; - }, - handleGroupChange(group) { - this.selectedGroup = group; - }, - }, - ANY_GROUP, -}; -</script> - -<template> - <gl-dropdown - ref="groupFilter" - class="gl-w-full" - menu-class="gl-w-full!" - toggle-class="gl-text-truncate gl-reset-line-height!" - :header-text="__('Filter results by group')" - @show="fetchGroups(groupSearch)" - > - <template #button-content> - <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate"> - {{ selectedGroup.name }} - </span> - <gl-loading-icon v-if="fetchingGroups" inline class="mr-2" /> - <gl-icon - v-if="!isGroupSelected($options.ANY_GROUP)" - v-gl-tooltip - name="clear" - :title="__('Clear')" - class="gl-text-gray-200! gl-hover-text-blue-800!" - @click.stop="handleGroupChange($options.ANY_GROUP)" - /> - <gl-icon name="chevron-down" /> - </template> - <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white"> - <gl-search-box-by-type - v-model="groupSearch" - class="m-2" - :debounce="500" - @input="fetchGroups" - /> - <gl-dropdown-item - class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2" - :is-check-item="true" - :is-checked="isGroupSelected($options.ANY_GROUP)" - @click="handleGroupChange($options.ANY_GROUP)" - > - {{ $options.ANY_GROUP.name }} - </gl-dropdown-item> - </div> - <div v-if="!fetchingGroups"> - <gl-dropdown-item - v-for="group in groups" - :key="group.id" - :is-check-item="true" - :is-checked="isGroupSelected(group)" - @click="handleGroupChange(group)" - > - {{ group.full_name }} - </gl-dropdown-item> - </div> - <div v-if="fetchingGroups" class="mx-3 mt-2"> - <gl-skeleton-loader :height="100"> - <rect y="0" width="90%" height="20" rx="4" /> - <rect y="40" width="70%" height="20" rx="4" /> - <rect y="80" width="80%" height="20" rx="4" /> - </gl-skeleton-loader> - </div> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/search/group_filter/constants.js b/app/assets/javascripts/search/group_filter/constants.js deleted file mode 100644 index 9bd92eaa130..00000000000 --- a/app/assets/javascripts/search/group_filter/constants.js +++ /dev/null @@ -1,10 +0,0 @@ -import { __ } from '~/locale'; - -export const ANY_GROUP = Object.freeze({ - id: null, - name: __('Any'), -}); - -export const GROUP_QUERY_PARAM = 'group_id'; - -export const PROJECT_QUERY_PARAM = 'project_id'; diff --git a/app/assets/javascripts/search/group_filter/index.js b/app/assets/javascripts/search/group_filter/index.js deleted file mode 100644 index 9b009bc0305..00000000000 --- a/app/assets/javascripts/search/group_filter/index.js +++ /dev/null @@ -1,28 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import GroupFilter from './components/group_filter.vue'; - -Vue.use(Translate); - -export default store => { - let initialGroup; - const el = document.getElementById('js-search-group-dropdown'); - - const { initialGroupData } = el.dataset; - - initialGroup = JSON.parse(initialGroupData); - initialGroup = convertObjectPropsToCamelCase(initialGroup, { deep: true }); - - return new Vue({ - el, - store, - render(createElement) { - return createElement(GroupFilter, { - props: { - initialGroup, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index 781a564d077..d2bb1ccfc44 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,7 +1,7 @@ import { queryToObject } from '~/lib/utils/url_utility'; import createStore from './store'; +import { initTopbar } from './topbar'; import { initSidebar } from './sidebar'; -import initGroupFilter from './group_filter'; export const initSearchApp = () => { // Similar to url_utility.decodeUrlParameter @@ -9,6 +9,6 @@ export const initSearchApp = () => { const sanitizedSearch = window.location.search.replace(/\+/g, '%20'); const store = createStore({ query: queryToObject(sanitizedSearch) }); + initTopbar(store); initSidebar(store); - initGroupFilter(store); }; diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index aa11b2025f2..e233d18b716 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -26,7 +26,7 @@ export default { <template> <form - class="gl-display-flex gl-flex-direction-column col-md-3 gl-mr-4 gl-mb-6 gl-mt-5" + class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5" @submit.prevent="applyQuery" > <status-filter /> diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 447278aa223..082beb5930d 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -16,6 +16,28 @@ export const fetchGroups = ({ commit }, search) => { }); }; +export const fetchProjects = ({ commit, state }, search) => { + commit(types.REQUEST_PROJECTS); + const groupId = state.query?.group_id; + const callback = data => { + if (data) { + commit(types.RECEIVE_PROJECTS_SUCCESS, data); + } else { + createFlash({ message: __('There was an error fetching projects') }); + commit(types.RECEIVE_PROJECTS_ERROR); + } + }; + + if (groupId) { + Api.groupProjects(groupId, search, {}, callback); + } else { + // The .catch() is due to the API method not handling a rejection properly + Api.projects(search, { order_by: 'id' }, callback).catch(() => { + callback(); + }); + } +}; + export const setQuery = ({ commit }, { key, value }) => { commit(types.SET_QUERY, { key, value }); }; diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js index 2482621d4d7..a6430b53c4f 100644 --- a/app/assets/javascripts/search/store/mutation_types.js +++ b/app/assets/javascripts/search/store/mutation_types.js @@ -2,4 +2,8 @@ export const REQUEST_GROUPS = 'REQUEST_GROUPS'; export const RECEIVE_GROUPS_SUCCESS = 'RECEIVE_GROUPS_SUCCESS'; export const RECEIVE_GROUPS_ERROR = 'RECEIVE_GROUPS_ERROR'; +export const REQUEST_PROJECTS = 'REQUEST_PROJECTS'; +export const RECEIVE_PROJECTS_SUCCESS = 'RECEIVE_PROJECTS_SUCCESS'; +export const RECEIVE_PROJECTS_ERROR = 'RECEIVE_PROJECTS_ERROR'; + export const SET_QUERY = 'SET_QUERY'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index e57850b870e..91d7cf66c8f 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -12,6 +12,17 @@ export default { state.fetchingGroups = false; state.groups = []; }, + [types.REQUEST_PROJECTS](state) { + state.fetchingProjects = true; + }, + [types.RECEIVE_PROJECTS_SUCCESS](state, data) { + state.fetchingProjects = false; + state.projects = data; + }, + [types.RECEIVE_PROJECTS_ERROR](state) { + state.fetchingProjects = false; + state.projects = []; + }, [types.SET_QUERY](state, { key, value }) { state.query[key] = value; }, diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index 70a8aab9998..9a0d61d0b93 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -2,5 +2,7 @@ const createState = ({ query }) => ({ query, groups: [], fetchingGroups: false, + projects: [], + fetchingProjects: false, }); export default createState; diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue new file mode 100644 index 00000000000..fce9ec17d23 --- /dev/null +++ b/app/assets/javascripts/search/topbar/components/group_filter.vue @@ -0,0 +1,49 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { isEmpty } from 'lodash'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import SearchableDropdown from './searchable_dropdown.vue'; +import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; + +export default { + name: 'GroupFilter', + components: { + SearchableDropdown, + }, + props: { + initialData: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + ...mapState(['groups', 'fetchingGroups']), + selectedGroup() { + return isEmpty(this.initialData) ? ANY_OPTION : this.initialData; + }, + }, + methods: { + ...mapActions(['fetchGroups']), + handleGroupChange(group) { + visitUrl( + setUrlParams({ [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null }), + ); + }, + }, + GROUP_DATA, +}; +</script> + +<template> + <searchable-dropdown + :header-text="$options.GROUP_DATA.headerText" + :selected-display-value="$options.GROUP_DATA.selectedDisplayValue" + :items-display-value="$options.GROUP_DATA.itemsDisplayValue" + :loading="fetchingGroups" + :selected-item="selectedGroup" + :items="groups" + @search="fetchGroups" + @change="handleGroupChange" + /> +</template> diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue new file mode 100644 index 00000000000..3f1f3848ac7 --- /dev/null +++ b/app/assets/javascripts/search/topbar/components/project_filter.vue @@ -0,0 +1,52 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import SearchableDropdown from './searchable_dropdown.vue'; +import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; + +export default { + name: 'ProjectFilter', + components: { + SearchableDropdown, + }, + props: { + initialData: { + type: Object, + required: false, + default: () => null, + }, + }, + computed: { + ...mapState(['projects', 'fetchingProjects']), + selectedProject() { + return this.initialData ? this.initialData : ANY_OPTION; + }, + }, + methods: { + ...mapActions(['fetchProjects']), + handleProjectChange(project) { + // This determines if we need to update the group filter or not + const queryParams = { + ...(project.namespace_id && { [GROUP_DATA.queryParam]: project.namespace_id }), + [PROJECT_DATA.queryParam]: project.id, + }; + + visitUrl(setUrlParams(queryParams)); + }, + }, + PROJECT_DATA, +}; +</script> + +<template> + <searchable-dropdown + :header-text="$options.PROJECT_DATA.headerText" + :selected-display-value="$options.PROJECT_DATA.selectedDisplayValue" + :items-display-value="$options.PROJECT_DATA.itemsDisplayValue" + :loading="fetchingProjects" + :selected-item="selectedProject" + :items="projects" + @search="fetchProjects" + @change="handleProjectChange" + /> +</template> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue new file mode 100644 index 00000000000..14577fd7d7a --- /dev/null +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue @@ -0,0 +1,144 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, + GlIcon, + GlButton, + GlSkeletonLoader, + GlTooltipDirective, +} from '@gitlab/ui'; + +import { ANY_OPTION } from '../constants'; + +export default { + name: 'SearchableDropdown', + components: { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, + GlIcon, + GlButton, + GlSkeletonLoader, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + headerText: { + type: String, + required: false, + default: "__('Filter')", + }, + selectedDisplayValue: { + type: String, + required: false, + default: 'name', + }, + itemsDisplayValue: { + type: String, + required: false, + default: 'name', + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + selectedItem: { + type: Object, + required: true, + }, + items: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + searchText: '', + }; + }, + methods: { + isSelected(selected) { + return selected.id === this.selectedItem.id; + }, + openDropdown() { + this.$emit('search', this.searchText); + }, + resetDropdown() { + this.$emit('change', ANY_OPTION); + }, + }, + ANY_OPTION, +}; +</script> + +<template> + <gl-dropdown + class="gl-w-full" + menu-class="gl-w-full!" + toggle-class="gl-text-truncate" + :header-text="headerText" + @show="$emit('search', searchText)" + @shown="$refs.searchBox.focusInput()" + > + <template #button-content> + <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate"> + {{ selectedItem[selectedDisplayValue] }} + </span> + <gl-loading-icon v-if="loading" inline class="gl-mr-3" /> + <gl-button + v-if="!isSelected($options.ANY_OPTION)" + v-gl-tooltip + name="clear" + category="tertiary" + :title="__('Clear')" + class="gl-p-0! gl-mr-2" + @keydown.enter.stop="resetDropdown" + @click.stop="resetDropdown" + > + <gl-icon name="clear" class="gl-text-gray-200! gl-hover-text-blue-800!" /> + </gl-button> + <gl-icon name="chevron-down" /> + </template> + <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white"> + <gl-search-box-by-type + ref="searchBox" + v-model="searchText" + class="gl-m-3" + :debounce="500" + @input="$emit('search', searchText)" + /> + <gl-dropdown-item + class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2" + :is-check-item="true" + :is-checked="isSelected($options.ANY_OPTION)" + @click="resetDropdown" + > + {{ $options.ANY_OPTION.name }} + </gl-dropdown-item> + </div> + <div v-if="!loading"> + <gl-dropdown-item + v-for="item in items" + :key="item.id" + :is-check-item="true" + :is-checked="isSelected(item)" + @click="$emit('change', item)" + > + {{ item[itemsDisplayValue] }} + </gl-dropdown-item> + </div> + <div v-if="loading" class="gl-mx-4 gl-mt-3"> + <gl-skeleton-loader :height="100"> + <rect y="0" width="90%" height="20" rx="4" /> + <rect y="40" width="70%" height="20" rx="4" /> + <rect y="80" width="80%" height="20" rx="4" /> + </gl-skeleton-loader> + </div> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/search/topbar/constants.js b/app/assets/javascripts/search/topbar/constants.js new file mode 100644 index 00000000000..3944b2c8374 --- /dev/null +++ b/app/assets/javascripts/search/topbar/constants.js @@ -0,0 +1,21 @@ +import { __ } from '~/locale'; + +export const ANY_OPTION = Object.freeze({ + id: null, + name: __('Any'), + name_with_namespace: __('Any'), +}); + +export const GROUP_DATA = { + headerText: __('Filter results by group'), + queryParam: 'group_id', + selectedDisplayValue: 'name', + itemsDisplayValue: 'full_name', +}; + +export const PROJECT_DATA = { + headerText: __('Filter results by project'), + queryParam: 'project_id', + selectedDisplayValue: 'name_with_namespace', + itemsDisplayValue: 'name_with_namespace', +}; diff --git a/app/assets/javascripts/search/topbar/index.js b/app/assets/javascripts/search/topbar/index.js new file mode 100644 index 00000000000..024544148a0 --- /dev/null +++ b/app/assets/javascripts/search/topbar/index.js @@ -0,0 +1,44 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import GroupFilter from './components/group_filter.vue'; +import ProjectFilter from './components/project_filter.vue'; + +Vue.use(Translate); + +const mountSearchableDropdown = (store, { id, component }) => { + const el = document.getElementById(id); + + if (!el) { + return false; + } + + let { initialData } = el.dataset; + + initialData = JSON.parse(initialData); + + return new Vue({ + el, + store, + render(createElement) { + return createElement(component, { + props: { + initialData, + }, + }); + }, + }); +}; + +const searchableDropdowns = [ + { + id: 'js-search-group-dropdown', + component: GroupFilter, + }, + { + id: 'js-search-project-dropdown', + component: ProjectFilter, + }, +]; + +export const initTopbar = store => + searchableDropdowns.map(dropdown => mountSearchableDropdown(store, dropdown)); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 7073b9ca12d..97674348436 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -250,6 +250,10 @@ export class SearchAutocomplete { url: `${mrPath}/?assignee_username=${userName}`, }, { + text: s__("SearchAutocomplete|Merge requests that I'm a reviewer"), + url: `${mrPath}/?reviewer_username=${userName}`, + }, + { text: s__("SearchAutocomplete|Merge requests I've created"), url: `${mrPath}/?author_username=${userName}`, }, diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 30e4e92d0cc..f2685dfbcdb 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -1,8 +1,9 @@ <script> /* eslint-disable vue/no-v-html */ import $ from 'jquery'; +import Vue from 'vue'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; -import { GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui'; +import { GlToast, GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __, s__ } from '~/locale'; import Api from '~/api'; @@ -16,6 +17,8 @@ export const AVAILABILITY_STATUS = { NOT_SET: 'not_set', }; +Vue.use(GlToast); + export default { components: { GlIcon, diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js index d22aca35e09..18160421136 100644 --- a/app/assets/javascripts/settings_panels.js +++ b/app/assets/javascripts/settings_panels.js @@ -3,6 +3,7 @@ import { __ } from './locale'; function expandSection($section) { $section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Collapse')); + // eslint-disable-next-line @gitlab/no-global-event-off $section .find('.settings-content') .off('scroll.expandSection') diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue index 00f1339d7f2..da9ff407faf 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -1,7 +1,11 @@ <script> +import { GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; export default { + components: { + GlIcon, + }, props: { user: { type: Object, @@ -46,6 +50,6 @@ export default { class="avatar avatar-inline m-0" data-qa-selector="avatar_image" /> - <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i> + <gl-icon v-if="hasMergeIcon" name="warning-solid" aria-hidden="true" class="merge-icon" /> </span> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index 5f8ba844218..26e88523abb 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -66,7 +66,7 @@ export default { href="#" role="button" > - <gl-icon aria-hidden="true" data-hidden="true" name="chevron-double-lg-right" :size="12" /> + <gl-icon data-hidden="true" name="chevron-double-lg-right" :size="12" /> </a> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index eabd4d88d52..362ca4ab917 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -112,11 +112,12 @@ export default { /> <button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button"> <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> - <i + <gl-icon v-if="isMergeRequest && !allAssigneesCanMerge" + name="warning-solid" aria-hidden="true" - class="fa fa-exclamation-triangle merge-icon" - ></i> + class="merge-icon" + /> </button> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index cf6a0a4a151..3c1b3afe889 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -1,9 +1,11 @@ <script> +import { GlButton } from '@gitlab/ui'; import { n__ } from '~/locale'; import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue'; export default { components: { + GlButton, UncollapsedAssigneeList, }, inject: ['rootPath'], @@ -27,9 +29,15 @@ export default { <template> <div class="gl-display-flex gl-flex-direction-column"> <div v-if="emptyUsers" data-testid="none"> - <span> - {{ __('None') }} - </span> + <span> {{ __('None') }} -</span> + <gl-button + data-testid="assign-yourself" + category="tertiary" + variant="link" + @click="$emit('assign-self')" + > + <span class="gl-text-gray-400">{{ __('assign yourself') }}</span> + </gl-button> </div> <uncollapsed-assignee-list v-else :users="users" :root-path="rootPath" /> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 2530cb77acd..ce120ff82f3 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -77,7 +77,7 @@ export default { class="sidebar-collapsed-icon" @click="toggleForm" > - <gl-icon :name="confidentialityIcon" aria-hidden="true" /> + <gl-icon :name="confidentialityIcon" /> </div> <div class="title hide-collapsed"> {{ __('Confidentiality') }} @@ -101,16 +101,11 @@ export default { :issuable-type="issuableType" /> <div v-if="!confidential" class="no-value sidebar-item-value" data-testid="not-confidential"> - <gl-icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" /> + <gl-icon :size="16" name="eye" class="sidebar-item-icon inline" /> {{ __('Not confidential') }} </div> <div v-else class="value sidebar-item-value hide-collapsed"> - <gl-icon - :size="16" - name="eye-slash" - aria-hidden="true" - class="sidebar-item-icon inline is-active" - /> + <gl-icon :size="16" name="eye-slash" class="sidebar-item-icon inline is-active" /> {{ confidentialText }} </div> </div> diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index 1785174e8d7..07abfa8d103 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -1,7 +1,7 @@ <script> import $ from 'jquery'; import { camelCase, difference, union } from 'lodash'; -import updateIssueLabelsMutation from '~/boards/queries/issue_set_labels.mutation.graphql'; +import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import createFlash from '~/flash'; import { IssuableType } from '~/issue_show/constants'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue index 45707c18f7b..10b16a44261 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -97,11 +97,12 @@ export default { <collapsed-reviewer v-for="user in collapsedUsers" :key="user.id" :user="user" /> <button v-if="hasMoreThanTwoReviewers" class="btn-link" type="button"> <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> - <i + <gl-icon v-if="!allReviewersCanMerge" + name="warning-solid" aria-hidden="true" - class="fa fa-exclamation-triangle merge-icon" - ></i> + class="merge-icon" + /> </button> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue index 9fa3fa38eac..7961b7cd679 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue @@ -1,9 +1,13 @@ <script> // 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 { GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; export default { + components: { + GlIcon, + }, props: { user: { type: Object, @@ -38,6 +42,6 @@ export default { class="avatar avatar-inline m-0" data-qa-selector="avatar_image" /> - <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i> + <gl-icon v-if="hasMergeIcon" name="warning-solid" aria-hidden="true" class="merge-icon" /> </span> </template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue index 4f4f7002dc9..d64b483acb1 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -59,7 +59,7 @@ export default { href="#" role="button" > - <gl-icon aria-hidden="true" data-hidden="true" name="chevron-double-lg-right" :size="12" /> + <gl-icon data-hidden="true" name="chevron-double-lg-right" :size="12" /> </a> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index 6e004084077..6d21936791c 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -114,12 +114,7 @@ export default { class="sidebar-collapsed-icon" @click="onClickCollapsedIcon" > - <gl-icon - :name="notificationIcon" - :size="16" - aria-hidden="true" - class="sidebar-item-icon is-active" - /> + <gl-icon :name="notificationIcon" :size="16" class="sidebar-item-icon is-active" /> </span> <span class="issuable-header-text hide-collapsed float-left"> {{ notificationText }} </span> <toggle-button diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index 51719df313f..1e3e870ec83 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -1,19 +1,18 @@ <script> -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; const MARK_TEXT = __('Mark as done'); const TODO_TEXT = __('Add a To-Do'); export default { - directives: { - tooltip, - }, components: { GlIcon, GlLoadingIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { issuableId: { type: Number, @@ -71,16 +70,13 @@ export default { <template> <button - v-tooltip + v-gl-tooltip.left.viewport :class="buttonClasses" :title="buttonTooltip" :aria-label="buttonLabel" :data-issuable-id="issuableId" :data-issuable-type="issuableType" type="button" - data-container="body" - data-placement="left" - data-boundary="viewport" @click="handleButtonClick" > <gl-icon diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 3492f19c996..f751df6367e 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -23,7 +23,8 @@ export default class SingleFileDiff { this.file = file; this.toggleDiff = this.toggleDiff.bind(this); this.content = $('.diff-content', this.file); - this.$toggleIcon = $('.diff-toggle-caret', this.file); + this.$chevronRightIcon = $('.diff-toggle-caret .chevron-right', this.file); + this.$chevronDownIcon = $('.diff-toggle-caret .chevron-down', this.file); this.diffForPath = this.content.find('[data-diff-for-path]').data('diffForPath'); this.isOpen = !this.diffForPath; if (this.diffForPath) { @@ -34,13 +35,13 @@ export default class SingleFileDiff { .hide(); this.content = null; this.collapsedContent.after(this.loadingContent); - this.$toggleIcon.addClass('fa-caret-right'); + this.$chevronRightIcon.removeClass('gl-display-none'); } else { this.collapsedContent = $(WRAPPER) .html(COLLAPSED_HTML) .hide(); this.content.after(this.collapsedContent); - this.$toggleIcon.addClass('fa-caret-down'); + this.$chevronDownIcon.removeClass('gl-display-none'); } $('.js-file-title, .click-to-expand', this.file).on('click', e => { @@ -52,20 +53,23 @@ export default class SingleFileDiff { if ( !$target.hasClass('js-file-title') && !$target.hasClass('click-to-expand') && - !$target.hasClass('diff-toggle-caret') + !$target.closest('.diff-toggle-caret').length > 0 ) return; this.isOpen = !this.isOpen; if (!this.isOpen && !this.hasError) { this.content.hide(); - this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down'); + this.$chevronRightIcon.removeClass('gl-display-none'); + this.$chevronDownIcon.addClass('gl-display-none'); this.collapsedContent.show(); } else if (this.content) { this.collapsedContent.hide(); this.content.show(); - this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right'); + this.$chevronDownIcon.removeClass('gl-display-none'); + this.$chevronRightIcon.addClass('gl-display-none'); } else { - this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right'); + this.$chevronDownIcon.removeClass('gl-display-none'); + this.$chevronRightIcon.addClass('gl-display-none'); return this.getContentHTML(cb); } } diff --git a/app/assets/javascripts/smart_interval.js b/app/assets/javascripts/smart_interval.js index 0e52d2d8010..c4655d35cf0 100644 --- a/app/assets/javascripts/smart_interval.js +++ b/app/assets/javascripts/smart_interval.js @@ -95,6 +95,7 @@ export default class SmartInterval { window.removeEventListener('blur', this.onWindowVisibilityChange); window.removeEventListener('focus', this.onWindowVisibilityChange); this.cancel(); + // eslint-disable-next-line @gitlab/no-global-event-off $(document) .off('visibilitychange') .off('beforeunload'); diff --git a/app/assets/javascripts/sourcegraph/index.js b/app/assets/javascripts/sourcegraph/index.js index 796e90bf08e..487a565b152 100644 --- a/app/assets/javascripts/sourcegraph/index.js +++ b/app/assets/javascripts/sourcegraph/index.js @@ -17,7 +17,7 @@ export default function initSourcegraph() { return; } - const assetsUrl = new URL('/assets/webpack/sourcegraph/', window.location.href); + const assetsUrl = new URL(process.env.SOURCEGRAPH_PUBLIC_PATH, window.location.href); const scriptPath = new URL('scripts/integration.bundle.js', assetsUrl).href; window.SOURCEGRAPH_ASSETS_URL = assetsUrl.href; diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index 69eabfe5339..b47126cdeb3 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -60,6 +60,7 @@ export default { }, data() { return { + formattedMarkdown: null, parsedSource: parseSourceFile(this.preProcess(true, this.content)), editorMode: EDITOR_TYPES.wysiwyg, hasMatter: false, @@ -140,10 +141,14 @@ export default { onSubmit() { const preProcessedContent = this.preProcess(false, this.parsedSource.content()); this.$emit('submit', { + formattedMarkdown: this.formattedMarkdown, content: preProcessedContent, images: this.$options.imageRepository.getAll(), }); }, + onEditorLoad({ formattedMarkdown }) { + this.formattedMarkdown = formattedMarkdown; + }, }, }; </script> @@ -167,6 +172,7 @@ export default { @modeChange="onModeChange" @input="onInputChange" @uploadImage="onUploadImage" + @load="onEditorLoad" /> <unsaved-changes-confirm-dialog :modified="isSaveable" /> <publish-toolbar diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index faa4026c064..4cabd943e22 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -15,10 +15,21 @@ export const LOAD_CONTENT_ERROR = __( 'An error ocurred while loading your content. Please try again.', ); +export const DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE = s__( + 'StaticSiteEditor|Automatic formatting changes', +); + +export const DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION = s__( + 'StaticSiteEditor|Markdown formatting preferences introduced by the Static Site Editor', +); + export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor'); 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 MR_META_LOCAL_STORAGE_KEY = 'sse-merge-request-meta-storage-key'; diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js index 4137ede49c6..1bd79d40071 100644 --- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js @@ -4,7 +4,17 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql'; const submitContentChangesResolver = ( _, - { input: { project: projectId, username, sourcePath, content, images, mergeRequestMeta } }, + { + input: { + project: projectId, + username, + sourcePath, + content, + images, + mergeRequestMeta, + formattedMarkdown, + }, + }, { cache }, ) => { return submitContentChanges({ @@ -14,6 +24,7 @@ const submitContentChangesResolver = ( content, images, mergeRequestMeta, + formattedMarkdown, }).then(savedContentMeta => { const data = produce(savedContentMeta, draftState => { return { diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 68943113c14..1e52e73294e 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -53,6 +53,7 @@ export default { return { content: null, images: null, + formattedMarkdown: null, submitChangesError: null, isSavingChanges: false, }; @@ -79,9 +80,10 @@ export default { onDismissError() { this.submitChangesError = null; }, - onPrepareSubmit({ content, images }) { + onPrepareSubmit({ formattedMarkdown, content, images }) { this.content = content; this.images = images; + this.formattedMarkdown = formattedMarkdown; this.isSavingChanges = true; this.$refs.editMetaModal.show(); @@ -110,6 +112,7 @@ export default { username: this.appData.username, sourcePath: this.appData.sourcePath, content: this.content, + formattedMarkdown: this.formattedMarkdown, images: this.images, mergeRequestMeta, }, 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 8623a671a7d..e57028ea05a 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 @@ -10,6 +10,10 @@ 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, + DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE, + DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION, } from '../constants'; const createBranch = (projectId, branch) => @@ -45,22 +49,24 @@ const createImageActions = (images, markdown) => { return actions; }; -const commitContent = (projectId, message, branch, sourcePath, content, images) => { +const createUpdateSourceFileAction = (sourcePath, content) => [ + convertObjectPropsToSnakeCase({ + action: 'update', + filePath: 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); return Api.commitMultiple( projectId, convertObjectPropsToSnakeCase({ branch, commitMessage: message, - actions: [ - convertObjectPropsToSnakeCase({ - action: 'update', - filePath: sourcePath, - content, - }), - ...createImageActions(images, content), - ], + actions, }), ).catch(() => { throw new Error(SUBMIT_CHANGES_COMMIT_ERROR); @@ -75,6 +81,7 @@ const createMergeRequest = ( targetBranch = DEFAULT_TARGET_BRANCH, ) => { Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_MERGE_REQUEST); + Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST); return Api.createProjectMergeRequest( projectId, @@ -96,6 +103,7 @@ const submitContentChanges = ({ content, images, mergeRequestMeta, + formattedMarkdown, }) => { const branch = generateBranchName(username); const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta; @@ -103,10 +111,25 @@ const submitContentChanges = ({ return createBranch(projectId, branch) .then(({ data: { web_url: url } }) => { + const message = `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`; + Object.assign(meta, { branch: { label: branch, url } }); - return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content, images); + return formattedMarkdown + ? commit( + projectId, + message, + branch, + createUpdateSourceFileAction(sourcePath, formattedMarkdown), + ) + : meta; }) + .then(() => + commit(projectId, mergeRequestTitle, branch, [ + ...createUpdateSourceFileAction(sourcePath, content), + ...createImageActions(images, content), + ]), + ) .then(({ data: { short_id: label, web_url: url } }) => { Object.assign(meta, { commit: { label, url } }); diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js index cf9064aba57..bae320cb705 100644 --- a/app/assets/javascripts/terminal/terminal.js +++ b/app/assets/javascripts/terminal/terminal.js @@ -25,6 +25,7 @@ export default class GLTerminal { this.setSocketUrl(); this.createTerminal(); + // eslint-disable-next-line @gitlab/no-global-event-off $(window) .off('resize.terminal') .on('resize.terminal', () => { @@ -104,6 +105,7 @@ export default class GLTerminal { } dispose() { + // eslint-disable-next-line @gitlab/no-global-event-off this.terminal.off('data'); this.terminal.dispose(); this.socket.close(); diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue index 2e4c18c5a5b..d0d49233334 100644 --- a/app/assets/javascripts/terraform/components/states_table.vue +++ b/app/assets/javascripts/terraform/components/states_table.vue @@ -1,16 +1,22 @@ <script> -import { GlBadge, GlIcon, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui'; +import { GlBadge, GlIcon, GlLink, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import StateActions from './states_table_actions.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { components: { + CiBadge, GlBadge, GlIcon, + GlLink, GlSprintf, GlTable, GlTooltip, + StateActions, TimeAgoTooltip, }, mixins: [timeagoMixin], @@ -19,28 +25,73 @@ export default { required: true, type: Array, }, + terraformAdmin: { + required: false, + type: Boolean, + default: false, + }, }, computed: { fields() { - return [ + const columns = [ { key: 'name', - thClass: 'gl-display-none', + label: this.$options.i18n.name, + }, + { + key: 'pipeline', + label: this.$options.i18n.pipeline, }, { key: 'updated', - thClass: 'gl-display-none', - tdClass: 'gl-text-right', + label: this.$options.i18n.details, }, ]; + + if (this.terraformAdmin) { + columns.push({ + key: 'actions', + label: this.$options.i18n.actions, + thClass: 'gl-w-12', + tdClass: 'gl-text-right', + }); + } + + return columns; }, }, + i18n: { + actions: s__('Terraform|Actions'), + details: s__('Terraform|Details'), + jobStatus: s__('Terraform|Job status'), + locked: s__('Terraform|Locked'), + lockedByUser: s__('Terraform|Locked by %{user} %{timeAgo}'), + name: s__('Terraform|Name'), + pipeline: s__('Terraform|Pipeline'), + unknownUser: s__('Terraform|Unknown User'), + updatedUser: s__('Terraform|%{user} updated %{timeAgo}'), + }, methods: { createdByUserName(item) { return item.latestVersion?.createdByUser?.name; }, lockedByUserName(item) { - return item.lockedByUser?.name || s__('Terraform|Unknown User'); + return item.lockedByUser?.name || this.$options.i18n.unknownUser; + }, + pipelineDetailedStatus(item) { + return item.latestVersion?.job?.detailedStatus; + }, + pipelineID(item) { + let id = item.latestVersion?.job?.pipeline?.id; + + if (id) { + id = getIdFromGraphQLId(id); + } + + return id; + }, + pipelinePath(item) { + return item.latestVersion?.job?.pipeline?.path; }, updatedTime(item) { return item.latestVersion?.updatedAt || item.updatedAt; @@ -50,25 +101,34 @@ export default { </script> <template> - <gl-table :items="states" :fields="fields" data-testid="terraform-states-table"> + <gl-table + :items="states" + :fields="fields" + data-testid="terraform-states-table" + fixed + stacked="md" + > <template #cell(name)="{ item }"> - <div class="gl-display-flex align-items-center" data-testid="terraform-states-table-name"> + <div + class="gl-display-flex align-items-center gl-justify-content-end gl-justify-content-md-start" + data-testid="terraform-states-table-name" + > <p class="gl-font-weight-bold gl-m-0 gl-text-gray-900"> {{ item.name }} </p> - <div v-if="item.lockedAt" id="terraformLockedBadgeContainer" class="gl-mx-2"> - <gl-badge id="terraformLockedBadge"> + <div v-if="item.lockedAt" :id="`terraformLockedBadgeContainer${item.name}`" class="gl-mx-2"> + <gl-badge :id="`terraformLockedBadge${item.name}`"> <gl-icon name="lock" /> - {{ s__('Terraform|Locked') }} + {{ $options.i18n.locked }} </gl-badge> <gl-tooltip - container="terraformLockedBadgeContainer" + :container="`terraformLockedBadgeContainer${item.name}`" + :target="`terraformLockedBadge${item.name}`" placement="right" - target="terraformLockedBadge" > - <gl-sprintf :message="s__('Terraform|Locked by %{user} %{timeAgo}')"> + <gl-sprintf :message="$options.i18n.lockedByUser"> <template #user> {{ lockedByUserName(item) }} </template> @@ -82,9 +142,37 @@ export default { </div> </template> + <template #cell(pipeline)="{ item }"> + <div data-testid="terraform-states-table-pipeline" class="gl-min-h-7"> + <gl-link v-if="pipelineID(item)" :href="pipelinePath(item)"> + #{{ pipelineID(item) }} + </gl-link> + + <div + v-if="pipelineDetailedStatus(item)" + :id="`terraformJobStatusContainer${item.name}`" + class="gl-my-2" + > + <ci-badge + :id="`terraformJobStatus${item.name}`" + :status="pipelineDetailedStatus(item)" + class="gl-py-1" + /> + + <gl-tooltip + :container="`terraformJobStatusContainer${item.name}`" + :target="`terraformJobStatus${item.name}`" + placement="right" + > + {{ $options.i18n.jobStatus }} + </gl-tooltip> + </div> + </div> + </template> + <template #cell(updated)="{ item }"> <p class="gl-m-0" data-testid="terraform-states-table-updated"> - <gl-sprintf :message="s__('Terraform|%{user} updated %{timeAgo}')"> + <gl-sprintf :message="$options.i18n.updatedUser"> <template #user> <span v-if="item.latestVersion"> {{ createdByUserName(item) }} @@ -97,5 +185,9 @@ export default { </gl-sprintf> </p> </template> + + <template v-if="terraformAdmin" #cell(actions)="{ item }"> + <state-actions :state="item" /> + </template> </gl-table> </template> diff --git a/app/assets/javascripts/terraform/components/states_table_actions.vue b/app/assets/javascripts/terraform/components/states_table_actions.vue new file mode 100644 index 00000000000..44b0713e544 --- /dev/null +++ b/app/assets/javascripts/terraform/components/states_table_actions.vue @@ -0,0 +1,192 @@ +<script> +import { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlFormGroup, + GlFormInput, + GlIcon, + GlModal, + GlSprintf, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; +import lockState from '../graphql/mutations/lock_state.mutation.graphql'; +import unlockState from '../graphql/mutations/unlock_state.mutation.graphql'; +import removeState from '../graphql/mutations/remove_state.mutation.graphql'; + +export default { + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlFormGroup, + GlFormInput, + GlIcon, + GlModal, + GlSprintf, + }, + props: { + state: { + required: true, + type: Object, + }, + }, + data() { + return { + loading: false, + showRemoveModal: false, + removeConfirmText: '', + }; + }, + i18n: { + downloadJSON: s__('Terraform|Download JSON'), + lock: s__('Terraform|Lock'), + modalBody: s__( + 'Terraform|You are about to remove the State file %{name}. This will permanently delete all the State versions and history. The infrastructure provisioned previously will remain intact, only the state file with all its versions are to be removed. This action is non-revertible.', + ), + modalCancel: s__('Terraform|Cancel'), + modalHeader: s__('Terraform|Are you sure you want to remove the Terraform State %{name}?'), + modalInputLabel: s__( + 'Terraform|To remove the State file and its versions, type %{name} to confirm:', + ), + modalRemove: s__('Terraform|Remove'), + remove: s__('Terraform|Remove state file and versions'), + unlock: s__('Terraform|Unlock'), + }, + computed: { + cancelModalProps() { + return { + text: this.$options.i18n.modalCancel, + attributes: [], + }; + }, + disableModalSubmit() { + return this.removeConfirmText !== this.state.name; + }, + primaryModalProps() { + return { + text: this.$options.i18n.modalRemove, + attributes: [{ disabled: this.disableModalSubmit }, { variant: 'danger' }], + }; + }, + }, + methods: { + hideModal() { + this.showRemoveModal = false; + this.removeConfirmText = ''; + }, + lock() { + this.stateMutation(lockState); + }, + unlock() { + this.stateMutation(unlockState); + }, + remove() { + if (!this.disableModalSubmit) { + this.hideModal(); + this.stateMutation(removeState); + } + }, + stateMutation(mutation) { + this.loading = true; + this.$apollo + .mutate({ + mutation, + variables: { + stateID: this.state.id, + }, + refetchQueries: () => ['getStates'], + awaitRefetchQueries: true, + notifyOnNetworkStatusChange: true, + }) + .catch(() => {}) + .finally(() => { + this.loading = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown + icon="ellipsis_v" + right + :data-testid="`terraform-state-actions-${state.name}`" + :disabled="loading" + toggle-class="gl-px-3! gl-shadow-none!" + > + <template #button-content> + <gl-icon class="gl-mr-0" name="ellipsis_v" /> + </template> + + <gl-dropdown-item + v-if="state.latestVersion" + data-testid="terraform-state-download" + :download="`${state.name}.json`" + :href="state.latestVersion.downloadPath" + > + {{ $options.i18n.downloadJSON }} + </gl-dropdown-item> + + <gl-dropdown-item v-if="state.lockedAt" data-testid="terraform-state-unlock" @click="unlock"> + {{ $options.i18n.unlock }} + </gl-dropdown-item> + + <gl-dropdown-item v-else data-testid="terraform-state-lock" @click="lock"> + {{ $options.i18n.lock }} + </gl-dropdown-item> + + <gl-dropdown-divider /> + + <gl-dropdown-item data-testid="terraform-state-remove" @click="showRemoveModal = true"> + {{ $options.i18n.remove }} + </gl-dropdown-item> + </gl-dropdown> + + <gl-modal + :modal-id="`terraform-state-actions-remove-modal-${state.name}`" + :visible="showRemoveModal" + :action-primary="primaryModalProps" + :action-cancel="cancelModalProps" + @ok="remove" + @cancel="hideModal" + @close="hideModal" + @hide="hideModal" + > + <template #modal-title> + <gl-sprintf :message="$options.i18n.modalHeader"> + <template #name> + <span>{{ state.name }}</span> + </template> + </gl-sprintf> + </template> + + <p> + <gl-sprintf :message="$options.i18n.modalBody"> + <template #name> + <span>{{ state.name }}</span> + </template> + </gl-sprintf> + </p> + + <gl-form-group> + <template #label> + <gl-sprintf :message="$options.i18n.modalInputLabel"> + <template #name> + <code>{{ state.name }}</code> + </template> + </gl-sprintf> + </template> + <gl-form-input + :id="`terraform-state-remove-input-${state.name}`" + ref="input" + v-model="removeConfirmText" + type="text" + @keyup.enter="remove" + /> + </gl-form-group> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue index f614bdc8d43..26a0bfe5fa5 100644 --- a/app/assets/javascripts/terraform/components/terraform_list.vue +++ b/app/assets/javascripts/terraform/components/terraform_list.vue @@ -15,13 +15,7 @@ export default { ...this.cursor, }; }, - update: data => { - return { - count: data?.project?.terraformStates?.count, - list: data?.project?.terraformStates?.nodes, - pageInfo: data?.project?.terraformStates?.pageInfo, - }; - }, + update: data => data, error() { this.states = null; }, @@ -46,6 +40,11 @@ export default { required: true, type: String, }, + terraformAdmin: { + required: false, + type: Boolean, + default: false, + }, }, data() { return { @@ -62,35 +61,34 @@ export default { return this.$apollo.queries.states.loading; }, pageInfo() { - return this.states?.pageInfo || {}; + return this.states?.project?.terraformStates?.pageInfo || {}; }, showPagination() { return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; }, statesCount() { - return this.states?.count; + return this.states?.project?.terraformStates?.count; }, statesList() { - return this.states?.list; + return this.states?.project?.terraformStates?.nodes; }, }, methods: { - updatePagination(item) { - if (item === this.pageInfo.endCursor) { - this.cursor = { - first: MAX_LIST_COUNT, - after: item, - last: null, - before: null, - }; - } else { - this.cursor = { - first: null, - after: null, - last: MAX_LIST_COUNT, - before: item, - }; - } + nextPage(item) { + this.cursor = { + first: MAX_LIST_COUNT, + after: item, + last: null, + before: null, + }; + }, + prevPage(item) { + this.cursor = { + first: null, + after: null, + last: MAX_LIST_COUNT, + before: item, + }; }, }, }; @@ -111,14 +109,10 @@ export default { <div v-else-if="statesList"> <div v-if="statesCount"> - <states-table :states="statesList" /> + <states-table :states="statesList" :terraform-admin="terraformAdmin" /> <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5"> - <gl-keyset-pagination - v-bind="pageInfo" - @prev="updatePagination" - @next="updatePagination" - /> + <gl-keyset-pagination v-bind="pageInfo" @prev="prevPage" @next="nextPage" /> </div> </div> diff --git a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql index c7e9700c696..70ba5c960be 100644 --- a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql +++ b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql @@ -1,9 +1,26 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" fragment StateVersion on TerraformStateVersion { + downloadPath + serial updatedAt createdByUser { ...User } + + job { + detailedStatus { + detailsPath + group + icon + label + text + } + + pipeline { + id + path + } + } } diff --git a/app/assets/javascripts/terraform/graphql/mutations/lock_state.mutation.graphql b/app/assets/javascripts/terraform/graphql/mutations/lock_state.mutation.graphql new file mode 100644 index 00000000000..aea0f8b025a --- /dev/null +++ b/app/assets/javascripts/terraform/graphql/mutations/lock_state.mutation.graphql @@ -0,0 +1,5 @@ +mutation lockState($stateID: TerraformStateID!) { + terraformStateLock(input: { id: $stateID }) { + errors + } +} diff --git a/app/assets/javascripts/terraform/graphql/mutations/remove_state.mutation.graphql b/app/assets/javascripts/terraform/graphql/mutations/remove_state.mutation.graphql new file mode 100644 index 00000000000..d85ebb9cea2 --- /dev/null +++ b/app/assets/javascripts/terraform/graphql/mutations/remove_state.mutation.graphql @@ -0,0 +1,5 @@ +mutation removeState($stateID: TerraformStateID!) { + terraformStateDelete(input: { id: $stateID }) { + errors + } +} diff --git a/app/assets/javascripts/terraform/graphql/mutations/unlock_state.mutation.graphql b/app/assets/javascripts/terraform/graphql/mutations/unlock_state.mutation.graphql new file mode 100644 index 00000000000..1909fe95cf3 --- /dev/null +++ b/app/assets/javascripts/terraform/graphql/mutations/unlock_state.mutation.graphql @@ -0,0 +1,5 @@ +mutation unlockState($stateID: TerraformStateID!) { + terraformStateUnlock(input: { id: $stateID }) { + errors + } +} diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js index 579d2d14023..e27a29433f3 100644 --- a/app/assets/javascripts/terraform/index.js +++ b/app/assets/javascripts/terraform/index.js @@ -24,6 +24,7 @@ export default () => { props: { emptyStateImage, projectPath, + terraformAdmin: el.hasAttribute('data-terraform-admin'), }, }); }, diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index dccd6807f13..e693c3e90a4 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -15,6 +15,7 @@ import { parseBoolean, spriteIcon } from '../lib/utils/common_utils'; import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { fixTitle, dispose } from '~/tooltips'; +import { loadCSSFile } from '../lib/utils/css_utils'; // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; @@ -48,6 +49,7 @@ function UsersSelect(currentUser, els, options = {}) { options.todoStateFilter = $dropdown.data('todoStateFilter'); options.iid = $dropdown.data('iid'); options.issuableType = $dropdown.data('issuableType'); + options.targetBranch = $dropdown.data('targetBranch'); const showNullUser = $dropdown.data('nullUser'); const defaultNullUser = $dropdown.data('nullUserDefault'); const showMenuAbove = $dropdown.data('showMenuAbove'); @@ -62,8 +64,7 @@ function UsersSelect(currentUser, els, options = {}) { const abilityName = $dropdown.data('abilityName'); let $value = $block.find('.value'); const $collapsedSidebar = $block.find('.sidebar-collapsed-user'); - // eslint-disable-next-line no-jquery/no-fade - const $loading = $block.find('.block-loading').fadeOut(); + const $loading = $block.find('.block-loading').addClass('gl-display-none'); const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; let selectedId = $dropdown.data('selected'); let assignTo; @@ -204,16 +205,14 @@ function UsersSelect(currentUser, els, options = {}) { const data = {}; data[abilityName] = {}; data[abilityName].assignee_id = selected != null ? selected : null; - // eslint-disable-next-line no-jquery/no-fade - $loading.removeClass('hidden').fadeIn(); + $loading.removeClass('gl-display-none'); $dropdown.trigger('loading.gl.dropdown'); return axios.put(issueURL, data).then(({ data }) => { let user = {}; let tooltipTitle = user.name; $dropdown.trigger('loaded.gl.dropdown'); - // eslint-disable-next-line no-jquery/no-fade - $loading.fadeOut(); + $loading.addClass('gl-display-none'); if (data.assignee) { user = { name: data.assignee.name, @@ -584,7 +583,14 @@ function UsersSelect(currentUser, els, options = {}) { img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`; } - return userSelect.renderRow(options.issuableType, user, selected, username, img); + return userSelect.renderRow( + options.issuableType, + user, + selected, + username, + img, + elsClassName, + ); }, }); }); @@ -592,92 +598,97 @@ function UsersSelect(currentUser, els, options = {}) { if ($('.ajax-users-select').length) { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - $('.ajax-users-select').each((i, select) => { - const options = getAjaxUsersSelectOptions($(select), AJAX_USERS_SELECT_OPTIONS_MAP); - options.skipLdap = $(select).hasClass('skip_ldap'); - const showNullUser = $(select).data('nullUser'); - const showAnyUser = $(select).data('anyUser'); - const showEmailUser = $(select).data('emailUser'); - const firstUser = $(select).data('firstUser'); - return $(select).select2({ - placeholder: __('Search for a user'), - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query(query) { - return userSelect.users(query.term, options, users => { - let name; - const data = { - results: users, - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - const ref = data.results; - - for (let index = 0, len = ref.length; index < len; index += 1) { - const obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + $('.ajax-users-select').each((i, select) => { + const options = getAjaxUsersSelectOptions($(select), AJAX_USERS_SELECT_OPTIONS_MAP); + options.skipLdap = $(select).hasClass('skip_ldap'); + const showNullUser = $(select).data('nullUser'); + const showAnyUser = $(select).data('anyUser'); + const showEmailUser = $(select).data('emailUser'); + const firstUser = $(select).data('firstUser'); + return $(select).select2({ + placeholder: __('Search for a user'), + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query(query) { + return userSelect.users(query.term, options, users => { + let name; + const data = { + results: users, + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + const ref = data.results; + + for (let index = 0, len = ref.length; index < len; index += 1) { + const obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } + } + } + if (showNullUser) { + const nullUser = { + name: s__('UsersSelect|Unassigned'), + id: 0, + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = s__('UsersSelect|Any User'); + } + const anyUser = { + name, + id: null, + }; + data.results.unshift(anyUser); } } - } - if (showNullUser) { - const nullUser = { - name: s__('UsersSelect|Unassigned'), - id: 0, - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = s__('UsersSelect|Any User'); + if ( + showEmailUser && + data.results.length === 0 && + query.term.match(/^[^@]+@[^@]+$/) + ) { + const trimmed = query.term.trim(); + const emailUser = { + name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), + username: trimmed, + id: trimmed, + invite: true, + }; + data.results.unshift(emailUser); } - const anyUser = { - name, - id: null, - }; - data.results.unshift(anyUser); - } - } - if ( - showEmailUser && - data.results.length === 0 && - query.term.match(/^[^@]+@[^@]+$/) - ) { - const trimmed = query.term.trim(); - const emailUser = { - name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), - username: trimmed, - id: trimmed, - invite: true, - }; - data.results.unshift(emailUser); - } - return query.callback(data); + return query.callback(data); + }); + }, + initSelection() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.initSelection.apply(userSelect, args); + }, + formatResult() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.formatResult.apply(userSelect, args); + }, + formatSelection() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.formatSelection.apply(userSelect, args); + }, + dropdownCssClass: 'ajax-users-dropdown', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, }); - }, - initSelection() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return userSelect.initSelection.apply(userSelect, args); - }, - formatResult() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return userSelect.formatResult.apply(userSelect, args); - }, - formatSelection() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return userSelect.formatSelection.apply(userSelect, args); - }, - dropdownCssClass: 'ajax-users-dropdown', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, - }); - }); + }); + }) + .catch(() => {}); }) .catch(() => {}); } @@ -743,8 +754,17 @@ UsersSelect.prototype.users = function(query, options, callback) { ...getAjaxUsersSelectParams(options, AJAX_USERS_SELECT_PARAMS_MAP), }; - if (options.issuableType === 'merge_request') { + const isMergeRequest = options.issuableType === 'merge_request'; + const isEditMergeRequest = !options.issuableType && (options.iid && options.targetBranch); + const isNewMergeRequest = !options.issuableType && (!options.iid && options.targetBranch); + + if (isMergeRequest || isEditMergeRequest || isNewMergeRequest) { params.merge_request_iid = options.iid || null; + params.approval_rules = true; + } + + if (isNewMergeRequest) { + params.target_branch = options.targetBranch || null; } return axios.get(url, { params }).then(({ data }) => { @@ -759,7 +779,14 @@ UsersSelect.prototype.buildUrl = function(url) { return url; }; -UsersSelect.prototype.renderRow = function(issuableType, user, selected, username, img) { +UsersSelect.prototype.renderRow = function( + issuableType, + user, + selected, + username, + img, + elsClassName, +) { const tooltip = issuableType === 'merge_request' && !user.can_merge ? __('Cannot merge') : ''; const tooltipClass = tooltip ? `has-tooltip` : ''; const selectedClass = selected === true ? 'is-active' : ''; @@ -773,10 +800,15 @@ UsersSelect.prototype.renderRow = function(issuableType, user, selected, usernam <a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}> ${this.renderRowAvatar(issuableType, user, img)} <span class="d-flex flex-column overflow-hidden"> - <strong class="dropdown-menu-user-full-name"> + <strong class="dropdown-menu-user-full-name gl-font-weight-bold"> ${escape(user.name)} </strong> - ${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''} + ${ + username + ? `<span class="dropdown-menu-user-username gl-text-gray-400">${username}</span>` + : '' + } + ${this.renderApprovalRules(elsClassName, user.applicable_approval_rules)} </span> </a> </li> @@ -790,7 +822,7 @@ UsersSelect.prototype.renderRowAvatar = function(issuableType, user, img) { const mergeIcon = issuableType === 'merge_request' && !user.can_merge - ? '<i class="fa fa-exclamation-triangle merge-icon"></i>' + ? spriteIcon('warning-solid', 's12 merge-icon') : ''; return `<span class="position-relative mr-2"> @@ -799,4 +831,22 @@ UsersSelect.prototype.renderRowAvatar = function(issuableType, user, img) { </span>`; }; +UsersSelect.prototype.renderApprovalRules = function(elsClassName, approvalRules = []) { + if (!gon.features?.reviewerApprovalRules || !elsClassName?.includes('reviewer')) { + return ''; + } + + const count = approvalRules.length; + const [rule] = approvalRules; + const countText = sprintf(__('(+%{count} rules)'), { count }); + const renderApprovalRulesCount = count > 1 ? `<span class="ml-1">${countText}</span>` : ''; + + return count + ? `<div class="gl-display-flex gl-font-sm"> + <span class="gl-text-truncate" title="${rule.name}">${rule.name}</span> + ${renderApprovalRulesCount} + </div>` + : ''; +}; + export default UsersSelect; diff --git a/app/assets/javascripts/version_check_image.js b/app/assets/javascripts/version_check_image.js index ec515e892c6..4e00e0f11f7 100644 --- a/app/assets/javascripts/version_check_image.js +++ b/app/assets/javascripts/version_check_image.js @@ -1,5 +1,6 @@ export default class VersionCheckImage { static bindErrorEvent(imageElement) { + // eslint-disable-next-line @gitlab/no-global-event-off imageElement.off('error').on('error', () => imageElement.hide()); } } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js index 66de4f8b682..29d067a46a6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js @@ -6,6 +6,7 @@ export const RUNNING = 'running'; export const SUCCESS = 'success'; export const FAILED = 'failed'; export const CANCELED = 'canceled'; +export const SKIPPED = 'skipped'; // ACTION STATUSES export const STOPPING = 'stopping'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue index 2f922b990d9..390469dec24 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -4,7 +4,15 @@ import { __ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import MemoryUsage from './memory_usage.vue'; -import { MANUAL_DEPLOY, WILL_DEPLOY, RUNNING, SUCCESS, FAILED, CANCELED } from './constants'; +import { + MANUAL_DEPLOY, + WILL_DEPLOY, + RUNNING, + SUCCESS, + FAILED, + CANCELED, + SKIPPED, +} from './constants'; export default { name: 'DeploymentInfo', @@ -38,6 +46,7 @@ export default { [SUCCESS]: __('Deployed to'), [FAILED]: __('Failed to deploy to'), [CANCELED]: __('Canceled deployment to'), + [SKIPPED]: __('Skipped deployment to'), }, computed: { deployTimeago() { 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 d5fdbe726e9..6628ab7be83 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 @@ -7,12 +7,14 @@ import { GlDropdownSectionHeader, GlDropdownItem, GlTooltipDirective, + GlModalDirective, } from '@gitlab/ui'; import { n__, s__, sprintf } from '~/locale'; import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import MrWidgetIcon from './mr_widget_icon.vue'; +import MrWidgetHowToMergeModal from './mr_widget_how_to_merge_modal.vue'; export default { name: 'MRWidgetHeader', @@ -20,6 +22,7 @@ export default { clipboardButton, TooltipOnTruncate, MrWidgetIcon, + MrWidgetHowToMergeModal, GlButton, GlDropdown, GlDropdownSectionHeader, @@ -27,6 +30,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + GlModalDirective, }, props: { mr: { @@ -82,6 +86,9 @@ export default { ) : ''; }, + isFork() { + return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath; + }, }, }; </script> @@ -140,13 +147,22 @@ export default { </gl-button> </span> <gl-button + v-gl-modal-directive="'modal-merge-info'" :disabled="mr.sourceBranchRemoved" - data-target="#modal_merge_info" - data-toggle="modal" class="js-check-out-branch gl-mr-3" > {{ s__('mrWidget|Check out branch') }} </gl-button> + <mr-widget-how-to-merge-modal + :is-fork="isFork" + :can-merge="mr.canMerge" + :source-branch="mr.sourceBranch" + :source-project="mr.sourceProject" + :source-project-path="mr.sourceProjectFullPath" + :target-branch="mr.targetBranch" + :source-project-default-url="mr.sourceProjectDefaultUrl" + :reviewing-docs-path="mr.reviewingDocsPath" + /> </template> <gl-dropdown v-gl-tooltip diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue new file mode 100644 index 00000000000..785e8ef8e8f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -0,0 +1,172 @@ +<script> +/* eslint-disable @gitlab/require-i18n-strings */ +import { GlModal, GlLink, GlSprintf } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { __ } from '~/locale'; + +export default { + i18n: { + steps: { + step1: { + label: __('Step 1.'), + help: __('Fetch and check out the branch for this merge request'), + }, + step2: { + label: __('Step 2.'), + help: __('Review the changes locally'), + }, + step3: { + label: __('Step 3.'), + help: __('Merge the branch and fix any conflicts that come up'), + }, + step4: { + label: __('Step 4.'), + help: __('Push the result of the merge to GitLab'), + }, + }, + copyCommands: __('Copy commands'), + tip: __( + '%{strongStart}Tip:%{strongEnd} You can also checkout merge requests locally by %{linkStart}following these guidelines%{linkEnd}', + ), + title: __('Check out, review, and merge locally'), + }, + components: { + GlModal, + ClipboardButton, + GlLink, + GlSprintf, + }, + props: { + canMerge: { + type: Boolean, + required: false, + default: false, + }, + isFork: { + type: Boolean, + required: false, + default: false, + }, + sourceBranch: { + type: String, + required: false, + default: '', + }, + sourceProjectPath: { + type: String, + required: false, + default: '', + }, + targetBranch: { + type: String, + required: false, + default: '', + }, + sourceProjectDefaultUrl: { + type: String, + required: false, + default: '', + }, + reviewingDocsPath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + mergeInfo1() { + return this.isFork + ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.sourceBranch}\ngit checkout -b "${this.sourceProjectPath}-${this.sourceBranch}" FETCH_HEAD` + : `git fetch origin\ngit checkout -b "${this.sourceBranch}" "origin/${this.sourceBranch}"`; + }, + mergeInfo2() { + return this.isFork + ? `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceProjectPath}-${this.sourceBranch}"` + : `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceBranch}"`; + }, + mergeInfo3() { + return this.canMerge + ? `git push origin "${this.targetBranch}"` + : __('Note that pushing to GitLab requires write access to this repository.'); + }, + }, +}; +</script> + +<template> + <gl-modal + modal-id="modal-merge-info" + :no-enforce-focus="true" + :title="$options.i18n.title" + no-fade + hide-footer + > + <p> + <strong> + {{ $options.i18n.steps.step1.label }} + </strong> + {{ $options.i18n.steps.step1.help }} + </p> + <div class="gl-display-flex"> + <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{ + mergeInfo1 + }}</pre> + <clipboard-button + :text="mergeInfo1" + :title="$options.i18n.copyCommands" + class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" + /> + </div> + + <p> + <strong> + {{ $options.i18n.steps.step2.label }} + </strong> + {{ $options.i18n.steps.step2.help }} + </p> + <p> + <strong> + {{ $options.i18n.steps.step3.label }} + </strong> + {{ $options.i18n.steps.step3.help }} + </p> + <div class="gl-display-flex"> + <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{ + mergeInfo2 + }}</pre> + <clipboard-button + :text="mergeInfo2" + :title="$options.i18n.copyCommands" + class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" + /> + </div> + <p> + <strong> + {{ $options.i18n.steps.step4.label }} + </strong> + {{ $options.i18n.steps.step4.help }} + </p> + <div class="gl-display-flex"> + <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{ + mergeInfo3 + }}</pre> + <clipboard-button + :text="mergeInfo3" + :title="$options.i18n.copyCommands" + class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" + /> + </div> + <p v-if="reviewingDocsPath"> + <gl-sprintf :message="$options.i18n.tip"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #link="{ content }"> + <gl-link class="gl-display-inline-block" :href="reviewingDocsPath" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue index 53bf9d5ab6f..1727383ea2c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue @@ -1,8 +1,15 @@ <script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; export default { name: 'MRWidgetMergeHelp', + components: { + GlButton, + }, + directives: { + GlModalDirective, + }, props: { missingBranch: { type: String, @@ -31,13 +38,12 @@ export default { {{ s__('mrWidget|You can merge this merge request manually using the') }} </template> - <button - type="button" - class="btn-link btn-blank js-open-modal-help" - data-toggle="modal" - data-target="#modal_merge_info" + <gl-button + v-gl-modal-directive="'modal-merge-info'" + variant="link" + class="gl-mt-n2 js-open-modal-help" > {{ s__('mrWidget|command line') }} - </button> + </gl-button> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 55efd7e7d3b..dffe3cab904 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -1,7 +1,6 @@ <script> import { isNumber } from 'lodash'; import ArtifactsApp from './artifacts_list_app.vue'; -import Deployment from './deployment/deployment.vue'; import MrWidgetContainer from './mr_widget_container.vue'; import MrWidgetPipeline from './mr_widget_pipeline.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -18,7 +17,7 @@ export default { name: 'MrWidgetPipelineContainer', components: { ArtifactsApp, - Deployment, + Deployment: () => import('./deployment/deployment.vue'), MrWidgetContainer, MrWidgetPipeline, MergeTrainPositionIndicator: () => diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 82566682bca..bc23ca6b1fc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,10 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import ciIcon from '../../vue_shared/components/ci_icon.vue'; export default { components: { ciIcon, + GlButton, GlLoadingIcon, }, props: { @@ -32,21 +33,23 @@ export default { }; </script> <template> - <div class="d-flex align-self-start"> + <div class="gl-display-flex gl-align-self-start"> <div class="square s24 h-auto d-flex-center gl-mr-3"> - <div v-if="isLoading" class="mr-widget-icon d-inline-flex"> - <gl-loading-icon size="md" class="mr-loading-icon d-inline-flex" /> + <div v-if="isLoading" class="mr-widget-icon gl-display-inline-flex"> + <gl-loading-icon size="md" class="mr-loading-icon gl-display-inline-flex" /> </div> <ci-icon v-else :status="statusObj" :size="24" /> </div> - <button + <gl-button v-if="showDisabledButton" type="button" - class="js-disabled-merge-button btn btn-success btn-sm" - disabled="true" + category="primary" + variant="success" + class="js-disabled-merge-button" + :disabled="true" > {{ s__('mrWidget|Merge') }} - </button> + </gl-button> </div> </template> 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 d421b744fa1..87c59e5ece9 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 @@ -1,14 +1,47 @@ <script> import $ from 'jquery'; import { escape } from 'lodash'; +import { GlButton, GlModalDirective, GlSkeletonLoader } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import StatusIcon from '../mr_widget_status_icon.vue'; +import userPermissionsQuery from '../../queries/permissions.query.graphql'; +import conflictsStateQuery from '../../queries/states/conflicts.query.graphql'; export default { name: 'MRWidgetConflicts', components: { + GlSkeletonLoader, StatusIcon, + GlButton, + }, + directives: { + GlModalDirective, + }, + mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], + apollo: { + userPermissions: { + query: userPermissionsQuery, + skip() { + return !this.glFeatures.mergeRequestWidgetGraphql; + }, + variables() { + return this.mergeRequestQueryVariables; + }, + update: data => data.project.mergeRequest.userPermissions, + }, + stateData: { + query: conflictsStateQuery, + skip() { + return !this.glFeatures.mergeRequestWidgetGraphql; + }, + variables() { + return this.mergeRequestQueryVariables; + }, + update: data => data.project.mergeRequest, + }, }, props: { /* TODO: This is providing all store and service down when it @@ -19,21 +52,72 @@ export default { default: () => ({}), }, }, + data() { + return { + userPermissions: {}, + stateData: {}, + }; + }, computed: { + isLoading() { + return ( + this.glFeatures.mergeRequestWidgetGraphql && + this.$apollo.queries.userPermissions.loading && + this.$apollo.queries.stateData.loading + ); + }, + canPushToSourceBranch() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + return this.userPermissions.pushToSourceBranch; + } + + return this.mr.canPushToSourceBranch; + }, + canMerge() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + return this.userPermissions.canMerge; + } + + return this.mr.canMerge; + }, + shouldBeRebased() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + return this.stateData.shouldBeRebased; + } + + return this.mr.shouldBeRebased; + }, + sourceBranchProtected() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + return this.stateData.sourceBranchProtected; + } + + return this.mr.sourceBranchProtected; + }, popoverTitle() { return s__( 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.', ); }, showResolveButton() { - return this.mr.conflictResolutionPath && this.mr.canPushToSourceBranch; + return this.mr.conflictResolutionPath && this.canPushToSourceBranch; }, showPopover() { - return this.showResolveButton && this.mr.sourceBranchProtected; + return this.showResolveButton && this.sourceBranchProtected; }, }, - mounted() { - if (this.showPopover) { + watch: { + showPopover: { + handler(newVal) { + if (newVal) { + this.$nextTick(this.initPopover); + } + }, + immediate: true, + }, + }, + methods: { + initPopover() { const $el = $(this.$refs.popover); $el @@ -63,7 +147,7 @@ export default { .on('show.bs.popover', () => { window.addEventListener('scroll', togglePopover.bind($el, false), { once: true }); }); - } + }, }, }; </script> @@ -71,40 +155,46 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="true" status="warning" /> - <div class="media-body space-children"> - <span v-if="mr.shouldBeRebased" class="bold"> + <div v-if="isLoading" class="gl-ml-4 gl-w-full mr-conflict-loader"> + <gl-skeleton-loader :width="334" :height="30"> + <rect x="0" y="7" width="150" height="16" rx="4" /> + <rect x="158" y="7" width="84" height="16" rx="4" /> + <rect x="250" y="7" width="84" height="16" rx="4" /> + </gl-skeleton-loader> + </div> + <div v-else class="media-body space-children"> + <span v-if="shouldBeRebased" class="bold"> {{ s__(`mrWidget|Fast-forward merge is not possible. -To merge this request, first rebase locally.`) + To merge this request, first rebase locally.`) }} </span> <template v-else> <span class="bold"> - {{ s__('mrWidget|There are merge conflicts') }}<span v-if="!mr.canMerge">.</span> - <span v-if="!mr.canMerge"> + {{ s__('mrWidget|There are merge conflicts') }}<span v-if="!canMerge">.</span> + <span v-if="!canMerge"> {{ s__(`mrWidget|Resolve these conflicts or ask someone - with write access to this repository to merge it locally`) + with write access to this repository to merge it locally`) }} </span> </span> <span v-if="showResolveButton" ref="popover"> - <a - :href="mr.conflictResolutionPath" - :disabled="mr.sourceBranchProtected" - class="js-resolve-conflicts-button btn btn-default btn-sm" + <gl-button + :href="!sourceBranchProtected && mr.conflictResolutionPath" + :disabled="sourceBranchProtected" + class="js-resolve-conflicts-button" > {{ s__('mrWidget|Resolve conflicts') }} - </a> + </gl-button> </span> - <button - v-if="mr.canMerge" - class="js-merge-locally-button btn btn-default btn-sm" - data-toggle="modal" - data-target="#modal_merge_info" + <gl-button + v-if="canMerge" + v-gl-modal-directive="'modal-merge-info'" + class="js-merge-locally-button" > {{ s__('mrWidget|Merge locally') }} - </button> + </gl-button> </template> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index 6489569cf68..8511797286d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -2,6 +2,9 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import statusIcon from '../mr_widget_status_icon.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; +import missingBranchQuery from '../../queries/states/missing_branch.query.graphql'; export default { name: 'MRWidgetMissingBranch', @@ -12,15 +15,38 @@ export default { GlIcon, statusIcon, }, + mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], + apollo: { + state: { + query: missingBranchQuery, + skip() { + return !this.glFeatures.mergeRequestWidgetGraphql; + }, + variables() { + return this.mergeRequestQueryVariables; + }, + update: data => data.project.mergeRequest, + }, + }, props: { mr: { type: Object, required: true, }, }, + data() { + return { state: {} }; + }, computed: { + sourceBranchRemoved() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + return !this.state.sourceBranchExists; + } + + return this.mr.sourceBranchRemoved; + }, missingBranchName() { - return this.mr.sourceBranchRemoved ? 'source' : 'target'; + return this.sourceBranchRemoved ? 'source' : 'target'; }, missingBranchNameMessage() { return sprintf( @@ -49,7 +75,7 @@ export default { <div class="media-body space-children"> <span class="bold js-branch-text"> - <span class="capitalize"> {{ missingBranchName }} </span> + <span class="capitalize" data-testid="missingBranchName"> {{ missingBranchName }} </span> {{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }} <gl-icon v-gl-tooltip :title="message" :aria-label="message" name="question-o" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index ff0d065c71d..1c9909e7178 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -1,10 +1,11 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui'; import { SQUASH_BEFORE_MERGE } from '../../i18n'; export default { components: { GlIcon, + GlFormCheckbox, }, directives: { GlTooltip: GlTooltipDirective, @@ -32,32 +33,23 @@ export default { tooltipTitle() { return this.isDisabled ? this.$options.i18n.tooltipTitle : null; }, - tooltipFocusable() { - return this.isDisabled ? '0' : null; - }, }, }; </script> <template> - <div class="inline"> - <label + <div class="gl-display-flex gl-align-items-center"> + <gl-form-checkbox v-gl-tooltip - :class="{ 'gl-text-gray-400': isDisabled }" - :tabindex="tooltipFocusable" - data-testid="squashLabel" + :checked="value" + :disabled="isDisabled" + name="squash" + class="qa-squash-checkbox js-squash-checkbox gl-mb-0 gl-mr-2" :title="tooltipTitle" + @change="checked => $emit('input', checked)" > - <input - :checked="value" - :disabled="isDisabled" - type="checkbox" - name="squash" - class="qa-squash-checkbox js-squash-checkbox" - @change="$emit('input', $event.target.checked)" - /> {{ $options.i18n.checkboxLabel }} - </label> + </gl-form-checkbox> <a v-if="helpPath" v-gl-tooltip 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 e7f0977778e..3f1f2144d8e 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 @@ -16,7 +16,6 @@ import WidgetHeader from './components/mr_widget_header.vue'; import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; -import Deployment from './components/deployment/deployment.vue'; import WidgetRelatedLinks from './components/mr_widget_related_links.vue'; import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue'; import MergedState from './components/states/mr_widget_merged.vue'; @@ -63,7 +62,6 @@ export default { 'mr-widget-suggest-pipeline': WidgetSuggestPipeline, 'mr-widget-merge-help': WidgetMergeHelp, MrWidgetPipelineContainer, - Deployment, 'mr-widget-related-links': WidgetRelatedLinks, MrWidgetAlertMessage, 'mr-widget-merged': MergedState, @@ -155,10 +153,7 @@ export default { }, shouldSuggestPipelines() { return ( - gon.features?.suggestPipeline && - !this.mr.hasCI && - this.mr.mergeRequestAddCiConfigPath && - !this.mr.isDismissedSuggestPipeline + !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath && !this.mr.isDismissedSuggestPipeline ); }, shouldRenderCodeQuality() { @@ -472,8 +467,10 @@ export default { <security-reports-app v-if="shouldRenderSecurityReport" :pipeline-id="mr.pipeline.id" - :project-id="mr.targetProjectId" + :project-id="mr.sourceProjectId" :security-reports-docs-path="mr.securityReportsDocsPath" + :target-project-full-path="mr.targetProjectFullPath" + :mr-iid="mr.iid" /> <grouped-test-reports-app diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql new file mode 100644 index 00000000000..ae2a67440fe --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql @@ -0,0 +1,10 @@ +query userPermissionsQuery($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + userPermissions { + canMerge + pushToSourceBranch + } + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql new file mode 100644 index 00000000000..186c0e64561 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql @@ -0,0 +1,8 @@ +query workInProgressQuery($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + shouldBeRebased + sourceBranchProtected + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql new file mode 100644 index 00000000000..ea95218aec6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql @@ -0,0 +1,7 @@ +query missingBranchQuery($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + sourceBranchExists + } + } +} 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 8b235b20ad4..f50b6caf0f5 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 @@ -220,6 +220,7 @@ export default class MergeRequestStore { this.sourceProjectFullPath = data.source_project_full_path; this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path; this.conflictsDocsPath = data.conflicts_docs_path; + this.reviewingDocsPath = data.reviewing_and_managing_merge_requests_docs_path; this.ciEnvironmentsStatusPath = data.ci_environments_status_path; this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path; this.eligibleApproversDocsPath = data.eligible_approvers_docs_path; @@ -229,6 +230,7 @@ export default class MergeRequestStore { this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path; this.humanAccess = data.human_access; this.newPipelinePath = data.new_project_pipeline_path; + this.sourceProjectDefaultUrl = data.source_project_default_url; this.userCalloutsPath = data.user_callouts_path; this.suggestPipelineFeatureId = data.suggest_pipeline_feature_id; this.isDismissedSuggestPipeline = data.is_dismissed_suggest_pipeline; @@ -240,6 +242,10 @@ export default class MergeRequestStore { this.baseBlobPath = blobPath.base_path || ''; this.codequalityHelpPath = data.codequality_help_path; this.codeclimate = data.codeclimate; + + // Security reports + this.sastComparisonPath = data.sast_comparison_path; + this.secretScanningComparisonPath = data.secret_scanning_comparison_path; } get isNothingToMergeState() { diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 7a687ea4ad0..9a6433963bc 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import { groupBy } from 'lodash'; -import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { glEmojiTag } from '../../emoji'; import { __, sprintf } from '~/locale'; @@ -10,8 +10,8 @@ const NO_USER_ID = -1; export default { components: { + GlButton, GlIcon, - GlLoadingIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -64,7 +64,7 @@ export default { methods: { getAwardClassBindings(awardList) { return { - active: this.hasReactionByCurrentUser(awardList), + selected: this.hasReactionByCurrentUser(awardList), disabled: this.currentUserId === NO_USER_ID, }; }, @@ -150,40 +150,39 @@ export default { <template> <div class="awards js-awards-block"> - <button + <gl-button v-for="awardList in groupedAwards" :key="awardList.name" v-gl-tooltip.viewport + class="gl-mr-3" :class="awardList.classes" :title="awardList.title" data-testid="award-button" - class="btn award-control" - type="button" @click="handleAward(awardList.name)" > - <span data-testid="award-html" v-html="awardList.html"></span> - <span class="award-control-text js-counter">{{ awardList.list.length }}</span> - </button> + <template #emoji> + <span class="award-emoji-block" data-testid="award-html" v-html="awardList.html"></span> + </template> + <span class="js-counter">{{ awardList.list.length }}</span> + </gl-button> <div v-if="canAwardEmoji" class="award-menu-holder"> - <button + <gl-button v-gl-tooltip.viewport :class="addButtonClass" - class="award-control btn js-add-award" + class="add-reaction-button js-add-award" title="Add reaction" :aria-label="__('Add reaction')" - type="button" > - <span class="award-control-icon award-control-icon-neutral"> - <gl-icon aria-hidden="true" name="slight-smile" /> + <span class="reaction-control-icon reaction-control-icon-neutral"> + <gl-icon name="slight-smile" /> </span> - <span class="award-control-icon award-control-icon-positive"> - <gl-icon aria-hidden="true" name="smiley" /> + <span class="reaction-control-icon reaction-control-icon-positive"> + <gl-icon name="smiley" /> </span> - <span class="award-control-icon award-control-icon-super-positive"> - <gl-icon aria-hidden="true" name="smiley" /> + <span class="reaction-control-icon reaction-control-icon-super-positive"> + <gl-icon name="smile" /> </span> - <gl-loading-icon size="md" color="dark" class="award-control-icon-loading" /> - </button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js index d4c1808eec2..106dd7a3b97 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js @@ -1,3 +1 @@ export const HIGHLIGHT_CLASS_NAME = 'hll'; - -export default {}; diff --git a/app/assets/javascripts/vue_shared/components/callout.vue b/app/assets/javascripts/vue_shared/components/callout.vue deleted file mode 100644 index 56bafebf4ce..00000000000 --- a/app/assets/javascripts/vue_shared/components/callout.vue +++ /dev/null @@ -1,24 +0,0 @@ -<script> -const calloutVariants = ['danger', 'success', 'info', 'warning']; - -export default { - props: { - category: { - type: String, - required: false, - default: calloutVariants[0], - validator: value => calloutVariants.includes(value), - }, - message: { - type: String, - required: false, - default: '', - }, - }, -}; -</script> -<template> - <div :class="`bs-callout bs-callout-${category}`" role="alert" aria-live="assertive"> - {{ message }} <slot></slot> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 1b7e51b7d02..f388a468fd2 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -20,6 +20,7 @@ import CiIcon from './ci_icon.vue'; * - Pipeline show view - header * - Job show view - header * - MR widget + * - Terraform table */ export default { diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index d775a093f5f..07bd6019b80 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -63,5 +63,7 @@ export default { }; </script> <template> - <span :class="cssClass"> <gl-icon :name="icon" :size="size" :class="cssClasses" /> </span> + <span :class="cssClass"> + <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" /> + </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 960551fae91..bf1361f1a6a 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -84,5 +84,8 @@ export default { :size="size" icon="copy-to-clipboard" :aria-label="__('Copy this value')" - /> + v-on="$listeners" + > + <slot></slot> + </gl-button> </template> diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue new file mode 100644 index 00000000000..6977692e30c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue @@ -0,0 +1,142 @@ +<script> +/** + * Renders a color picker input with preset colors to choose from + * + * @example + * <color-picker :label="__('Background color')" set-color="#FF0000" /> + */ +import { GlFormGroup, GlFormInput, GlFormInputGroup, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const VALID_RGB_HEX_COLOR = /^#([0-9A-F]{3}){1,2}$/i; +const PREVIEW_COLOR_DEFAULT_CLASSES = + 'gl-relative gl-w-7 gl-bg-gray-10 gl-rounded-top-left-base gl-rounded-bottom-left-base'; + +export default { + name: 'ColorPicker', + components: { + GlFormGroup, + GlFormInput, + GlFormInputGroup, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + label: { + type: String, + required: false, + default: '', + }, + setColor: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + selectedColor: this.setColor.trim() || '', + }; + }, + computed: { + description() { + return this.hasSuggestedColors + ? this.$options.i18n.fullDescription + : this.$options.i18n.shortDescription; + }, + suggestedColors() { + return gon.suggested_label_colors; + }, + previewColor() { + if (this.isValidColor) { + return { backgroundColor: this.selectedColor }; + } + + return {}; + }, + previewColorClasses() { + const borderStyle = this.isInvalidColor + ? 'gl-inset-border-1-red-500' + : 'gl-inset-border-1-gray-400'; + + return `${PREVIEW_COLOR_DEFAULT_CLASSES} ${borderStyle}`; + }, + hasSuggestedColors() { + return Object.keys(this.suggestedColors).length; + }, + isInvalidColor() { + return this.isValidColor === false; + }, + isValidColor() { + if (this.selectedColor === '') { + return null; + } + + return VALID_RGB_HEX_COLOR.test(this.selectedColor); + }, + }, + methods: { + handleColorChange(color) { + this.selectedColor = color.trim(); + + if (this.isValidColor) { + this.$emit('input', this.selectedColor); + } + }, + }, + i18n: { + fullDescription: __('Choose any color. Or you can choose one of the suggested colors below'), + shortDescription: __('Choose any color'), + invalid: __('Please enter a valid hex (#RRGGBB or #RGB) color value'), + }, +}; +</script> + +<template> + <div> + <gl-form-group + :label="label" + label-for="color-picker" + :description="description" + :invalid-feedback="this.$options.i18n.invalid" + :state="isValidColor" + :class="{ 'gl-mb-3!': hasSuggestedColors }" + > + <gl-form-input-group + id="color-picker" + :state="isValidColor" + max-length="7" + type="text" + class="gl-align-center gl-rounded-0 gl-rounded-top-right-base gl-rounded-bottom-right-base" + :value="selectedColor" + @input="handleColorChange" + > + <template #prepend> + <div :class="previewColorClasses" :style="previewColor" data-testid="color-preview"> + <gl-form-input + type="color" + class="gl-absolute gl-top-0 gl-left-0 gl-h-full! gl-p-0! gl-m-0! gl-cursor-pointer gl-opacity-0" + tabindex="-1" + :value="selectedColor" + @input="handleColorChange" + /> + </div> + </template> + </gl-form-input-group> + </gl-form-group> + + <div v-if="hasSuggestedColors" class="gl-mb-3"> + <gl-link + v-for="(name, hex) in suggestedColors" + :key="hex" + v-gl-tooltip + :title="name" + :style="{ backgroundColor: hex }" + class="gl-rounded-base gl-w-7 gl-h-7 gl-display-inline-block gl-mr-3 gl-mb-3 gl-text-decoration-none" + @click.prevent="handleColorChange(hex)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue index 328c7e3fd32..eb7e24734ce 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -28,6 +28,8 @@ export default { return { width: 0, height: 0, + renderedWidth: 0, + renderedHeight: 0, }; }, computed: { @@ -63,11 +65,14 @@ export default { this.height = contentImg.naturalHeight; this.$nextTick(() => { + this.renderedWidth = contentImg.clientWidth; + this.renderedHeight = contentImg.clientHeight; + this.$emit('imgLoaded', { width: this.width, height: this.height, - renderedWidth: contentImg.clientWidth, - renderedHeight: contentImg.clientHeight, + renderedWidth: this.renderedWidth, + renderedHeight: this.renderedHeight, }); }); } @@ -77,9 +82,14 @@ export default { </script> <template> - <div> + <div data-testid="image-viewer"> <div :class="innerCssClasses" class="position-relative"> - <img ref="contentImg" :src="path" @load="onImgLoad" /> <slot name="image-overlay"></slot> + <img ref="contentImg" :src="path" @load="onImgLoad" /> + <slot + name="image-overlay" + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + ></slot> </div> <p v-if="renderInfo" class="image-info"> <template v-if="hasFileSize"> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 6bb05e59f6b..67be76604a3 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -109,7 +109,7 @@ export default { </script> <template> - <div ref="markdownPreview" class="md-previewer"> + <div ref="markdownPreview" class="md-previewer" data-testid="md-previewer"> <gl-skeleton-loading v-if="isLoading" /> <div v-else class="md" v-html="previewContent"></div> </div> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index a7e6438a935..79cdf308ac5 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -219,7 +219,7 @@ export default { <span v-if="utc" class="gl-text-gray-500 gl-font-weight-bold gl-font-sm">{{ __('UTC') }}</span> - <gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" /> + <gl-icon class="gl-dropdown-caret" name="chevron-down" /> </template> <div class="d-flex justify-content-between gl-p-2"> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js index 40708453d79..aaadc9766db 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js @@ -89,5 +89,3 @@ export const inputStringToIsoDate = (value, utc = false) => { */ export const isoDateToInputString = (date, utc = false) => dateformat(date, dateFormats.inputFormat, utc); - -export default {}; diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index a2fe19f9672..e755494a668 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -106,7 +106,13 @@ export default { :a-mode="aMode" :b-mode="bMode" > - <slot slot="image-overlay" name="image-overlay"></slot> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </component> <slot></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue index 2b5b2269ec8..433aafdeb9e 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue @@ -141,7 +141,13 @@ export default { :path="newPath" @imgLoaded="onionNewImgLoaded" > - <slot slot="image-overlay" name="image-overlay"> </slot> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </image-viewer> </div> <div class="controls"> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue index 2f2618d448f..acca6ba117f 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue @@ -143,7 +143,13 @@ export default { class="frame added" @imgLoaded="swipeNewImgLoaded" > - <slot slot="image-overlay" name="image-overlay"> </slot> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </image-viewer> </div> <span diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue index 4dbfdb6d79c..97cac919b2a 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue @@ -44,7 +44,13 @@ export default { :inner-css-classes="['frame', 'added']" class="wrap w-50" > - <slot slot="image-overlay" name="image-overlay"> </slot> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </image-viewer> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue index 6f5a133b225..00033145603 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue @@ -76,7 +76,13 @@ export default { <div v-if="diffMode === $options.diffModes.replaced" class="diff-viewer"> <div class="image js-replaced-image"> <component :is="imageViewComponent" v-bind="$props"> - <slot slot="image-overlay" name="image-overlay"> </slot> + <template #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </component> </div> <div class="view-modes"> @@ -121,7 +127,13 @@ export default { }, ]" > - <slot v-if="isNew || isRenamed" slot="image-overlay" name="image-overlay"> </slot> + <template v-if="isNew || isRenamed" #image-overlay="{ renderedWidth, renderedHeight }"> + <slot + :rendered-width="renderedWidth" + :rendered-height="renderedHeight" + name="image-overlay" + ></slot> + </template> </image-viewer> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/dismissible_container.vue b/app/assets/javascripts/vue_shared/components/dismissible_container.vue index b4227bae09e..6d5fd065751 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_container.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_container.vue @@ -45,7 +45,7 @@ export default { data-testid="close" @click="dismiss" > - <gl-icon name="close" aria-hidden="true" class="gl-text-gray-500" /> + <gl-icon name="close" class="gl-text-gray-500" /> </button> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue index 48b94fdc181..edb5ffdc39c 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue @@ -44,6 +44,6 @@ export default { type="search" autocomplete="off" /> - <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" data-hidden="true" /> + <gl-icon name="search" class="dropdown-input-search" data-hidden="true" /> </div> </template> 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 386df617d47..05403b38850 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -234,7 +234,6 @@ export default { name="search" class="dropdown-input-search" :class="{ hidden: showClearInputButton }" - aria-hidden="true" /> <gl-icon name="close" diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index b4115b0c6a4..4d07d9fcfdd 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -143,6 +143,7 @@ export default { :style="levelIndentation" class="file-row-name" data-qa-selector="file_name_content" + data-testid="file-row-name-container" :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]" > <file-icon diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 97b4ceda033..3988b3814f9 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -286,6 +286,7 @@ export default { handleFilterSubmit() { const filterTokens = uniqueTokens(this.filterValue); this.filterValue = filterTokens; + if (this.recentSearchesStorageKey) { this.recentSearchesPromise .then(() => { @@ -302,6 +303,17 @@ export default { this.blurSearchInput(); this.$emit('onFilter', this.removeQuotesEnclosure(filterTokens)); }, + historyTokenOptionTitle(historyToken) { + const tokenOption = this.tokens + .find(token => token.type === historyToken.type) + ?.options?.find(option => option.value === historyToken.value.data); + + if (!tokenOption?.title) { + return historyToken.value.data; + } + + return tokenOption.title; + }, }, }; </script> @@ -333,7 +345,7 @@ export default { <span v-if="tokenTitles[token.type]" >{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span > - <strong>{{ tokenSymbols[token.type] }}{{ token.value.data }}</strong> + <strong>{{ tokenSymbols[token.type] }}{{ historyTokenOptionTitle(token) }}</strong> </span> </template> </template> diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue new file mode 100644 index 00000000000..1ad0ca36bf8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue @@ -0,0 +1,97 @@ +<script> +import Tribute from '@gitlab/tributejs'; +import { + GfmAutocompleteType, + tributeConfig, +} from 'ee_else_ce/vue_shared/components/gfm_autocomplete/utils'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; + +export default { + errorMessage: __( + 'An error occurred while getting autocomplete data. Please refresh the page and try again.', + ), + props: { + autocompleteTypes: { + type: Array, + required: false, + default: () => Object.values(GfmAutocompleteType), + }, + dataSources: { + type: Object, + required: false, + default: () => gl.GfmAutoComplete?.dataSources || {}, + }, + }, + computed: { + config() { + return this.autocompleteTypes.map(type => ({ + ...tributeConfig[type].config, + loadingItemTemplate: `<span class="gl-spinner gl-vertical-align-text-bottom gl-ml-3 gl-mr-2"></span>${__( + 'Loading', + )}`, + requireLeadingSpace: true, + values: this.getValues(type), + })); + }, + }, + mounted() { + this.cache = {}; + this.tribute = new Tribute({ collection: this.config }); + + const input = this.$slots.default?.[0]?.elm; + this.tribute.attach(input); + }, + beforeDestroy() { + const input = this.$slots.default?.[0]?.elm; + this.tribute.detach(input); + }, + methods: { + cacheAssignees() { + const isAssigneesLengthSame = + this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length; + + if (!this.assignees || !isAssigneesLengthSame) { + this.assignees = + SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || []; + } + }, + filterValues(type) { + // The assignees AJAX response can come after the user first invokes autocomplete + // so we need to check more than once if we need to update the assignee cache + this.cacheAssignees(); + + return tributeConfig[type].filterValues + ? tributeConfig[type].filterValues({ + assignees: this.assignees, + collection: this.cache[type], + fullText: this.$slots.default?.[0]?.elm?.value, + selectionStart: this.$slots.default?.[0]?.elm?.selectionStart, + }) + : this.cache[type]; + }, + getValues(type) { + return (inputText, processValues) => { + if (this.cache[type]) { + processValues(this.filterValues(type)); + } else if (this.dataSources[type]) { + axios + .get(this.dataSources[type]) + .then(response => { + this.cache[type] = response.data; + processValues(this.filterValues(type)); + }) + .catch(() => createFlash({ message: this.$options.errorMessage })); + } else { + processValues([]); + } + }; + }, + }, + render(createElement) { + return createElement('div', this.$slots.default); + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js new file mode 100644 index 00000000000..2581888b504 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js @@ -0,0 +1,142 @@ +import { escape, last } from 'lodash'; +import { spriteIcon } from '~/lib/utils/common_utils'; + +const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings + +const nonWordOrInteger = /\W|^\d+$/; + +export const GfmAutocompleteType = { + Issues: 'issues', + Labels: 'labels', + Members: 'members', + MergeRequests: 'mergeRequests', + Milestones: 'milestones', + Snippets: 'snippets', +}; + +function doesCurrentLineStartWith(searchString, fullText, selectionStart) { + const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; + const currentLine = fullText.split('\n')[currentLineNumber - 1]; + return currentLine.startsWith(searchString); +} + +export const tributeConfig = { + [GfmAutocompleteType.Issues]: { + config: { + trigger: '#', + lookup: value => `${value.iid}${value.title}`, + menuItemTemplate: ({ original }) => + `<small>${original.reference || original.iid}</small> ${escape(original.title)}`, + selectTemplate: ({ original }) => original.reference || `#${original.iid}`, + }, + }, + + [GfmAutocompleteType.Labels]: { + config: { + trigger: '~', + lookup: 'title', + menuItemTemplate: ({ original }) => ` + <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span> + ${escape(original.title)}`, + selectTemplate: ({ original }) => + nonWordOrInteger.test(original.title) + ? `~"${escape(original.title)}"` + : `~${escape(original.title)}`, + }, + filterValues({ collection, fullText, selectionStart }) { + if (doesCurrentLineStartWith('/label', fullText, selectionStart)) { + return collection.filter(label => !label.set); + } + + if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) { + return collection.filter(label => label.set); + } + + return collection; + }, + }, + + [GfmAutocompleteType.Members]: { + config: { + trigger: '@', + fillAttr: 'username', + lookup: value => + value.type === groupType ? last(value.name.split(' / ')) : `${value.name}${value.username}`, + menuItemTemplate: ({ original }) => { + const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; + const noAvatarClasses = `${commonClasses} gl-rounded-small + gl-display-flex gl-align-items-center gl-justify-content-center`; + + const avatar = original.avatar_url + ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />` + : `<div class="${noAvatarClasses}" aria-hidden="true"> + ${original.username.charAt(0).toUpperCase()}</div>`; + + let displayName = original.name; + let parentGroupOrUsername = `@${original.username}`; + + if (original.type === groupType) { + const splitName = original.name.split(' / '); + displayName = splitName.pop(); + parentGroupOrUsername = splitName.pop(); + } + + const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; + + const disabledMentionsIcon = original.mentionsDisabled + ? spriteIcon('notifications-off', 's16 gl-ml-3') + : ''; + + return ` + <div class="gl-display-flex gl-align-items-center"> + ${avatar} + <div class="gl-font-sm gl-line-height-normal gl-ml-3"> + <div>${escape(displayName)}${count}</div> + <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> + </div> + ${disabledMentionsIcon} + </div> + `; + }, + }, + filterValues({ assignees, collection, fullText, selectionStart }) { + if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) { + return collection.filter(member => !assignees.includes(member.username)); + } + + if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) { + return collection.filter(member => assignees.includes(member.username)); + } + + return collection; + }, + }, + + [GfmAutocompleteType.MergeRequests]: { + config: { + trigger: '!', + lookup: value => `${value.iid}${value.title}`, + menuItemTemplate: ({ original }) => + `<small>${original.reference || original.iid}</small> ${escape(original.title)}`, + selectTemplate: ({ original }) => original.reference || `!${original.iid}`, + }, + }, + + [GfmAutocompleteType.Milestones]: { + config: { + trigger: '%', + lookup: 'title', + menuItemTemplate: ({ original }) => escape(original.title), + selectTemplate: ({ original }) => `%"${escape(original.title)}"`, + }, + }, + + [GfmAutocompleteType.Snippets]: { + config: { + trigger: '$', + fillAttr: 'id', + lookup: value => `${value.id}${value.title}`, + menuItemTemplate: ({ original }) => `<small>${original.id}</small> ${escape(original.title)}`, + }, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue deleted file mode 100644 index dde7e3ebe13..00000000000 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ /dev/null @@ -1,238 +0,0 @@ -<script> -import { escape, last } from 'lodash'; -import Tribute from 'tributejs'; -import axios from '~/lib/utils/axios_utils'; -import { spriteIcon } from '~/lib/utils/common_utils'; -import SidebarMediator from '~/sidebar/sidebar_mediator'; - -const AutoComplete = { - Issues: 'issues', - Labels: 'labels', - Members: 'members', - MergeRequests: 'mergeRequests', - Milestones: 'milestones', - Snippets: 'snippets', -}; - -const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings - -function doesCurrentLineStartWith(searchString, fullText, selectionStart) { - const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; - const currentLine = fullText.split('\n')[currentLineNumber - 1]; - return currentLine.startsWith(searchString); -} - -const autoCompleteMap = { - [AutoComplete.Issues]: { - filterValues() { - return this[AutoComplete.Issues]; - }, - menuItemTemplate({ original }) { - return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`; - }, - }, - [AutoComplete.Labels]: { - filterValues() { - const fullText = this.$slots.default?.[0]?.elm?.value; - const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart; - - if (doesCurrentLineStartWith('/label', fullText, selectionStart)) { - return this.labels.filter(label => !label.set); - } - - if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) { - return this.labels.filter(label => label.set); - } - - return this.labels; - }, - menuItemTemplate({ original }) { - return ` - <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span> - ${escape(original.title)}`; - }, - }, - [AutoComplete.Members]: { - filterValues() { - const fullText = this.$slots.default?.[0]?.elm?.value; - const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart; - - // Need to check whether sidebar store assignees has been updated - // in the case where the assignees AJAX response comes after the user does @ autocomplete - const isAssigneesLengthSame = - this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length; - - if (!this.assignees || !isAssigneesLengthSame) { - this.assignees = - SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || []; - } - - if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) { - return this.members.filter(member => !this.assignees.includes(member.username)); - } - - if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) { - return this.members.filter(member => this.assignees.includes(member.username)); - } - - return this.members; - }, - menuItemTemplate({ original }) { - const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; - const noAvatarClasses = `${commonClasses} gl-rounded-small - gl-display-flex gl-align-items-center gl-justify-content-center`; - - const avatar = original.avatar_url - ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />` - : `<div class="${noAvatarClasses}" aria-hidden="true"> - ${original.username.charAt(0).toUpperCase()}</div>`; - - let displayName = original.name; - let parentGroupOrUsername = `@${original.username}`; - - if (original.type === groupType) { - const splitName = original.name.split(' / '); - displayName = splitName.pop(); - parentGroupOrUsername = splitName.pop(); - } - - const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; - - const disabledMentionsIcon = original.mentionsDisabled - ? spriteIcon('notifications-off', 's16 gl-ml-3') - : ''; - - return ` - <div class="gl-display-flex gl-align-items-center"> - ${avatar} - <div class="gl-font-sm gl-line-height-normal gl-ml-3"> - <div>${escape(displayName)}${count}</div> - <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> - </div> - ${disabledMentionsIcon} - </div> - `; - }, - }, - [AutoComplete.MergeRequests]: { - filterValues() { - return this[AutoComplete.MergeRequests]; - }, - menuItemTemplate({ original }) { - return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`; - }, - }, - [AutoComplete.Milestones]: { - filterValues() { - return this[AutoComplete.Milestones]; - }, - menuItemTemplate({ original }) { - return escape(original.title); - }, - }, - [AutoComplete.Snippets]: { - filterValues() { - return this[AutoComplete.Snippets]; - }, - menuItemTemplate({ original }) { - return `<small>${original.id}</small> ${escape(original.title)}`; - }, - }, -}; - -export default { - name: 'GlMentions', - props: { - dataSources: { - type: Object, - required: false, - default: () => gl.GfmAutoComplete?.dataSources || {}, - }, - }, - mounted() { - const NON_WORD_OR_INTEGER = /\W|^\d+$/; - - this.tribute = new Tribute({ - collection: [ - { - trigger: '#', - lookup: value => value.iid + value.title, - menuItemTemplate: autoCompleteMap[AutoComplete.Issues].menuItemTemplate, - selectTemplate: ({ original }) => original.reference || `#${original.iid}`, - values: this.getValues(AutoComplete.Issues), - }, - { - trigger: '@', - fillAttr: 'username', - lookup: value => - value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username, - menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate, - values: this.getValues(AutoComplete.Members), - }, - { - trigger: '~', - lookup: 'title', - menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate, - selectTemplate: ({ original }) => - NON_WORD_OR_INTEGER.test(original.title) - ? `~"${escape(original.title)}"` - : `~${escape(original.title)}`, - values: this.getValues(AutoComplete.Labels), - }, - { - trigger: '!', - lookup: value => value.iid + value.title, - menuItemTemplate: autoCompleteMap[AutoComplete.MergeRequests].menuItemTemplate, - selectTemplate: ({ original }) => original.reference || `!${original.iid}`, - values: this.getValues(AutoComplete.MergeRequests), - }, - { - trigger: '%', - lookup: 'title', - menuItemTemplate: autoCompleteMap[AutoComplete.Milestones].menuItemTemplate, - selectTemplate: ({ original }) => `%"${escape(original.title)}"`, - values: this.getValues(AutoComplete.Milestones), - }, - { - trigger: '$', - fillAttr: 'id', - lookup: value => value.id + value.title, - menuItemTemplate: autoCompleteMap[AutoComplete.Snippets].menuItemTemplate, - values: this.getValues(AutoComplete.Snippets), - }, - ], - }); - - const input = this.$slots.default?.[0]?.elm; - this.tribute.attach(input); - }, - beforeDestroy() { - const input = this.$slots.default?.[0]?.elm; - this.tribute.detach(input); - }, - methods: { - getValues(autoCompleteType) { - return (inputText, processValues) => { - if (this[autoCompleteType]) { - const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this); - processValues(filteredValues); - } else if (this.dataSources[autoCompleteType]) { - axios - .get(this.dataSources[autoCompleteType]) - .then(response => { - this[autoCompleteType] = response.data; - const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this); - processValues(filteredValues); - }) - .catch(() => {}); - } else { - processValues([]); - } - }; - }, - }, - render(createElement) { - return createElement('div', this.$slots.default); - }, -}; -</script> diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index 7154360611f..821ae6cec52 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { inserted } from '~/feature_highlight/feature_highlight_helper'; import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover'; @@ -11,7 +11,7 @@ import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover export default { name: 'HelpPopover', components: { - GlIcon, + GlButton, }, props: { options: { @@ -43,7 +43,5 @@ export default { }; </script> <template> - <button type="button" class="btn btn-blank btn-transparent btn-help" tabindex="0"> - <gl-icon name="question" /> - </button> + <gl-button variant="link" icon="question" tabindex="0" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js index 02f28da8bb0..61ab2a698ce 100644 --- a/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js +++ b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js @@ -1,5 +1,3 @@ export function pixeliseValue(val) { return val ? `${val}px` : ''; } - -export default {}; diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue deleted file mode 100644 index 59ce632c4a2..00000000000 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ /dev/null @@ -1,61 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -/* eslint-disable vue/require-default-prop */ -/* -This component will be deprecated in favor of gl-deprecated-button. -https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-button--loading-button -https://gitlab.com/gitlab-org/gitlab/issues/207412 -*/ - -export default { - components: { - GlLoadingIcon, - }, - props: { - loading: { - type: Boolean, - required: false, - default: false, - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - label: { - type: String, - required: false, - }, - containerClass: { - type: [String, Array, Object], - required: false, - default: 'btn btn-align-content', - }, - }, - methods: { - onClick(e) { - this.$emit('click', e); - }, - }, -}; -</script> - -<template> - <button :class="containerClass" :disabled="loading || disabled" type="button" @click="onClick"> - <transition name="fade-in"> - <gl-loading-icon - v-if="loading" - :inline="true" - :class="{ - 'gl-mr-2': label, - }" - class="js-loading-button-icon" - /> - </transition> - <transition name="fade-in"> - <slot> - <span v-if="label" class="js-loading-button-label"> {{ label }} </span> - </slot> - </transition> - </button> -</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue new file mode 100644 index 00000000000..b9729a3dc5c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -0,0 +1,59 @@ +<script> +import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; + +export default { + components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton }, + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + fileName: { + type: String, + required: true, + }, + }, + data() { + return { + message: null, + buttonText: __('Apply suggestion'), + headerText: __('Apply suggestion commit message'), + }; + }, + computed: { + placeholderText() { + return sprintf(__('Apply suggestion on %{fileName}'), { fileName: this.fileName }); + }, + }, + methods: { + onApply() { + this.$emit('apply', this.message || this.placeholderText); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :text="buttonText" + :header-text="headerText" + :disabled="disabled" + boundary="window" + right + menu-class="gl-w-full! gl-pb-0!" + > + <gl-dropdown-form class="gl-m-3!"> + <gl-form-textarea v-model="message" :placeholder="placeholderText" /> + <gl-button + class="gl-w-quarter! gl-mt-3 gl-text-center! float-right" + category="secondary" + variant="success" + @click="onApply" + > + {{ __('Apply') }} + </gl-button> + </gl-dropdown-form> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 9cfba85e0d8..232a3054cd0 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -10,14 +10,14 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import GLForm from '~/gl_form'; import MarkdownHeader from './header.vue'; import MarkdownToolbar from './toolbar.vue'; -import GlMentions from '~/vue_shared/components/gl_mentions.vue'; +import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import axios from '~/lib/utils/axios_utils'; export default { components: { - GlMentions, + GfmAutocomplete, MarkdownHeader, MarkdownToolbar, GlIcon, @@ -173,7 +173,7 @@ export default { members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - epics: this.enableAutocomplete, + epics: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, @@ -246,9 +246,9 @@ export default { /> <div v-show="!previewMarkdown" class="md-write-holder"> <div class="zen-backdrop"> - <gl-mentions v-if="glFeatures.tributeAutocomplete"> + <gfm-autocomplete v-if="glFeatures.tributeAutocomplete"> <slot name="textarea"></slot> - </gl-mentions> + </gfm-autocomplete> <slot v-else name="textarea"></slot> <a class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/vue_shared/components/members/utils.js deleted file mode 100644 index 4229a62c0a7..00000000000 --- a/app/assets/javascripts/vue_shared/components/members/utils.js +++ /dev/null @@ -1,48 +0,0 @@ -import { __ } from '~/locale'; - -export const generateBadges = (member, isCurrentUser) => [ - { - show: isCurrentUser, - text: __("It's you"), - variant: 'success', - }, - { - show: member.user?.blocked, - text: __('Blocked'), - variant: 'danger', - }, - { - show: member.user?.twoFactorEnabled, - text: __('2FA'), - variant: 'info', - }, -]; - -export const isGroup = member => { - return Boolean(member.sharedWithGroup); -}; - -export const isDirectMember = (member, sourceId) => { - return isGroup(member) || member.source?.id === sourceId; -}; - -export const isCurrentUser = (member, currentUserId) => { - return member.user?.id === currentUserId; -}; - -export const canRemove = (member, sourceId) => { - return isDirectMember(member, sourceId) && member.canRemove; -}; - -export const canResend = member => { - return Boolean(member.invite?.canResend); -}; - -export const canUpdate = (member, currentUserId, sourceId) => { - return ( - !isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate - ); -}; - -// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js` -export const canOverride = () => false; diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue index c12012d8419..ad6f6e0e2e3 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -88,7 +88,7 @@ export default { }; </script> <template> - <div class="issuable-note-warning"> + <div class="issuable-note-warning" data-testid="confidential-warning"> <gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" /> <span v-if="isLockedAndConfidential" ref="lockedAndConfidential"> diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index 8104d919bf6..85481f3f7b4 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -1,10 +1,14 @@ <script> import Pikaday from 'pikaday'; +import { GlIcon } from '@gitlab/ui'; import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; export default { name: 'DatePicker', + components: { + GlIcon, + }, props: { label: { type: String, @@ -66,7 +70,7 @@ export default { <div class="dropdown open"> <button type="button" class="dropdown-menu-toggle" data-toggle="dropdown" @click="toggled"> <span class="dropdown-toggle-text"> {{ label }} </span> - <i class="fa fa-chevron-down" aria-hidden="true"> </i> + <gl-icon name="chevron-down" class="gl-absolute gl-right-3 gl-top-3 gl-text-gray-500" /> </button> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index 9eacf74bba8..fe50a459e52 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -105,6 +105,8 @@ export default { registerHTMLToMarkdownRenderer(editorApi); this.addListeners(editorApi); + + this.$emit('load', { formattedMarkdown: editorApi.getMarkdown() }); }, onOpenAddImageModal() { this.$refs.addImageModal.show(); diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue index c90bd4da6c2..3dbf0ccdfa9 100644 --- a/app/assets/javascripts/vue_shared/components/select2_select.vue +++ b/app/assets/javascripts/vue_shared/components/select2_select.vue @@ -1,6 +1,7 @@ <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 @@ -20,10 +21,14 @@ export default { }, mounted() { - $(this.$refs.dropdownInput) - .val(this.value) - .select2(this.options) - .on('change', event => this.$emit('input', event.target.value)); + 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() { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue index 7b2802650a2..4f505b9e678 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue @@ -16,7 +16,7 @@ export default { type="button" class="dropdown-title-button dropdown-menu-close gl-ml-auto" > - <gl-icon name="close" aria-hidden="true" class="dropdown-menu-close-icon" /> + <gl-icon name="close" class="dropdown-menu-close-icon" /> </button> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 2f71907f772..8ce624aa303 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -105,6 +105,11 @@ export default { required: false, default: __('Manage group labels'), }, + isEditing: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -131,6 +136,11 @@ export default { showDropdownContents(showDropdownContents) { this.setContentIsOnViewport(showDropdownContents); }, + isEditing(newVal) { + if (newVal) { + this.toggleDropdownContents(); + } + }, }, mounted() { this.setInitialState({ diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue index 579ad53e6db..b48dfa8b452 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue @@ -1,6 +1,7 @@ <script> import { isFunction } from 'lodash'; import tooltip from '../directives/tooltip'; +import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; export default { directives: { @@ -49,7 +50,7 @@ export default { }, updateTooltip() { const target = this.selectTarget(); - this.showTooltip = Boolean(target && target.scrollWidth > target.offsetWidth); + this.showTooltip = hasHorizontalOverflow(target); }, }, }; diff --git a/app/assets/javascripts/vue_shared/directives/popover.js b/app/assets/javascripts/vue_shared/directives/popover.js deleted file mode 100644 index c913bc34c68..00000000000 --- a/app/assets/javascripts/vue_shared/directives/popover.js +++ /dev/null @@ -1,22 +0,0 @@ -import $ from 'jquery'; - -/** - * Helper to user bootstrap popover in vue.js. - * Follow docs for html attributes: https://getbootstrap.com/docs/3.3/javascript/#static-popover - * - * @example - * import popover from 'vue_shared/directives/popover.js'; - * { - * directives: [popover] - * } - * <a v-popover="{options}">popover</a> - */ -export default { - bind(el, binding) { - $(el).popover(binding.value); - }, - - unbind(el) { - $(el).popover('dispose'); - }, -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js new file mode 100644 index 00000000000..9b1cbfe218b --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js @@ -0,0 +1,8 @@ +export const SEVERITY_CLASS_NAME_MAP = { + critical: 'text-danger-800', + high: 'text-danger-600', + medium: 'text-warning-400', + low: 'text-warning-200', + info: 'text-primary-400', + unknown: 'text-secondary-400', +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue new file mode 100644 index 00000000000..3c606283c7d --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue @@ -0,0 +1,58 @@ +<script> +import { GlButton, GlIcon, GlLink, GlPopover } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlIcon, + GlLink, + GlPopover, + }, + props: { + helpPath: { + type: String, + required: true, + }, + discoverProjectSecurityPath: { + type: String, + required: false, + default: '', + }, + }, + i18n: { + securityReportsHelp: s__('SecurityReports|Security reports help page link'), + upgradeToManageVulnerabilities: s__('SecurityReports|Upgrade to manage vulnerabilities'), + upgradeToInteract: s__( + 'SecurityReports|Upgrade to interact, track and shift left with vulnerability management features in the UI.', + ), + }, +}; +</script> + +<template> + <span v-if="discoverProjectSecurityPath"> + <gl-button + ref="discoverProjectSecurity" + icon="information-o" + category="tertiary" + :aria-label="$options.i18n.upgradeToManageVulnerabilities" + /> + + <gl-popover + :target="() => $refs.discoverProjectSecurity.$el" + :title="$options.i18n.upgradeToManageVulnerabilities" + placement="top" + triggers="click blur" + > + {{ $options.i18n.upgradeToInteract }} + <gl-link :href="discoverProjectSecurityPath" target="_blank" class="gl-font-sm">{{ + __('Learn more') + }}</gl-link> + </gl-popover> + </span> + + <gl-link v-else target="_blank" :href="helpPath" :aria-label="$options.i18n.securityReportsHelp"> + <gl-icon name="question" /> + </gl-link> +</template> 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 new file mode 100644 index 00000000000..d7c1e27ff3e --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue @@ -0,0 +1,48 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'SecurityReportDownloadDropdown', + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + artifacts: { + type: Array, + required: true, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + artifactText({ name }) { + return sprintf(s__('SecurityReports|Download %{artifactName}'), { + artifactName: name, + }); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :text="s__('SecurityReports|Download results')" + :loading="loading" + icon="download" + right + > + <gl-dropdown-item + v-for="artifact in artifacts" + :key="artifact.path" + :href="artifact.path" + download + > + {{ artifactText(artifact) }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue new file mode 100644 index 00000000000..babb9fddcf6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue @@ -0,0 +1,59 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { SEVERITY_CLASS_NAME_MAP } from './constants'; + +export default { + components: { + GlSprintf, + }, + props: { + message: { + type: Object, + required: true, + }, + }, + computed: { + shouldShowCountMessage() { + return !this.message.status && Boolean(this.message.countMessage); + }, + }, + methods: { + getSeverityClass(severity) { + return SEVERITY_CLASS_NAME_MAP[severity]; + }, + }, + slotNames: ['critical', 'high', 'other'], + spacingClasses: { + critical: 'gl-pl-4', + high: 'gl-px-2', + other: 'gl-px-2', + }, +}; +</script> + +<template> + <span> + <gl-sprintf :message="message.message"> + <template #total="{content}"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + <span v-if="shouldShowCountMessage" class="gl-font-sm"> + <gl-sprintf :message="message.countMessage"> + <template v-for="slotName in $options.slotNames" #[slotName]="{content}"> + <span :key="slotName"> + <strong + v-if="message[slotName] > 0" + :class="[getSeverityClass(slotName), $options.spacingClasses[slotName]]" + > + {{ content }} + </strong> + <span v-else :class="$options.spacingClasses[slotName]"> + {{ content }} + </span> + </span> + </template> + </gl-sprintf> + </span> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index 2f87c4e7878..68241a8c5be 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -1,3 +1,32 @@ +import { invert } from 'lodash'; + export const FEEDBACK_TYPE_DISMISSAL = 'dismissal'; export const FEEDBACK_TYPE_ISSUE = 'issue'; export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request'; + +/** + * Security scan report types, as provided by the backend. + */ +export const REPORT_TYPE_SAST = 'sast'; +export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; + +/** + * SecurityReportTypeEnum values for use with GraphQL. + * + * These should correspond to the lowercase security scan report types. + */ +export const SECURITY_REPORT_TYPE_ENUM_SAST = 'SAST'; +export const SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION = 'SECRET_DETECTION'; + +/** + * A mapping from security scan report types to SecurityReportTypeEnum values. + */ +export const reportTypeToSecurityReportTypeEnum = { + [REPORT_TYPE_SAST]: SECURITY_REPORT_TYPE_ENUM_SAST, + [REPORT_TYPE_SECRET_DETECTION]: SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION, +}; + +/** + * A mapping from SecurityReportTypeEnum values to security scan report types. + */ +export const securityReportTypeEnumToReportType = invert(reportTypeToSecurityReportTypeEnum); diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql new file mode 100644 index 00000000000..310d8d88904 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql @@ -0,0 +1,23 @@ +query securityReportDownloadPaths( + $projectPath: ID! + $iid: String! + $reportTypes: [SecurityReportTypeEnum!] +) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + headPipeline { + jobs(securityReportTypes: $reportTypes) { + nodes { + name + artifacts { + nodes { + downloadPath + fileType + } + } + } + } + } + } + } +} 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 89253cc7116..bdbf9957ad4 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 @@ -1,19 +1,37 @@ <script> -import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { mapActions, mapGetters } from 'vuex'; +import { GlLink, GlSprintf } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ReportSection from '~/reports/components/report_section.vue'; -import { status } from '~/reports/constants'; +import { LOADING, ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants'; import { s__ } from '~/locale'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; -import Flash from '~/flash'; +import createFlash from '~/flash'; import Api from '~/api'; +import HelpIcon from './components/help_icon.vue'; +import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue'; +import SecuritySummary from './components/security_summary.vue'; +import store from './store'; +import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants'; +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SECRET_DETECTION, + reportTypeToSecurityReportTypeEnum, +} from './constants'; +import securityReportDownloadPathsQuery from './queries/security_report_download_paths.query.graphql'; +import { extractSecurityReportArtifacts } from './utils'; export default { + store, components: { - GlIcon, GlLink, GlSprintf, ReportSection, + HelpIcon, + SecurityReportDownloadDropdown, + SecuritySummary, }, + mixins: [glFeatureFlagsMixin()], props: { pipelineId: { type: Number, @@ -27,33 +45,131 @@ export default { type: String, required: true, }, + discoverProjectSecurityPath: { + type: String, + required: false, + default: '', + }, + sastComparisonPath: { + type: String, + required: false, + default: '', + }, + secretScanningComparisonPath: { + type: String, + required: false, + default: '', + }, + targetProjectFullPath: { + type: String, + required: false, + default: '', + }, + mrIid: { + type: Number, + required: false, + default: 0, + }, + canDiscoverProjectSecurity: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { - hasSecurityReports: false, + availableSecurityReports: [], + canShowCounts: false, - // Error state is shown even when successfully loaded, since success + // When core_security_mr_widget_counts is not enabled, the + // error state is shown even when successfully loaded, since success // state suggests that the security scans detected no security problems, // which is not necessarily the case. A future iteration will actually // check whether problems were found and display the appropriate status. - status: status.ERROR, + status: ERROR, }; }, + apollo: { + reportArtifacts: { + query: securityReportDownloadPathsQuery, + variables() { + return { + projectPath: this.targetProjectFullPath, + iid: String(this.mrIid), + reportTypes: this.$options.reportTypes.map( + reportType => reportTypeToSecurityReportTypeEnum[reportType], + ), + }; + }, + skip() { + return !this.canShowDownloads; + }, + update(data) { + return extractSecurityReportArtifacts(this.$options.reportTypes, data); + }, + error(error) { + this.showError(error); + }, + result({ loading }) { + if (loading) { + return; + } + + // Query has completed, so populate the availableSecurityReports. + this.onCheckingAvailableSecurityReports( + this.reportArtifacts.map(({ reportType }) => reportType), + ); + }, + }, + }, + computed: { + ...mapGetters(['groupedSummaryText', 'summaryStatus']), + canShowDownloads() { + return this.glFeatures.coreSecurityMrWidgetDownloads; + }, + hasSecurityReports() { + return this.availableSecurityReports.length > 0; + }, + hasSastReports() { + return this.availableSecurityReports.includes(REPORT_TYPE_SAST); + }, + hasSecretDetectionReports() { + return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION); + }, + isLoadingReportArtifacts() { + return this.$apollo.queries.reportArtifacts.loading; + }, + shouldShowDownloadGuidance() { + return !this.canShowDownloads && this.summaryStatus !== LOADING; + }, + scansHaveRunMessage() { + return this.canShowDownloads + ? this.$options.i18n.scansHaveRun + : this.$options.i18n.scansHaveRunWithDownloadGuidance; + }, + }, created() { - this.checkHasSecurityReports(this.$options.reportTypes) - .then(hasSecurityReports => { - this.hasSecurityReports = hasSecurityReports; - }) - .catch(error => { - Flash({ - message: this.$options.i18n.apiError, - captureError: true, - error, - }); - }); + if (!this.canShowDownloads) { + this.checkAvailableSecurityReports(this.$options.reportTypes) + .then(availableSecurityReports => { + this.onCheckingAvailableSecurityReports(Array.from(availableSecurityReports)); + }) + .catch(this.showError); + } }, methods: { - async checkHasSecurityReports(reportTypes) { + ...mapActions(MODULE_SAST, { + setSastDiffEndpoint: 'setDiffEndpoint', + fetchSastDiff: 'fetchDiff', + }), + ...mapActions(MODULE_SECRET_DETECTION, { + setSecretDetectionDiffEndpoint: 'setDiffEndpoint', + fetchSecretDetectionDiff: 'fetchDiff', + }), + async checkAvailableSecurityReports(reportTypes) { + const reportTypesSet = new Set(reportTypes); + const availableReportTypes = new Set(); + let page = 1; while (page) { // eslint-disable-next-line no-await-in-loop @@ -62,47 +178,127 @@ export default { page, }); - const hasSecurityReports = jobs.some(({ artifacts = [] }) => - artifacts.some(({ file_type }) => reportTypes.includes(file_type)), - ); + jobs.forEach(({ artifacts = [] }) => { + artifacts.forEach(({ file_type }) => { + if (reportTypesSet.has(file_type)) { + availableReportTypes.add(file_type); + } + }); + }); - if (hasSecurityReports) { - return true; + // If we've found artifacts for all the report types, stop looking! + if (availableReportTypes.size === reportTypesSet.size) { + return availableReportTypes; } page = parseIntPagination(normalizeHeaders(headers)).nextPage; } - return false; + return availableReportTypes; + }, + fetchCounts() { + if (!this.glFeatures.coreSecurityMrWidgetCounts) { + return; + } + + if (this.sastComparisonPath && this.hasSastReports) { + this.setSastDiffEndpoint(this.sastComparisonPath); + this.fetchSastDiff(); + this.canShowCounts = true; + } + + if (this.secretScanningComparisonPath && this.hasSecretDetectionReports) { + this.setSecretDetectionDiffEndpoint(this.secretScanningComparisonPath); + this.fetchSecretDetectionDiff(); + this.canShowCounts = true; + } }, activatePipelinesTab() { if (window.mrTabs) { window.mrTabs.tabShown('pipelines'); } }, + onCheckingAvailableSecurityReports(availableSecurityReports) { + this.availableSecurityReports = availableSecurityReports; + this.fetchCounts(); + }, + showError(error) { + createFlash({ + message: this.$options.i18n.apiError, + captureError: true, + error, + }); + }, }, - reportTypes: ['sast', 'secret_detection'], + reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION], i18n: { apiError: s__( 'SecurityReports|Failed to get security report information. Please reload the page or try again later.', ), - scansHaveRun: s__( + scansHaveRun: s__('SecurityReports|Security scans have run'), + scansHaveRunWithDownloadGuidance: s__( 'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', ), - securityReportsHelp: s__('SecurityReports|Security reports help page link'), + downloadFromPipelineTab: s__( + 'SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', + ), }, + summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR], }; </script> <template> <report-section - v-if="hasSecurityReports" + v-if="canShowCounts" + :status="summaryStatus" + :has-issues="false" + class="mr-widget-border-top mr-report" + data-testid="security-mr-widget" + > + <template v-for="slot in $options.summarySlots" #[slot]> + <span :key="slot"> + <security-summary :message="groupedSummaryText" /> + + <help-icon + :help-path="securityReportsDocsPath" + :discover-project-security-path="discoverProjectSecurityPath" + /> + </span> + </template> + + <template v-if="shouldShowDownloadGuidance" #sub-heading> + <span class="gl-font-sm"> + <gl-sprintf :message="$options.i18n.downloadFromPipelineTab"> + <template #link="{ content }"> + <gl-link + class="gl-font-sm" + data-testid="show-pipelines" + @click="activatePipelinesTab" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </span> + </template> + + <template v-if="canShowDownloads" #action-buttons> + <security-report-download-dropdown + :artifacts="reportArtifacts" + :loading="isLoadingReportArtifacts" + /> + </template> + </report-section> + + <!-- TODO: Remove this section when removing core_security_mr_widget_counts + feature flag. See https://gitlab.com/gitlab-org/gitlab/-/issues/284097 --> + <report-section + v-else-if="hasSecurityReports" :status="status" :has-issues="false" class="mr-widget-border-top mr-report" data-testid="security-mr-widget" > <template #error> - <gl-sprintf :message="$options.i18n.scansHaveRun"> + <gl-sprintf :message="scansHaveRunMessage"> <template #link="{ content }"> <gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{ content @@ -110,14 +306,17 @@ export default { </template> </gl-sprintf> - <gl-link - target="_blank" - data-testid="help" - :href="securityReportsDocsPath" - :aria-label="$options.i18n.securityReportsHelp" - > - <gl-icon name="question" /> - </gl-link> + <help-icon + :help-path="securityReportsDocsPath" + :discover-project-security-path="discoverProjectSecurityPath" + /> + </template> + + <template v-if="canShowDownloads" #action-buttons> + <security-report-download-dropdown + :artifacts="reportArtifacts" + :loading="isLoadingReportArtifacts" + /> </template> </report-section> </template> diff --git a/app/assets/javascripts/vue_shared/security_reports/store/constants.js b/app/assets/javascripts/vue_shared/security_reports/store/constants.js new file mode 100644 index 00000000000..6aeab56eea2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/constants.js @@ -0,0 +1,7 @@ +/** + * Vuex module names corresponding to security scan types. These are similar to + * the snake_case report types from the backend, but should not be considered + * to be equivalent. + */ +export const MODULE_SAST = 'sast'; +export const MODULE_SECRET_DETECTION = 'secretDetection'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/getters.js b/app/assets/javascripts/vue_shared/security_reports/store/getters.js new file mode 100644 index 00000000000..1e5a60c32fd --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/getters.js @@ -0,0 +1,66 @@ +import { s__, sprintf } from '~/locale'; +import { countVulnerabilities, groupedTextBuilder } from './utils'; +import { LOADING, ERROR, SUCCESS } from '~/reports/constants'; +import { TRANSLATION_IS_LOADING } from './messages'; + +export const summaryCounts = state => + countVulnerabilities( + state.reportTypes.reduce((acc, reportType) => { + acc.push(...state[reportType].newIssues); + return acc; + }, []), + ); + +export const groupedSummaryText = (state, getters) => { + const reportType = s__('ciReport|Security scanning'); + let status = ''; + + // All reports are loading + if (getters.areAllReportsLoading) { + return { message: sprintf(TRANSLATION_IS_LOADING, { reportType }) }; + } + + // All reports returned error + if (getters.allReportsHaveError) { + return { message: s__('ciReport|Security scanning failed loading any results') }; + } + + if (getters.areReportsLoading && getters.anyReportHasError) { + status = s__('ciReport|is loading, errors when loading results'); + } else if (getters.areReportsLoading && !getters.anyReportHasError) { + status = s__('ciReport|is loading'); + } else if (!getters.areReportsLoading && getters.anyReportHasError) { + status = s__('ciReport|: Loading resulted in an error'); + } + + const { critical, high, other } = getters.summaryCounts; + + return groupedTextBuilder({ reportType, status, critical, high, other }); +}; + +export const summaryStatus = (state, getters) => { + if (getters.areReportsLoading) { + return LOADING; + } + + if (getters.anyReportHasError || getters.anyReportHasIssues) { + return ERROR; + } + + return SUCCESS; +}; + +export const areReportsLoading = state => + state.reportTypes.some(reportType => state[reportType].isLoading); + +export const areAllReportsLoading = state => + state.reportTypes.every(reportType => state[reportType].isLoading); + +export const allReportsHaveError = state => + state.reportTypes.every(reportType => state[reportType].hasError); + +export const anyReportHasError = state => + state.reportTypes.some(reportType => state[reportType].hasError); + +export const anyReportHasIssues = state => + state.reportTypes.some(reportType => state[reportType].newIssues.length > 0); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/index.js b/app/assets/javascripts/vue_shared/security_reports/store/index.js new file mode 100644 index 00000000000..10705e04a21 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/index.js @@ -0,0 +1,16 @@ +import Vuex from 'vuex'; +import * as getters from './getters'; +import state from './state'; +import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants'; +import sast from './modules/sast'; +import secretDetection from './modules/secret_detection'; + +export default () => + new Vuex.Store({ + modules: { + [MODULE_SAST]: sast, + [MODULE_SECRET_DETECTION]: secretDetection, + }, + getters, + state, + }); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/messages.js b/app/assets/javascripts/vue_shared/security_reports/store/messages.js new file mode 100644 index 00000000000..c25e252a768 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/messages.js @@ -0,0 +1,4 @@ +import { s__ } from '~/locale'; + +export const TRANSLATION_IS_LOADING = s__('ciReport|%{reportType} is loading'); +export const TRANSLATION_HAS_ERROR = s__('ciReport|%{reportType}: Loading resulted in an error'); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/state.js b/app/assets/javascripts/vue_shared/security_reports/store/state.js new file mode 100644 index 00000000000..5dc4d1ad2fb --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/state.js @@ -0,0 +1,5 @@ +import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants'; + +export default () => ({ + reportTypes: [MODULE_SAST, MODULE_SECRET_DETECTION], +}); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js index 6e50efae741..c5e786c92b1 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -1,5 +1,7 @@ import pollUntilComplete from '~/lib/utils/poll_until_complete'; import axios from '~/lib/utils/axios_utils'; +import { __, n__, sprintf } from '~/locale'; +import { CRITICAL, HIGH } from '~/vulnerabilities/constants'; import { FEEDBACK_TYPE_DISMISSAL, FEEDBACK_TYPE_ISSUE, @@ -73,3 +75,79 @@ export const parseDiff = (diff, enrichData) => { existing: diff.existing ? diff.existing.map(enrichVulnerability) : [], }; }; + +const createCountMessage = ({ critical, high, other, total }) => { + const otherMessage = n__('%d Other', '%d Others', other); + const countMessage = __( + '%{criticalStart}%{critical} Critical%{criticalEnd} %{highStart}%{high} High%{highEnd} and %{otherStart}%{otherMessage}%{otherEnd}', + ); + return total ? sprintf(countMessage, { critical, high, otherMessage }) : ''; +}; + +const createStatusMessage = ({ reportType, status, total }) => { + const vulnMessage = n__('vulnerability', 'vulnerabilities', total); + let message; + if (status) { + message = __('%{reportType} %{status}'); + } else if (!total) { + message = __('%{reportType} detected %{totalStart}no%{totalEnd} vulnerabilities.'); + } else { + message = __( + '%{reportType} detected %{totalStart}%{total}%{totalEnd} potential %{vulnMessage}', + ); + } + return sprintf(message, { reportType, status, total, vulnMessage }); +}; + +/** + * Counts vulnerabilities. + * Returns the amount of critical, high, and other vulnerabilities. + * + * @param {Array} vulnerabilities The raw vulnerabilities to parse + * @returns {{critical: number, high: number, other: number}} + */ +export const countVulnerabilities = (vulnerabilities = []) => + vulnerabilities.reduce( + (acc, { severity }) => { + if (severity === CRITICAL) { + acc.critical += 1; + } else if (severity === HIGH) { + acc.high += 1; + } else { + acc.other += 1; + } + + return acc; + }, + { critical: 0, high: 0, other: 0 }, + ); + +/** + * Takes an object of options and returns the object with an externalized string representing + * the critical, high, and other severity vulnerabilities for a given report. + * + * The resulting string _may_ still contain sprintf-style placeholders. These + * are left in place so they can be replaced with markup, via the + * SecuritySummary component. + * @param {{reportType: string, status: string, critical: number, high: number, other: number}} options + * @returns {Object} the parameters with an externalized string + */ +export const groupedTextBuilder = ({ + reportType = '', + status = '', + critical = 0, + high = 0, + other = 0, +} = {}) => { + const total = critical + high + other; + + return { + countMessage: createCountMessage({ critical, high, other, total }), + message: createStatusMessage({ reportType, status, total }), + critical, + high, + other, + status, + total, + }; +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js new file mode 100644 index 00000000000..827a87f9aaf --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/utils.js @@ -0,0 +1,22 @@ +import { securityReportTypeEnumToReportType } from './constants'; + +export const extractSecurityReportArtifacts = (reportTypes, data) => { + const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? []; + + return jobs.reduce((acc, job) => { + const artifacts = job.artifacts?.nodes ?? []; + + artifacts.forEach(({ downloadPath, fileType }) => { + const reportType = securityReportTypeEnumToReportType[fileType]; + if (reportType && reportTypes.includes(reportType)) { + acc.push({ + name: job.name, + reportType, + path: downloadPath, + }); + } + }); + + return acc; + }, []); +}; diff --git a/app/assets/javascripts/vuex_shared/modules/members/index.js b/app/assets/javascripts/vuex_shared/modules/members/index.js deleted file mode 100644 index 586d52a5288..00000000000 --- a/app/assets/javascripts/vuex_shared/modules/members/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import createState from 'ee_else_ce/vuex_shared/modules/members/state'; -import mutations from 'ee_else_ce/vuex_shared/modules/members/mutations'; -import * as actions from 'ee_else_ce/vuex_shared/modules/members/actions'; - -export default initialState => ({ - namespaced: true, - state: createState(initialState), - actions, - mutations, -}); diff --git a/app/assets/javascripts/vulnerabilities/constants.js b/app/assets/javascripts/vulnerabilities/constants.js new file mode 100644 index 00000000000..42fb38e8e7e --- /dev/null +++ b/app/assets/javascripts/vulnerabilities/constants.js @@ -0,0 +1,15 @@ +/** + * Vulnerability severities as provided by the backend on vulnerability + * objects. + */ +export const CRITICAL = 'critical'; +export const HIGH = 'high'; +export const MEDIUM = 'medium'; +export const LOW = 'low'; +export const INFO = 'info'; +export const UNKNOWN = 'unknown'; + +/** + * All vulnerability severities in decreasing order. + */ +export const SEVERITIES = [CRITICAL, HIGH, MEDIUM, LOW, INFO, UNKNOWN]; diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index 3c1de57252a..560cabd3bba 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -2,13 +2,15 @@ import { mapState, mapActions } from 'vuex'; import { GlDrawer, - GlBadge, - GlIcon, - GlLink, GlInfiniteScroll, GlResizeObserverDirective, + GlTabs, + GlTab, + GlBadge, + GlLoadingIcon, } from '@gitlab/ui'; import SkeletonLoader from './skeleton_loader.vue'; +import Feature from './feature.vue'; import Tracking from '~/tracking'; import { getDrawerBodyHeight } from '../utils/get_drawer_body_height'; @@ -17,11 +19,13 @@ const trackingMixin = Tracking.mixin(); export default { components: { GlDrawer, - GlBadge, - GlIcon, - GlLink, GlInfiniteScroll, + GlTabs, + GlTab, SkeletonLoader, + Feature, + GlBadge, + GlLoadingIcon, }, directives: { GlResizeObserver: GlResizeObserverDirective, @@ -31,11 +35,19 @@ export default { storageKey: { type: String, required: true, - default: null, + }, + versions: { + type: Array, + required: true, + }, + gitlabDotCom: { + type: Boolean, + required: false, + default: false, }, }, computed: { - ...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight']), + ...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']), }, mounted() { this.openDrawer(this.storageKey); @@ -49,14 +61,25 @@ export default { methods: { ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems', 'setDrawerBodyHeight']), bottomReached() { - if (this.pageInfo.nextPage) { - this.fetchItems(this.pageInfo.nextPage); + const page = this.pageInfo.nextPage; + if (page) { + this.fetchItems({ page }); } }, handleResize() { const height = getDrawerBodyHeight(this.$refs.drawer.$el); this.setDrawerBodyHeight(height); }, + featuresForVersion(version) { + return this.features.filter(feature => { + return feature.release === parseFloat(version); + }); + }, + fetchVersion(version) { + if (this.featuresForVersion(version).length === 0) { + this.fetchItems({ version }); + } + }, }, }; </script> @@ -73,64 +96,39 @@ export default { <template #header> <h4 class="page-title gl-my-2">{{ __("What's new at GitLab") }}</h4> </template> - <gl-infinite-scroll - v-if="features.length" - :fetched-items="features.length" - :max-list-height="drawerBodyHeight" - class="gl-p-0" - @bottomReached="bottomReached" - > - <template #items> - <div - v-for="feature in features" - :key="feature.title" - class="gl-pb-7 gl-pt-5 gl-px-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + <template v-if="features.length"> + <gl-infinite-scroll + v-if="gitlabDotCom" + :fetched-items="features.length" + :max-list-height="drawerBodyHeight" + class="gl-p-0" + @bottomReached="bottomReached" + > + <template #items> + <feature v-for="feature in features" :key="feature.title" :feature="feature" /> + </template> + </gl-infinite-scroll> + <gl-tabs v-else :style="{ height: `${drawerBodyHeight}px` }" class="gl-p-0"> + <gl-tab + v-for="(version, index) in versions" + :key="version" + @click="fetchVersion(version)" > - <gl-link - :href="feature.url" - target="_blank" - class="whats-new-item-title-link" - data-track-event="click_whats_new_item" - :data-track-label="feature.title" - :data-track-property="feature.url" - > - <h5 class="gl-font-lg">{{ feature.title }}</h5> - </gl-link> - <div v-if="feature.packages" class="gl-mb-3"> - <gl-badge - v-for="package_name in feature.packages" - :key="package_name" - size="sm" - class="whats-new-item-badge gl-mr-2" - > - <gl-icon name="license" />{{ package_name }} - </gl-badge> - </div> - <gl-link - :href="feature.url" - target="_blank" - data-track-event="click_whats_new_item" - :data-track-label="feature.title" - :data-track-property="feature.url" - > - <img - :alt="feature.title" - :src="feature.image_url" - class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image" + <template #title> + <span>{{ version }}</span> + <gl-badge v-if="index === 0">{{ __('Your Version') }}</gl-badge> + </template> + <gl-loading-icon v-if="fetching" size="lg" class="text-center" /> + <template v-else> + <feature + v-for="feature in featuresForVersion(version)" + :key="feature.title" + :feature="feature" /> - </gl-link> - <p class="gl-pt-3">{{ feature.body }}</p> - <gl-link - :href="feature.url" - target="_blank" - data-track-event="click_whats_new_item" - :data-track-label="feature.title" - :data-track-property="feature.url" - >{{ __('Learn more') }}</gl-link - > - </div> - </template> - </gl-infinite-scroll> + </template> + </gl-tab> + </gl-tabs> + </template> <div v-else class="gl-mt-5"> <skeleton-loader /> <skeleton-loader /> diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue new file mode 100644 index 00000000000..f6f7618b0d8 --- /dev/null +++ b/app/assets/javascripts/whats_new/components/feature.vue @@ -0,0 +1,67 @@ +<script> +import { GlBadge, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + GlIcon, + GlLink, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + feature: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="gl-pb-7 gl-pt-5 gl-px-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> + <gl-link + :href="feature.url" + target="_blank" + class="whats-new-item-title-link" + data-track-event="click_whats_new_item" + :data-track-label="feature.title" + :data-track-property="feature.url" + > + <h5 class="gl-font-lg" data-test-id="feature-title">{{ feature.title }}</h5> + </gl-link> + <div v-if="feature.packages" class="gl-mb-3"> + <gl-badge + v-for="packageName in feature.packages" + :key="packageName" + size="sm" + class="whats-new-item-badge gl-mr-2" + > + <gl-icon name="license" />{{ packageName }} + </gl-badge> + </div> + <gl-link + :href="feature.url" + target="_blank" + data-track-event="click_whats_new_item" + :data-track-label="feature.title" + :data-track-property="feature.url" + > + <img + :alt="feature.title" + :src="feature.image_url" + class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image" + /> + </gl-link> + <div v-safe-html="feature.body" class="gl-pt-3"></div> + <gl-link + :href="feature.url" + target="_blank" + data-track-event="click_whats_new_item" + :data-track-label="feature.title" + :data-track-property="feature.url" + >{{ __('Learn more') }}</gl-link + > + </div> +</template> diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js index a57c9718156..ed0258c3992 100644 --- a/app/assets/javascripts/whats_new/index.js +++ b/app/assets/javascripts/whats_new/index.js @@ -1,25 +1,35 @@ import Vue from 'vue'; +import { mapState } from 'vuex'; import App from './components/app.vue'; import store from './store'; +import { getStorageKey, setNotification } from './utils/notification'; let whatsNewApp; -export default () => { +export default el => { if (whatsNewApp) { store.dispatch('openDrawer'); } else { - const whatsNewElm = document.getElementById('whats-new-app'); - whatsNewApp = new Vue({ - el: whatsNewElm, + el, store, components: { App, }, + computed: { + ...mapState(['open']), + }, + watch: { + open() { + setNotification(el); + }, + }, render(createElement) { return createElement('app', { props: { - storageKey: whatsNewElm.getAttribute('data-storage-key'), + storageKey: getStorageKey(el), + versions: JSON.parse(el.getAttribute('data-versions')), + gitlabDotCom: el.getAttribute('data-gitlab-dot-com'), }, }); }, diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js index 532febd61cb..0e5eeda742a 100644 --- a/app/assets/javascripts/whats_new/store/actions.js +++ b/app/assets/javascripts/whats_new/store/actions.js @@ -13,7 +13,7 @@ export default { localStorage.setItem(storageKey, JSON.stringify(false)); } }, - fetchItems({ commit, state }, page) { + fetchItems({ commit, state }, { page, version } = { page: null, version: null }) { if (state.fetching) { return false; } @@ -24,6 +24,7 @@ export default { .get('/-/whats_new', { params: { page, + version, }, }) .then(({ data, headers }) => { diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js new file mode 100644 index 00000000000..f261a089554 --- /dev/null +++ b/app/assets/javascripts/whats_new/utils/notification.js @@ -0,0 +1,17 @@ +export const getStorageKey = appEl => appEl.getAttribute('data-storage-key'); + +export const setNotification = appEl => { + const storageKey = getStorageKey(appEl); + const notificationEl = document.querySelector('.header-help'); + let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count'); + + if (JSON.parse(localStorage.getItem(storageKey)) === false) { + notificationEl.classList.remove('with-notifications'); + if (notificationCountEl) { + notificationCountEl.parentElement.removeChild(notificationCountEl); + notificationCountEl = null; + } + } else { + notificationEl.classList.add('with-notifications'); + } +}; diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 52bc19fddd9..f56665553ba 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -10,7 +10,6 @@ @import './pages/events'; @import './pages/groups'; @import './pages/help'; -@import './pages/import'; @import './pages/incident_management_list'; @import './pages/issuable'; @import './pages/issues/issue_count_badge'; diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 4b1139d2354..9ef1b58ed24 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -5,14 +5,10 @@ // directory. @import '@gitlab/at.js/dist/css/jquery.atwho'; @import 'dropzone/dist/basic'; -@import 'select2'; // GitLab UI framework @import 'framework'; -// Custom Fontawesome icons -@import 'fontawesome_custom'; - // Page specific styles (issues, projects etc): @import 'page_specific_files'; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index 3d5076f485c..deeef86c386 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -235,10 +235,6 @@ h3.popover-header { @extend .border-0; } - &.card-without-margin { - margin: 0; - } - &.bg-light { @extend .border-0; } diff --git a/app/assets/stylesheets/components/milestone_combobox.scss b/app/assets/stylesheets/components/milestone_combobox.scss index e0637088bbb..f73ec4d5998 100644 --- a/app/assets/stylesheets/components/milestone_combobox.scss +++ b/app/assets/stylesheets/components/milestone_combobox.scss @@ -1,11 +1,6 @@ -.selected-item::before { - content: '\f00c'; - color: $green-500; - position: absolute; - left: 16px; - top: 16px; - transform: translateY(-50%); - font: 14px FontAwesome; +.selected-item { + /* stylelint-disable-next-line function-url-quotes */ + background: url(asset_path('checkmark.png')) no-repeat 0 2px; } .dropdown-item-space { diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss deleted file mode 100644 index f870948cc4f..00000000000 --- a/app/assets/stylesheets/components/popover.scss +++ /dev/null @@ -1,111 +0,0 @@ -.popover { - max-width: $popover-max-width; - border: 1px solid $gray-100; - box-shadow: $popover-box-shadow; - font-size: $gl-font-size-small; - - /** - * Blue popover variation - */ - &.blue { - background-color: $blue-600; - border-color: $blue-600; - - .popover-body { - color: $white; - } - - &.bs-popover-bottom { - .arrow::before, - .arrow::after { - border-bottom-color: $blue-600; - } - } - - &.bs-popover-top { - .arrow::before, - .arrow::after { - border-top-color: $blue-600; - } - } - - &.bs-popover-right { - .arrow::after, - .arrow::before { - border-right-color: $blue-600; - } - } - - &.bs-popover-left { - .arrow::before, - .arrow::after { - border-left-color: $blue-600; - } - } - } -} - -.bs-popover-top { - /* When popover position is top, the arrow is translated 1 pixel - * due to the box-shadow include in our custom styles. - */ - > .arrow::before { - border-top-color: $gray-100; - bottom: 1px; - } - - > .arrow::after { - bottom: 2px; - } -} - -.bs-popover-bottom { - > .arrow::before { - border-bottom-color: $gray-100; - } - - > .popover-header::before { - border-color: $white; - } -} - -.bs-popover-right > .arrow::before { - border-right-color: $gray-100; -} - -.bs-popover-left > .arrow::before { - border-left-color: $gray-100; -} - -.popover-header { - background-color: $white; - font-size: $gl-font-size-small; -} - -.popover-body { - padding: $gl-padding $gl-padding-12; - - > .popover-hr { - margin: $gl-padding 0; - } -} - -/** -* mr_popover component -*/ -.mr-popover { - .text-secondary { - font-size: 12px; - line-height: 1.33; - } -} - -.suggest-gitlab-ci-yml { - margin-top: -1em; - - .popover-header { - padding: $gl-padding; - display: flex; - align-items: center; - } -} diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss index 64e82531c30..51bf2686be2 100644 --- a/app/assets/stylesheets/components/whats_new.scss +++ b/app/assets/stylesheets/components/whats_new.scss @@ -6,6 +6,32 @@ .gl-infinite-scroll-legend { @include gl-display-none; } + + .gl-tabs { + @include gl-overflow-y-auto; + } + + .gl-tabs-nav { + flex-wrap: nowrap; + overflow-x: scroll; + align-items: stretch; + + .nav-item { + @include gl-flex-shrink-0; + + a { + @include gl-h-full; + line-height: 1.5; + } + } + } + + .gl-spinner-container { + @include gl-w-full; + @include gl-absolute; + top: 50%; + transform: translateY(-50%); + } } .with-performance-bar .whats-new-drawer { diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss index 8a955cffc49..b9bb3edaaab 100644 --- a/app/assets/stylesheets/fontawesome_custom.scss +++ b/app/assets/stylesheets/fontawesome_custom.scss @@ -1,191 +1,43 @@ -/*! - * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */ - -// stylelint-disable property-no-vendor-prefix -// stylelint-disable at-rule-no-vendor-prefix -// stylelint-disable stylelint-gitlab/duplicate-selectors -// scss-lint:disable MergeableSelector -@font-face { - font-family: 'FontAwesome'; - src: asset-url('fontawesome-webfont.woff2?v=4.7.0') format('woff2'), asset-url('fontawesome-webfont.woff?v=4.7.0') format('woff'); - font-weight: normal; - font-style: normal; -} - -.fa { - display: inline-block; - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* makes the font 33% larger relative to the icon container */ -.fa-lg { - font-size: 1.33333333em; - line-height: 0.75em; - vertical-align: -15%; -} - -.fa-2x { - font-size: 2em; -} - -.fa-3x { - font-size: 3em; -} - -.fa-4x { - font-size: 4em; -} - -.fa-5x { - font-size: 5em; -} - -.fa-fw { - width: 1.28571429em; - text-align: center; -} - -.fa-spin { - -webkit-animation: fa-spin 2s infinite linear; - animation: fa-spin 2s infinite linear; -} - -@-webkit-keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); +// Custom Font Awesome styles that render emojis in asciidoc +.md { + .fa { + display: inline-block; + font-style: normal; + font-size: 14px; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); + .fa-2x { + font-size: 2em; } -} -@keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); + .fa-exclamation-triangle::before { + content: '⚠'; } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); + .fa-exclamation-circle::before { + content: '❗'; } -} - -.fa-inverse { - color: $white; -} - -.fa-chevron-down::before { - content: '\f078'; -} - -.fa-caret-down::before { - content: '\f0d7'; -} - -.fa-warning::before, -.fa-exclamation-triangle::before { - content: '\f071'; -} - -.fa-spinner::before { - content: '\f110'; -} - -.fa-caret-right::before { - content: '\f0da'; -} - -.fa-exclamation-circle::before { - content: '\f06a'; -} - -.fa-file-o::before { - content: '\f016'; -} - -.fa-lightbulb-o::before { - content: '\f0eb'; -} - -.fa-circle::before { - content: '\f111'; -} - -.fa-thumb-tack::before { - content: '\f08d'; -} - -.fa-fire::before { - content: '\f06d'; -} - -.fa-file-pdf-o::before { - content: '\f1c1'; -} - -.fa-file-word-o::before { - content: '\f1c2'; -} - -.fa-file-excel-o::before { - content: '\f1c3'; -} -.fa-file-powerpoint-o::before { - content: '\f1c4'; -} - -.fa-file-image-o::before { - content: '\f1c5'; -} - -.fa-file-archive-o::before { - content: '\f1c6'; -} - -.fa-file-audio-o::before { - content: '\f1c7'; -} - -.fa-file-video-o::before { - content: '\f1c8'; -} + .fa-lightbulb-o::before { + content: '💡'; + } -.fa-square-o::before { - content: '\f096'; -} + .fa-thumb-tack::before { + content: '📌'; + } -.fa-check-square-o::before { - content: '\f046'; -} + .fa-fire::before { + content: '🔥'; + } -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; -} + .fa-square-o::before { + content: '\2610'; + } -.sr-only-focusable:active, -.sr-only-focusable:focus { - position: static; - width: auto; - height: auto; - margin: 0; - overflow: visible; - clip: auto; + .fa-check-square-o::before { + content: '\2611'; + } } diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 196fb3a7088..a93c70c75d3 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -103,7 +103,8 @@ @include transition(color); } -a { +a, +.notification-dot { @include transition(background-color, color, border); } diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 4f09f1a394b..d9ad4992458 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -253,3 +253,111 @@ vertical-align: middle; } } + + +// The following encompasses the "add reaction" button redesign to +// align properly within GitLab UI's gl-button. The implementation +// above will be deprecated once all instances of "award emoji" are +// migrated to Vue. + +.gl-button .award-emoji-block gl-emoji { + top: -1px; + margin-top: -1px; + margin-bottom: -1px; +} + +.add-reaction-button { + position: relative; + + // This forces the height and width of the inner content to match + // other gl-buttons despite all child elements being set to + // `position:absolute` + &::after { + content: '\a0'; + width: 1em; + } + + .reaction-control-icon { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + + // center the icon vertically and horizontally within the button + display: flex; + align-items: center; + justify-content: center; + + @include transition(opacity, transform); + + .gl-icon { + height: $default-icon-size; + width: $default-icon-size; + } + } + + .reaction-control-icon-neutral { + opacity: 1; + } + + .reaction-control-icon-positive, + .reaction-control-icon-super-positive { + opacity: 0; + } + + &:hover, + &.active, + &:active, + &.is-active { + // extra specificty added to override another selector + .reaction-control-icon .gl-icon { + color: $blue-500; + transform: scale(1.15); + } + + .reaction-control-icon-neutral { + opacity: 0; + } + } + + &:hover { + .reaction-control-icon-positive { + opacity: 1; + } + } + + &.active, + &:active, + &.is-active { + .reaction-control-icon-positive { + opacity: 0; + } + + .reaction-control-icon-super-positive { + opacity: 1; + } + } + + &.disabled { + cursor: default; + + &:hover, + &:focus, + &:active { + .reaction-control-icon .gl-icon { + color: inherit; + transform: scale(1); + } + + .reaction-control-icon-neutral { + opacity: 1; + } + + .reaction-control-icon-positive, + .reaction-control-icon-super-positive { + opacity: 0; + } + } + } +} diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index f42e500efa8..bfa4a640fe2 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -73,7 +73,7 @@ &.content-component-block { padding: 11px 0; - background-color: $white; + background-color: $body-bg; } .title { diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index a8cc685d880..182c58c3931 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -166,8 +166,7 @@ line-height: $gl-btn-xs-line-height; } - &.btn-success, - &.btn-register { + &.btn-success { @include btn-green; } @@ -176,7 +175,6 @@ @include btn-outline($white, $green-600, $green-500, $green-100, $green-700, $green-500, $green-200, $green-600, $green-800); } - &.btn-remove, &.btn-danger { @include btn-outline($white, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800); } @@ -200,18 +198,11 @@ @include btn-orange; } - &.btn-close, - &.btn-close-color { + &.btn-close { @include btn-outline($white, $orange-500, $orange-500, $orange-50, $orange-600, $orange-600, $orange-100, $orange-700, $orange-700); } - &.btn-spam { - @include btn-outline($white, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800); - } - - &.btn-danger, - &.btn-remove, - &.btn-red { + &.btn-danger { @include btn-red; } @@ -219,11 +210,6 @@ float: right; } - &.btn-reopen, - .btn-reopen-color { - /* should be same as parent class for now */ - } - &.btn-grouped { @include btn-with-margin; } @@ -232,17 +218,6 @@ color: $gray-700; } - .fa-caret-down, - .fa-chevron-down { - margin-left: 5px; - } - - &.dropdown-toggle { - .fa-caret-down { - margin-left: 3px; - } - } - &.btn-text-field { width: 100%; text-align: left; @@ -276,11 +251,8 @@ width: 15px; } - svg, - .fa { - &:not(:last-child) { - margin-right: 5px; - } + svg:not(:last-child) { + margin-right: 5px; } } @@ -370,24 +342,15 @@ .btn-loading { &:not(.disabled) { - .fa, .spinner { display: none; } } - - .fa { - margin-right: 5px; - } } .btn-build { margin-left: 10px; - i { - color: $gl-text-color-secondary; - } - svg { fill: $gl-text-color-secondary; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index deb2d6c4641..3b59c028437 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -135,7 +135,6 @@ hr { text-overflow: ellipsis; white-space: nowrap; - > div:not(.block):not(.select2-display-none), .str-truncated { display: inline; } @@ -389,11 +388,7 @@ img.emoji { 🚨 Do not use these classes — they are deprecated and being removed. 🚨 See https://gitlab.com/gitlab-org/gitlab/-/issues/217418 for more details. **/ -.prepend-top-15 { margin-top: 15px; } .prepend-top-20 { margin-top: 20px; } -.prepend-left-15 { margin-left: 15px; } -.prepend-left-20 { margin-left: 20px; } -.append-right-20 { margin-right: 20px; } .append-bottom-20 { margin-bottom: 20px; } .ml-10 { margin-left: 4.5rem; } .inline { display: inline-block; } diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index e16ab5ee72f..cf9363b77be 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -2,10 +2,6 @@ .diff-file { margin-bottom: $gl-padding; - &.conflict { - border-top: 1px solid $border-color; - } - &.has-body { .file-title { box-shadow: 0 -2px 0 0 var(--white); @@ -60,7 +56,7 @@ left: -11px; width: 10px; height: calc(100% + 1px); - background: $white; + background: $body-bg; pointer-events: none; } @@ -601,10 +597,6 @@ table.code { .diff-grid-right { display: grid; grid-template-columns: 50px 8px 1fr; - - .diff-td:nth-child(2) { - display: none; - } } .diff-grid-comments { @@ -635,20 +627,6 @@ table.code { .diff-grid-left, .diff-grid-right { grid-template-columns: 50px 50px 8px 1fr; - - .diff-td:nth-child(2) { - display: block; - } - } - - .diff-grid-left .old:nth-child(1) [data-linenumber], - .diff-grid-right .new:nth-child(2) [data-linenumber] { - display: inline; - } - - .diff-grid-left .old:nth-child(2) [data-linenumber], - .diff-grid-right .new:nth-child(1) [data-linenumber] { - display: none; } } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 2094c824286..e2335c184b0 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -16,12 +16,6 @@ } } -@mixin chevron-active { - .fa-chevron-down { - color: $gray-darkest; - } -} - @mixin set-visible { transform: translateY(0); display: block; @@ -56,7 +50,6 @@ .dropdown-toggle, .dropdown-menu-toggle { - @include chevron-active; border-color: $gray-darkest; } @@ -114,20 +107,11 @@ color: $gray-darkest; } - .fa-chevron-down { - font-size: $dropdown-chevron-size; - position: relative; - top: -2px; - margin-left: 5px; - } - &:hover { - @include chevron-active; border-color: $gray-darkest; } &:focus:active { - @include chevron-active; border-color: $dropdown-toggle-active-border-color; outline: 0; } @@ -143,18 +127,6 @@ .fa { position: absolute; - - &.fa-spinner { - font-size: 16px; - margin-top: -3px; - } - } - - .fa-chevron-down, - .fa-spinner { - position: absolute; - top: 11px; - right: 8px; } .spinner { @@ -369,7 +341,8 @@ } .droplab-dropdown { - .dropdown-toggle > i { + .dropdown-toggle > i, + .dropdown-toggle > svg { pointer-events: none; } @@ -532,29 +505,27 @@ &.is-active { color: $gl-text-color; - &::before { - position: absolute; - left: 16px; - top: 16px; - transform: translateY(-50%); - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - } - &.dropdown-menu-user-link::before { top: 50%; } } &.is-indeterminate::before { - content: '\f068'; + position: absolute; + left: 16px; + top: 16px; + transform: translateY(-50%); + font-style: normal; + font-size: inherit; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + content: '—'; } - &.is-active::before { - content: '\f00c'; + &.is-active { + /* stylelint-disable-next-line function-url-quotes */ + background: url(asset_path('checkmark.png')) no-repeat 14px 8px; } } } diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 7be676ed83c..6e47fef02d5 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -133,11 +133,6 @@ label { } .input-group { - .select2-container { - display: table-cell; - max-width: 180px; - } - .input-group-prepend, .input-group-append { background-color: $input-group-addon-bg; @@ -213,15 +208,6 @@ label { position: relative; } -.select-wrapper > .fa-chevron-down { - position: absolute; - font-size: 10px; - right: 10px; - top: 12px; - color: $gray-darkest; - pointer-events: none; -} - .input-icon-wrapper > .input-icon-right { position: absolute; right: 0.8em; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 52319d9658b..a6a01c7b090 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -384,6 +384,10 @@ text-overflow: ellipsis; flex: 0 1 auto; } + + &:last-of-type > .breadcrumbs-list-angle { + display: none; + } } } @@ -556,12 +560,17 @@ border: 1px solid $gray-normal; } -.header-user-notification-dot { +.notification-dot { background-color: $orange-300; height: 12px; width: 12px; - right: 8px; - top: -8px; + margin-top: -15px; + pointer-events: none; + visibility: hidden; +} + +.with-notifications .notification-dot { + visibility: visible; } .with-performance-bar .navbar-gitlab { diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 2464ea3607b..b0334da6943 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -234,6 +234,8 @@ ul.content-list { } } +ul.content-list.issuable-list > li, +ul.content-list.todos-list > li, .card > .content-list > li { padding: $gl-padding-top $gl-padding; } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 20d44b71bf6..7ba9236b833 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -146,7 +146,11 @@ } @mixin green-status-color { - @include status-color($green-100, $green-500, $green-700); + @include status-color( + var(--green-100, $green-100), + var(--green-500, $green-500), + var(--green-700, $green-700) + ); } @mixin fade($gradient-direction, $gradient-color) { @@ -169,7 +173,6 @@ transition-duration: 0.3s; } - .fa, svg { position: relative; top: 5px; @@ -255,9 +258,9 @@ @mixin build-trace-bar($height) { height: $height; min-height: $height; - background: $gray-light; - border: 1px solid $border-color; - color: $gl-text-color; + background: var(--gray-50, $gray-50); + border: 1px solid var(--border-color, $border-color); + color: var(--gl-text-color, $gl-text-color); padding: $grid-size; } @@ -361,11 +364,6 @@ color: $gray-400; fill: $gray-400; - .fa { - position: relative; - font-size: 16px; - } - svg { @include btn-svg; margin: 0; diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 372e3bed6e0..2dbeacb0f8c 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -91,6 +91,7 @@ body.modal-open { overflow: hidden; + padding-right: 0 !important; } .modal-no-backdrop { diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 3e218de6af9..2ad9a9d2dff 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -276,7 +276,7 @@ @include fade(left, $gray-light); right: -5px; - .fa { + svg { right: -7px; } } @@ -286,7 +286,7 @@ left: -5px; text-align: center; - .fa { + svg { left: -7px; } } @@ -337,7 +337,7 @@ @include fade(left, $white); right: -5px; - .fa { + svg { right: -7px; } } @@ -346,7 +346,7 @@ @include fade(right, $white); left: -5px; - .fa { + svg { left: -7px; } } diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 86a5aa1a16e..d8ce6826fc1 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -1,275 +1,3 @@ -/** Select2 selectbox style override **/ -.select2-container { - width: 100% !important; - - &.input-md, - &.input-lg { - display: block; - } -} - -.select2-container, -.select2-container.select2-drop-above { - .select2-choice { - background: $white; - color: $gl-text-color; - border-color: $input-border; - height: 34px; - padding: $gl-vert-padding $gl-input-padding; - font-size: $gl-font-size; - line-height: 1.42857143; - border-radius: $border-radius-base; - - .select2-arrow { - background-image: none; - background-color: transparent; - border: 0; - padding-top: 12px; - padding-right: 20px; - font-size: 10px; - - b { - display: none; - } - - &::after { - content: '\f078'; - position: absolute; - z-index: 1; - text-align: center; - pointer-events: none; - box-sizing: border-box; - color: $gray-darkest; - display: inline-block; - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - } - } - - .select2-chosen { - margin-right: 15px; - } - - &:hover { - border-color: $gray-darkest; - color: $gl-text-color; - } - } - - // Essentially we’re doing @include form-control-focus here (from - // bootstrap/scss/mixins/_forms.scss), except that the bootstrap mixin adds a - // `&:focus` selector and we’re never actually focusing the .select2-choice - // link nor the .select2-container, the Select2 library focuses an off-screen - // .select2-focusser element instead. - &.select2-container-active:not(.select2-dropdown-open) { - .select2-choice { - color: $input-focus-color; - background-color: $input-focus-bg; - border-color: $input-focus-border-color; - outline: 0; - } - - // Reusable focus “glow” box-shadow - @mixin form-control-focus-glow { - @if $enable-shadows { - box-shadow: $input-box-shadow, $input-focus-box-shadow; - } @else { - box-shadow: $input-focus-box-shadow; - } - } - - // Apply the focus “glow” shadow to the .select2-container if it also has - // the .block-truncated class as that applies an overflow: hidden, thereby - // hiding the glow of the nested .select2-choice element. - &.block-truncated { - @include form-control-focus-glow; - } - - // Apply the glow directly to the .select2-choice link if we’re not - // block-truncating the container. - &:not(.block-truncated) .select2-choice { - @include form-control-focus-glow; - } - } - - &.is-invalid { - ~ .invalid-feedback { - display: block; - } - - .select2-choices, - .select2-choice { - border-color: $red-500; - } - } -} - -.select2-drop, -.select2-drop.select2-drop-above { - background: $white; - box-shadow: 0 2px 4px $dropdown-shadow-color; - border-radius: $border-radius-base; - border: 1px solid $border-color; - min-width: 175px; - color: $gl-text-color; - z-index: 999; - - .modal-open & { - z-index: $zindex-modal + 200; - } -} - -.select2-drop-mask { - z-index: 998; - - .modal-open & { - z-index: $zindex-modal + 100; - } -} - -.select2-drop.select2-drop-above.select2-drop-active { - border-top: 1px solid $border-color; - margin-top: -6px; -} - -.select2-container-active { - .select2-choice, - .select2-choices { - box-shadow: none; - } -} - -.select2-dropdown-open, -.select2-dropdown-open.select2-drop-above { - .select2-choice { - border-color: $gray-darkest; - outline: 0; - } -} - -.select2-container-multi { - .select2-choices { - border-radius: $border-radius-default; - border-color: $input-border; - background: none; - - .select2-search-field input { - padding: 5px $gl-input-padding; - height: auto; - font-family: inherit; - font-size: inherit; - } - - .select2-search-choice { - margin: 5px 0 0 8px; - box-shadow: none; - border-color: $input-border; - color: $gl-text-color; - line-height: 15px; - background-color: $gray-light; - background-image: none; - padding: 3px 18px 3px 5px; - - .select2-search-choice-close { - top: 5px; - left: initial; - right: 3px; - } - - &.select2-search-choice-focus { - border-color: $gl-text-color; - } - } - } -} - -.select2-drop-active { - margin-top: $dropdown-vertical-offset; - font-size: 14px; - - .select2-results { - max-height: 350px; - } -} - -.select2-search { - padding: $grid-size; - - .select2-drop-auto-width & { - padding: $grid-size; - } - - input { - padding: $grid-size; - background: transparent image-url('select2.png'); - color: $gl-text-color; - background-clip: content-box; - background-origin: content-box; - background-repeat: no-repeat; - background-position: right 0 bottom 0 !important; - border: 1px solid $input-border; - border-radius: $border-radius-default; - line-height: 16px; - transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; - - &:focus { - border-color: $blue-300; - } - - &.select2-active { - background-color: $white; - background-image: image-url('select2-spinner.gif') !important; - background-origin: content-box; - background-repeat: no-repeat; - background-position: right 6px center !important; - background-size: 16px 16px !important; - } - } - - + .select2-results { - padding-top: 0; - } -} - -.select2-results { - margin: 0; - padding: #{$gl-padding / 2} 0; - - .select2-no-results, - .select2-searching, - .select2-ajax-error, - .select2-selection-limit { - background: transparent; - padding: #{$gl-padding / 2} $gl-padding; - } - - .select2-result-label, - .select2-more-results { - padding: #{$gl-padding / 2} $gl-padding; - } - - .select2-highlighted { - background: transparent; - color: $gl-text-color; - - .select2-result-label { - background: $gray-darker; - } - } - - .select2-result { - padding: 0 1px; - } - - li.select2-result-with-children > .select2-result-label { - font-weight: $gl-font-weight-bold; - color: $gl-text-color; - } -} - .ajax-users-select { width: 400px; @@ -282,14 +10,6 @@ } } -.select2-highlighted { - .group-result { - .group-path { - color: $gray-700; - } - } -} - .group-result { .group-image { float: left; @@ -345,11 +65,3 @@ .ajax-users-dropdown { min-width: 250px !important; } - -.select2-result-selectable, -.select2-result-unselectable { - .select2-match { - font-weight: $gl-font-weight-bold; - text-decoration: none; - } -} diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index 39d9e9a77f9..89713fdbbea 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -184,46 +184,3 @@ table { border-top: 0; } } - -.vulnerability-list { - @media (min-width: $breakpoint-sm) { - .checkbox { - padding-left: $gl-spacing-scale-4; - padding-right: 0; - width: 1px; - - + td, - + th { - padding-left: $gl-spacing-scale-4; - } - } - - .detected { - width: 9%; - } - - .status { - width: 8%; - } - - .severity { - width: 10%; - } - - .description { - max-width: 0; - } - - .identifier { - width: 16%; - } - - .scanner { - width: 10%; - } - - .activity { - width: 5%; - } - } -} diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss index 054280f3321..fd888fdec65 100644 --- a/app/assets/stylesheets/framework/toggle.scss +++ b/app/assets/stylesheets/framework/toggle.scss @@ -4,22 +4,22 @@ * @usage * ### Active and Inactive text should be provided as data attributes: * <button type="button" class="project-feature-toggle" data-enabled-text="Enabled" data-disabled-text="Disabled"> -* <i class="fa fa-spinner fa-spin loading-icon hidden"></i> +* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span> * </button> * ### Checked should have `is-checked` class * <button type="button" class="project-feature-toggle is-checked" data-enabled-text="Enabled" data-disabled-text="Disabled"> -* <i class="fa fa-spinner fa-spin loading-icon hidden"></i> +* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span> * </button> * ### Disabled should have `is-disabled` class * <button type="button" class="project-feature-toggle is-disabled" data-enabled-text="Enabled" data-disabled-text="Disabled" disabled="true"> -* <i class="fa fa-spinner fa-spin loading-icon hidden"></i> +* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span> * </button> * ### Loading should have `is-loading` and an icon with `loading-icon` class * <button type="button" class="project-feature-toggle is-loading" data-enabled-text="Enabled" data-disabled-text="Disabled"> -* <i class="fa fa-spinner fa-spin loading-icon"></i> +* <span class="gl-spinner loading-icon" aria-label="Loading"></span> * </button> */ .project-feature-toggle { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 3d09edfe181..1a568bb41a5 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -1,3 +1,6 @@ +// Custom Fontawesome icons +@import 'fontawesome_custom'; + /** * Apply Markup (Markdown/AsciiDoc) typography * @@ -432,11 +435,11 @@ &::before { margin-right: 4px; - font: normal normal normal 14px/1 FontAwesome; + font-style: normal; font-size: inherit; text-rendering: auto; -webkit-font-smoothing: antialiased; - content: '\f0c6'; + content: '📎'; } &.no-attachment-icon { @@ -573,10 +576,6 @@ body { font-size: 1.25em; font-weight: $gl-font-weight-bold; - &:last-child { - margin-bottom: 0; - } - &.with-button { line-height: 34px; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index f0b1e859139..808813599c5 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -468,7 +468,6 @@ $gl-line-height-20: 20px; $gl-line-height-24: 24px; $gl-line-height-14: 14px; -$issue-box-upcoming-bg: #8f8f8f; $pages-group-name-color: #4c4e54; /* diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss index 6c51c4b0ec3..b148cc8f0e7 100644 --- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss +++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss @@ -22,32 +22,14 @@ border-radius: $gl-border-radius-base; .select2-arrow { - background-image: none; - background-color: transparent; - border: 0; padding-top: 12px; padding-right: 20px; - font-size: 10px; + /* stylelint-disable-next-line function-url-quotes */ + background: url(asset_path('chevron-down.png')) no-repeat 2px 8px; b { display: none; } - - &::after { - content: '\f078'; - position: absolute; - z-index: 1; - text-align: center; - pointer-events: none; - box-sizing: border-box; - color: $gray-darkest; - display: inline-block; - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - } } .select2-chosen { diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss index 499394ad960..cc876c9a635 100644 --- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss +++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss @@ -4,7 +4,7 @@ position: absolute; top: 48%; left: -$length; - border-top: 2px solid $border-color; + border-top: 2px solid var(--border-color, $border-color); width: $length; height: 1px; } @@ -14,14 +14,14 @@ display: inline-block; padding: 8px 10px 9px; width: 100%; - border: 1px solid $border-color; + border: 1px solid var(--border-color, $border-color); border-radius: $border-radius; - background-color: $white; + background-color: var(--white, $white); &:hover { - background-color: $gray-darker; + background-color: var(--gray-50, $gray-50); border: 1px solid $dropdown-toggle-active-border-color; - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } } @@ -66,7 +66,7 @@ @mixin mini-pipeline-item() { border-radius: 100px; - background-color: $white; + background-color: var(--white, $white); border-width: 1px; border-style: solid; width: $ci-action-icon-size; @@ -85,22 +85,22 @@ // Dropdown button animation in mini pipeline graph &.ci-status-icon-success { - @include mini-pipeline-graph-color($white, $green-100, $green-200, $green-500, $green-600, $green-700); + @include mini-pipeline-graph-color(var(--white, $white), $green-100, $green-200, $green-500, $green-600, $green-700); } &.ci-status-icon-failed { - @include mini-pipeline-graph-color($white, $red-100, $red-200, $red-500, $red-600, $red-700); + @include mini-pipeline-graph-color(var(--white, $white), $red-100, $red-200, $red-500, $red-600, $red-700); } &.ci-status-icon-pending, &.ci-status-icon-waiting-for-resource, &.ci-status-icon-success-with-warnings { - @include mini-pipeline-graph-color($white, $orange-50, $orange-100, $orange-500, $orange-600, $orange-700); + @include mini-pipeline-graph-color(var(--white, $white), $orange-50, $orange-100, $orange-500, $orange-600, $orange-700); } &.ci-status-icon-preparing, &.ci-status-icon-running { - @include mini-pipeline-graph-color($white, $blue-100, $blue-200, $blue-500, $blue-600, $blue-700); + @include mini-pipeline-graph-color(var(--white, $white), $blue-100, $blue-200, $blue-500, $blue-600, $blue-700); } &.ci-status-icon-canceled, @@ -108,12 +108,12 @@ &.ci-status-icon-disabled, &.ci-status-icon-not-found, &.ci-status-icon-manual { - @include mini-pipeline-graph-color($white, $gray-500, $gray-700, $gray-900, $gray-950, $black); + @include mini-pipeline-graph-color(var(--white, $white), $gray-500, $gray-700, $gray-900, $gray-950, $black); } &.ci-status-icon-created, &.ci-status-icon-skipped { - @include mini-pipeline-graph-color($white, $gray-100, $gray-200, $gray-300, $gray-400, $gray-500); + @include mini-pipeline-graph-color(var(--white, $white), $gray-100, $gray-200, $gray-300, $gray-400, $gray-500); } } @@ -226,7 +226,7 @@ &:focus { outline: none; text-decoration: none; - background-color: $gray-darker; + background-color: var(--gray-100, $gray-50); } } } diff --git a/app/assets/stylesheets/page_bundles/alert_management_details.scss b/app/assets/stylesheets/page_bundles/alert_management_details.scss index beb80a14c5a..2eaf4517710 100644 --- a/app/assets/stylesheets/page_bundles/alert_management_details.scss +++ b/app/assets/stylesheets/page_bundles/alert_management_details.scss @@ -17,22 +17,19 @@ } } - .assignee-dropdown-item { - .dropdown-item { - @include gl-display-flex; - @include gl-align-items-center; - + .dropdown-item { + &:first-child { &::before { - top: 50% !important; + @include gl-pt-0; } + } - &.is-active { - &:last-child { - @include gl-border-b-gray-100; - @include gl-border-b-1; - @include gl-border-b-solid; - } - } + &::before { + @include gl-pt-8; + } + + .gl-new-dropdown-item-text-wrapper { + @include gl-py-0; } } diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index ffc15af6329..3d1ae3519a9 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -92,7 +92,6 @@ .board-title-caret { border-radius: $border-radius-default; line-height: $gl-spacing-scale-5; - height: $gl-spacing-scale-5; &.btn svg { top: 0; @@ -173,13 +172,6 @@ } } -.board-promotion-state { - background-color: var(--white, $white); - flex: 1; - overflow-y: auto; - overflow-x: hidden; -} - .board-list-component { min-height: 0; // firefox fix } diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index 2f0f4a46658..3962c546b51 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -61,7 +61,7 @@ } .environment-information { - border: 1px solid $border-color; + border: 1px solid var(--border-color, $border-color); padding: 8px $gl-padding 12px; border-radius: $border-radius-default; @@ -219,9 +219,9 @@ } .builds-container { - background-color: $white; - border-top: 1px solid $border-color; - border-bottom: 1px solid $border-color; + background-color: var(--white, $white); + border-top: 1px solid var(--border-color, $border-color); + border-bottom: 1px solid var(--border-color, $border-color); max-height: 300px; width: 289px; overflow: auto; @@ -237,7 +237,7 @@ width: 270px; &:hover { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } } @@ -256,13 +256,13 @@ } &:hover { - background-color: $gray-darker; + background-color: var(--gray-50, $gray-50); } } } .link-commit { - color: $blue-600; + color: var(--blue-600, $blue-600); } } diff --git a/app/assets/stylesheets/page_bundles/ci_status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss index 8522a0a8fe4..232d363b7f1 100644 --- a/app/assets/stylesheets/page_bundles/ci_status.scss +++ b/app/assets/stylesheets/page_bundles/ci_status.scss @@ -2,7 +2,7 @@ .ci-status { padding: 2px 7px 4px; - border: 1px solid $gray-darker; + border: 1px solid var(--border-color, $border-color); white-space: nowrap; border-radius: 4px; @@ -18,7 +18,11 @@ } &.ci-failed { - @include status-color($red-100, $red-500, $red-600); + @include status-color( + var(--red-100, $red-100), + var(--red-500, $red-500), + var(--red-600, $red-600) + ); } &.ci-success { @@ -26,11 +30,12 @@ } &.ci-canceled, + &.ci-skipped, &.ci-disabled, &.ci-scheduled, &.ci-manual { - color: $gl-text-color; - border-color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); + border-color: currentColor; &:not(span):hover { background-color: rgba($gl-text-color, 0.07); @@ -38,25 +43,37 @@ } &.ci-preparing { - @include status-color($gray-100, $gray-300, $gray-400); + @include status-color( + var(--gray-100, $gray-100), + var(--gray-300, $gray-300), + var(--gray-400, $gray-400) + ); } &.ci-pending, &.ci-waiting-for-resource, &.ci-failed-with-warnings, &.ci-success-with-warnings { - @include status-color($orange-50, $orange-500, $orange-700); + @include status-color( + var(--orange-50, $orange-50), + var(--orange-500, $orange-500), + var(--orange-700, $orange-700) + ); } &.ci-info, &.ci-running { - @include status-color($blue-100, $blue-500, $blue-600); + @include status-color( + var(--blue-100, $blue-100), + var(--blue-500, $blue-500), + var(--blue-600, $blue-600) + ); } &.ci-created, &.ci-skipped { - color: $gl-text-color-secondary; - border-color: $gl-text-color-secondary; + color: var(--gray-500, $gray-500); + border-color: currentColor; &:not(span):hover { background-color: rgba($gl-text-color-secondary, 0.07); diff --git a/app/assets/stylesheets/page_bundles/cycle_analytics.scss b/app/assets/stylesheets/page_bundles/cycle_analytics.scss index 3a5e2e4159d..4a48333cd27 100644 --- a/app/assets/stylesheets/page_bundles/cycle_analytics.scss +++ b/app/assets/stylesheets/page_bundles/cycle_analytics.scss @@ -314,11 +314,6 @@ vertical-align: top; font-weight: $gl-font-weight-normal; } - - .fa { - color: var(--gray-500, $gray-500); - font-size: $code-font-size; - } } } diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss new file mode 100644 index 00000000000..5f43d5df7e3 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/import.scss @@ -0,0 +1,81 @@ +@import 'mixins_and_variables_and_functions'; + +.import-jobs-to-col { + width: 39%; +} + +.import-jobs-status-col { + width: 15%; +} + +.import-jobs-cta-col { + width: 1%; +} + +.import-project-name-input { + border-radius: 0 $border-radius-default $border-radius-default 0; + position: relative; + left: -1px; + max-width: 300px; +} + +.import-slash-divider { + background-color: $gray-lightest; + border: 1px solid $border-color; +} + +.import-row { + height: 55px; +} + +.import-table { + .import-jobs-from-col, + .import-jobs-to-col, + .import-jobs-status-col, + .import-jobs-cta-col { + border-bottom-width: 1px; + padding-left: $gl-padding; + } +} + +.import-projects-loading-icon { + margin-top: $gl-padding-32; +} + +.import-entities-target-select { + &.disabled { + .import-entities-target-select-separator, + .select2-container.select2-container-disabled .select2-choice { + color: var(--gray-400, $gray-400); + border-color: var(--gray-100, $gray-100); + background-color: var(--gray-10, $gray-10); + } + + .select2-container.select2-container-disabled .select2-choice .select2-arrow { + background-color: var(--gray-10, $gray-10); + } + } + + .import-entities-target-select-separator { + border-color: var(--gray-200, $gray-200); + background-color: var(--gray-10, $gray-10); + } + + .select2-container { + > .select2-choice { + .select2-arrow { + background-color: var(--white, $white); + } + + border-color: var(--gray-200, $gray-200); + color: var(--gray-900, $gray-900) !important; + background-color: var(--white, $white) !important; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + + .gl-form-input { + box-shadow: inset 0 0 0 1px var(--gray-200, $gray-200); + } +} diff --git a/app/assets/stylesheets/page_bundles/merge_conflicts.scss b/app/assets/stylesheets/page_bundles/merge_conflicts.scss index b0655408edf..a26affb10a9 100644 --- a/app/assets/stylesheets/page_bundles/merge_conflicts.scss +++ b/app/assets/stylesheets/page_bundles/merge_conflicts.scss @@ -255,10 +255,6 @@ $colors: ( } } - .btn-success .fa-spinner { - color: var(--white, $white); - } - .editor-wrap { &.is-loading { .editor { diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss new file mode 100644 index 00000000000..3c95ecc9bf0 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -0,0 +1,189 @@ +@import 'mixins_and_variables_and_functions'; + +@mixin inset-border-1-red-500($important: false) { + box-shadow: inset 0 0 0 $gl-border-size-1 $red-500 if-important($important); +} + +.timezone-dropdown { + .dropdown-menu { + @include gl-w-full; + } + + .gl-new-dropdown-item-text-primary { + @include gl-overflow-hidden; + @include gl-text-overflow-ellipsis; + } +} + +.modal-footer { + @include gl-bg-gray-10; +} + +.invalid-dropdown { + .gl-dropdown-toggle { + @include inset-border-1-red-500; + + &:hover { + @include inset-border-1-red-500(true); + } + } +} + +//// Copied from roadmaps.scss - adapted for on-call schedules +$header-item-height: 72px; +$item-height: 40px; +$details-cell-width: 180px; +$timeline-cell-height: 32px; +$timeline-cell-width: 180px; +$border-style: 1px solid var(--gray-100, $gray-100); +$gradient-dark-gray: rgba(0, 0, 0, 0.15); +$gradient-gray: rgba(255, 255, 255, 0.001); +$scroll-top-gradient: linear-gradient(to bottom, $gradient-dark-gray 0%, $gradient-gray 100%); +$scroll-bottom-gradient: linear-gradient(to bottom, $gradient-gray 0%, $gradient-dark-gray 100%); +$column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradient-gray 100%); + +.schedule-shell { + @include gl-relative; + @include gl-h-full; + @include gl-w-full; + @include gl-overflow-x-auto; + @include gl-border-gray-100; + @include gl-border-1; + @include gl-border-solid; + @include gl-rounded-base; +} + +.timeline-section { + @include gl-sticky; + @include gl-top-0; + z-index: 20; + + .timeline-header-blank, + .timeline-header-item { + @include float-left; + height: $header-item-height; + border-bottom: $border-style; + background-color: var(--white, $white); + } + + .timeline-header-blank { + @include gl-sticky; + @include gl-top-0; + @include gl-left-0; + width: $details-cell-width; + z-index: 2; + } + + .timeline-header-item { + // container size minus left panel width divided by 2 week timeframes + width: calc((100% - #{$details-cell-width}) / 2); + + &:last-of-type .item-label { + @include gl-border-r-0; + } + + .item-label, + .item-sublabel .sublabel-value { + color: var(--gray-400, $gray-400); + @include gl-font-weight-normal; + + &.label-dark { + @include gl-text-gray-900; + } + + &.label-bold { + @include gl-font-weight-bold; + } + } + + .item-label { + @include gl-py-4; + @include gl-pl-4; + border-right: $border-style; + border-bottom: $border-style; + } + + .item-sublabel { + @include gl-relative; + @include gl-display-flex; + + .sublabel-value { + @include gl-flex-grow-1; + @include gl-flex-basis-0; + + text-align: center; + @include gl-font-base; + padding: 2px 0; + } + } + + .current-day-indicator-header { + @include gl-absolute; + @include gl-bottom-0; + height: $grid-size; + width: $grid-size; + background-color: var(--red-500, $red-500); + @include gl-rounded-full; + transform: translate(-50%, 50%); + } + } +} + +.timeline-section .timeline-header-blank, +.list-section .details-cell { + &::after { + @include gl-h-full; + @include gl-content-empty; + @include gl-absolute; + @include gl-top-0; + right: -$grid-size; + width: $grid-size; + @include gl-pointer-events-none; + background: $column-right-gradient; + } +} + +.details-cell, +.timeline-cell { + @include float-left; + height: $item-height; +} + +.details-cell { + @include gl-sticky; + @include gl-left-0; + width: $details-cell-width; + @include gl-font-base; + background-color: var(--white, $white); + z-index: 10; +} + +.timeline-cell { + @include gl-relative; + // width: $timeline-cell-width; + // container size minus left panel width divided by 2 week timeframes + width: calc((100% - #{$details-cell-width}) / 2); + @include gl-bg-transparent; + border-right: $border-style; + + &:last-child { + @include gl-border-r-0; + } + + .current-day-indicator { + @include gl-absolute; + top: -1px; + width: $gl-spacing-scale-1; + height: calc(100% + 1px); + background-color: var(--red-500, $red-500); + @include gl-pointer-events-none; + transform: translateX(-50%); + } +} + +.gl-token { + .gl-avatar-labeled-label { + @include gl-text-white; + @include gl-font-weight-normal; + } +} diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index 1de66aa73da..d9ab52774bd 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -33,7 +33,7 @@ } .stage { - color: $gl-text-color-secondary; + color: var(--gray-500, $gray-500); font-weight: $gl-font-weight-normal; vertical-align: middle; } @@ -62,7 +62,7 @@ a { font-weight: $gl-font-weight-bold; - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); text-decoration: none; &:focus, @@ -124,11 +124,46 @@ display: flex; width: 100%; min-height: $dropdown-max-height-lg; - background-color: $gray-light; + background-color: var(--gray-50, $gray-50); padding: $gl-padding 0; overflow: auto; } +// These are single-value classes to use with utility-class style CSS +// but to still access this variable. Do not add other styles. +.gl-pipeline-min-h { + min-height: $dropdown-max-height-lg; +} + +.gl-pipeline-job-width { + width: 186px; +} + +.gl-linked-pipeline-padding { + padding-right: 120px; +} + +.gl-build-content { + @include build-content(); +} + +.gl-ci-action-icon-container { + position: absolute; + right: 5px; + top: 50% !important; + transform: translateY(-50%); + + // Action Icons in big pipeline-graph nodes + &.ci-action-icon-wrapper { + height: 30px; + width: 30px; + border-radius: 100%; + display: block; + padding: 0; + line-height: 0; + } +} + // Pipeline graph, used at // app/assets/javascripts/pipelines/components/graph/graph_component.vue .pipeline-graph { @@ -142,7 +177,7 @@ a { text-decoration: none; - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } svg { @@ -214,18 +249,18 @@ height: 25px; position: absolute; top: -31px; - border-top: 2px solid $border-color; + border-top: 2px solid var(--border-color, $border-color); } &::after { left: -44px; - border-right: 2px solid $border-color; + border-right: 2px solid var(--border-color, $border-color); border-radius: 0 20px; } &::before { right: -44px; - border-left: 2px solid $border-color; + border-left: 2px solid var(--border-color, $border-color); border-radius: 20px 0 0; } } @@ -281,7 +316,7 @@ a.build-content:hover, button.build-content:hover { - background-color: $gray-darker; + background-color: var(--gray-100, $gray-100); border: 1px solid $dropdown-toggle-active-border-color; } @@ -292,7 +327,7 @@ position: absolute; top: 48%; right: -48px; - border-top: 2px solid $border-color; + border-top: 2px solid var(--border-color, $border-color); width: 48px; height: 1px; } @@ -305,7 +340,7 @@ content: ''; top: -49px; position: absolute; - border-bottom: 2px solid $border-color; + border-bottom: 2px solid var(--border-color, $border-color); width: 25px; height: 69px; } @@ -313,14 +348,14 @@ // Right connecting curves &::after { right: -25px; - border-right: 2px solid $border-color; + border-right: 2px solid var(--border-color, $border-color); border-radius: 0 0 20px; } // Left connecting curves &::before { left: -25px; - border-left: 2px solid $border-color; + border-left: 2px solid var(--border-color, $border-color); border-radius: 0 0 0 20px; } } @@ -355,7 +390,7 @@ line-height: 0; svg { - fill: $gl-text-color-secondary; + fill: var(--gray-500, $gray-500); } .spinner { @@ -453,13 +488,13 @@ left: -6px; margin-top: 3px; border-width: 7px 5px 7px 0; - border-right-color: $border-color; + border-right-color: var(--border-color, $border-color); } &::after { left: -5px; border-width: 10px 7px 10px 0; - border-right-color: $white; + border-right-color: var(--white, $white); } } @@ -484,5 +519,5 @@ } .progress-bar.bg-primary { - background-color: $blue-500 !important; + background-color: var(--blue-500, $blue-500) !important; } diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss index e0e56893afc..dbde7933a8b 100644 --- a/app/assets/stylesheets/page_bundles/pipelines.scss +++ b/app/assets/stylesheets/page_bundles/pipelines.scss @@ -22,7 +22,7 @@ min-width: 170px; //Guarantees buttons don't break in several lines. .btn-default { - color: $gl-text-color-secondary; + color: var(--gray-500, $gray-500); } .btn.btn-retry:hover, @@ -32,7 +32,7 @@ } svg path { - fill: $gl-text-color-secondary; + fill: var(--gray-500, $gray-500); } .dropdown-menu { @@ -42,12 +42,7 @@ .dropdown-toggle, .dropdown-menu { - color: $gl-text-color-secondary; - - .fa { - color: $gl-text-color-secondary; - font-size: 14px; - } + color: var(--gray-500, $gray-500); } .btn-group.open .btn-default { diff --git a/app/assets/stylesheets/page_bundles/profile_two_factor_auth.scss b/app/assets/stylesheets/page_bundles/profile_two_factor_auth.scss new file mode 100644 index 00000000000..3b4b1fdcded --- /dev/null +++ b/app/assets/stylesheets/page_bundles/profile_two_factor_auth.scss @@ -0,0 +1,11 @@ +@media print { + .codes-to-print { + background-color: var(--white); + height: 100%; + width: 100%; + position: fixed; + top: 0; + left: 0; + margin: 0; + } +} diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 4e27f438e36..f7b8a4c5b84 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -58,22 +58,6 @@ } } -.cluster-application-banner { - height: 45px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.cluster-application-banner-close { - align-self: flex-start; - font-weight: 500; - font-size: 20px; - color: $orange-500; - opacity: 1; - margin: $gl-padding-8 14px 0 0; -} - .cluster-application-description { flex: 1; } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 17474b95e50..9b17da80023 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -174,12 +174,6 @@ } .commit-actions { - @include media-breakpoint-up(sm) { - .fa-spinner { - font-size: 12px; - } - } - .ci-status-icon svg { vertical-align: text-bottom; } diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index f357d508d5d..f237d57aa88 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -41,12 +41,6 @@ @include media-breakpoint-down(xs) { width: 100%; margin-top: 10px; - - > .issue-btn-group { - > .btn { - width: 100%; - } - } } } diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 5c845c37e90..e0e10d63f8e 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -74,10 +74,6 @@ justify-content: flex-end; } - .select2 { - float: right; - } - .encoding-selector, .soft-wrap-toggle { display: inline-block; @@ -220,10 +216,6 @@ } } -.editor-title-row { - margin-bottom: 20px; -} - .popover.suggest-gitlab-ci-yml { z-index: $header-zindex - 1; } diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index e73b6b18afd..aeda91c1714 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -80,12 +80,6 @@ .btn-success { width: 100%; } - - .dropdown .dropdown-toggle .fa-chevron-down { - position: absolute; - top: 11px; - right: 8px; - } } } @@ -299,12 +293,6 @@ table.pipeline-project-metrics tr td { padding: $gl-padding; } -.mattermost-icon svg { - width: 16px; - height: 16px; - vertical-align: text-bottom; -} - .mattermost-team-name { color: $gl-text-color-secondary; } diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss deleted file mode 100644 index 74f80a11471..00000000000 --- a/app/assets/stylesheets/pages/import.scss +++ /dev/null @@ -1,61 +0,0 @@ -.import-jobs-to-col { - width: 39%; -} - -.import-jobs-status-col { - width: 15%; -} - -.import-jobs-cta-col { - width: 1%; -} - -.import-project-name-input { - border-radius: 0 $border-radius-default $border-radius-default 0; - position: relative; - left: -1px; - max-width: 300px; -} - -.import-namespace-select { - > .select2-choice { - border-radius: $border-radius-default 0 0 $border-radius-default; - position: relative; - left: 1px; - } -} - -.import-slash-divider { - background-color: $gray-lightest; - border: 1px solid $border-color; -} - -.import-row { - height: 55px; -} - -.import-table { - .import-jobs-from-col, - .import-jobs-to-col, - .import-jobs-status-col, - .import-jobs-cta-col { - border-bottom-width: 1px; - padding-left: $gl-padding; - } -} - -.import-projects-loading-icon { - margin-top: $gl-padding-32; -} - -.btn-import { - .loading-icon { - display: none; - } - - &.is-loading { - .loading-icon { - display: inline-block; - } - } -} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index cc4827f75d4..e5528c25e82 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -10,6 +10,7 @@ } .limit-container-width { + .flash-container, .detail-page-header, .page-content-header, .commit-box, @@ -112,7 +113,7 @@ position: absolute; bottom: 0; right: 0; - text-shadow: -1px -1px 2px $white, 1px -1px 2px $white, -1px 1px 2px $white, 1px 1px 2px $white; + filter: drop-shadow(0 0 0.5px $white) drop-shadow(0 0 1px $white) drop-shadow(0 0 2px $white); } } @@ -199,10 +200,6 @@ border: 0; } - .select2-container span { - margin-top: 0; - } - &.assignee { .author-link { display: block; @@ -395,6 +392,13 @@ text-align: center; } + .merge-icon { + height: 12px; + width: 12px; + bottom: -5px; + right: 4px; + } + .sidebar-collapsed-icon { display: flex; flex-direction: column; @@ -405,7 +409,7 @@ text-align: center; color: $gl-text-color-secondary; - svg { + > svg { fill: $gl-text-color-secondary; } @@ -413,7 +417,7 @@ &:hover .todo-undone { color: $gl-text-color; - svg { + > svg { fill: $gl-text-color; } } @@ -485,10 +489,6 @@ display: none; } - .merge-icon { - font-size: 10px; - } - .multiple-users { position: relative; height: 24px; @@ -697,10 +697,6 @@ .issuable-list { li { - .issue-box { - display: flex; - } - .issuable-info-container { flex: 1; display: flex; @@ -894,29 +890,6 @@ } } -.issuable-close-button, -.issuable-close-toggle { - @include transition(border-color, color); -} - -.issuable-close-dropdown { - .dropdown-menu { - min-width: 270px; - left: auto; - right: 0; - } - - .description { - .text { - margin: 0; - } - } - - .dropdown-toggle > .icon { - margin: 0 3px; - } -} - /* * Following overrides are done to prevent * legacy dropdown styles from influencing diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 08faebc8ec0..1caf62067a6 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -92,6 +92,11 @@ ul.related-merge-requests > li { } } +.issues-footer { + padding-top: $gl-padding; + padding-bottom: 37px; +} + .issues-nav-controls, .new-branch-col { font-size: 0; @@ -196,14 +201,6 @@ ul.related-merge-requests > li { } } } - - .create-merge-request-dropdown-toggle { - .fa-caret-down { - pointer-events: none; - color: inherit; - margin-left: 0; - } - } } .discussion-reply-holder { diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index a8b489f1273..0ccde57746a 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -30,20 +30,6 @@ margin-bottom: 0; } - .member-controls { - .fa { - line-height: inherit; - } - } - - .btn-remove { - width: 100%; - - @include media-breakpoint-up(sm) { - width: auto; - } - } - &.existing-title { @include media-breakpoint-up(sm) { float: left; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index a0ac55e4c6c..efca82def92 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -53,6 +53,7 @@ $mr-widget-min-height: 69px; position: relative; border: 1px solid $border-color; border-radius: $border-radius-default; + background: var(--white, $white); .gl-skeleton-loader { display: block; @@ -61,7 +62,7 @@ $mr-widget-min-height: 69px; .mr-widget-extension { border-top: 1px solid $border-color; - background-color: $gray-light; + background-color: $gray-50; &.clickable:hover { background-color: $gray-100; @@ -87,6 +88,7 @@ $mr-widget-min-height: 69px; border: 1px solid $border-color; border-radius: $border-radius-default; border-top: 0; + background: var(--white, $white); } .mr-widget-body, @@ -161,12 +163,6 @@ $mr-widget-min-height: 69px; .btn { font-size: $gl-font-size; - - &.dropdown-toggle { - .fa { - color: inherit; - } - } } .accept-merge-holder { @@ -287,10 +283,6 @@ $mr-widget-min-height: 69px; margin-top: 0; margin-bottom: 0; - &.has-conflicts .fa-exclamation-triangle { - color: $orange-500; - } - time { font-weight: $gl-font-weight-normal; } @@ -343,13 +335,6 @@ $mr-widget-min-height: 69px; } } - .dropdown-toggle { - .fa { - margin-left: 0; - color: inherit; - } - } - .has-custom-error { display: inline-block; } @@ -507,19 +492,6 @@ $mr-widget-min-height: 69px; display: none; } -#modal_merge_info .modal-dialog { - .dark { - margin-right: 40px; - } - - .btn-clipboard { - margin-right: 20px; - margin-top: 5px; - position: absolute; - right: 0; - } -} - .mr-links { padding-left: $gl-padding-8 + $status-icon-size + $gl-btn-padding; @@ -560,16 +532,13 @@ $mr-widget-min-height: 69px; border-radius: $border-radius-default; padding: $gl-padding; border: 1px solid $border-color; + background: var(--white, $white); min-height: $mr-widget-min-height; @include media-breakpoint-up(md) { align-items: center; } - .dropdown-toggle .fa { - color: $gl-text-color; - } - .git-merge-container { justify-content: space-between; flex: 1; @@ -720,7 +689,7 @@ $mr-widget-min-height: 69px; z-index: 199; white-space: nowrap; - .dropdown-menu-toggle { + .gl-dropdown-toggle { width: auto; max-width: 170px; @@ -778,7 +747,7 @@ $mr-widget-min-height: 69px; .epic-tabs-holder { top: $header-height; z-index: 250; - background-color: $white; + background-color: $body-bg; border-bottom: 1px solid $border-color; .with-system-header & { @@ -1039,3 +1008,11 @@ $mr-widget-min-height: 69px; .diff-file-row.is-active { background-color: $gray-50; } + +.mr-conflict-loader { + max-width: 334px; + + > svg { + vertical-align: middle; + } +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index e23ec25a2f3..4216091e8a9 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -190,8 +190,7 @@ $note-form-margin-left: 72px; border: 1px solid darken($gray-100, 25%); } - .note-headline-light, - .fa-spinner { + .note-headline-light { margin-left: 3px; } } @@ -249,16 +248,6 @@ $note-form-margin-left: 72px; .note-emoji-button { position: relative; line-height: 1; - - .fa-spinner { - display: none; - } - - &.is-loading { - .fa-spinner { - display: inline-block; - } - } } } @@ -361,7 +350,7 @@ $note-form-margin-left: 72px; left: $gl-padding-24; right: 0; bottom: 0; - background: linear-gradient(rgba($white, 0.1) -100px, $white 100%); + background: linear-gradient(rgba($white, 0.1) -100px, $body-bg 100%); } } } @@ -407,8 +396,6 @@ $note-form-margin-left: 72px; .discussion-body .diff-file { .file-title { cursor: default; - line-height: 42px; - padding: 0 $gl-padding; border-top: 1px solid $border-color; border-radius: 0; @@ -791,13 +778,6 @@ $note-form-margin-left: 72px; outline: none; color: $blue-600; } - - .fa { - margin-right: 3px; - font-size: 10px; - line-height: 18px; - vertical-align: top; - } } .note-role { diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss index e1cbf0e6654..33ab42b5511 100644 --- a/app/assets/stylesheets/pages/notifications.scss +++ b/app/assets/stylesheets/pages/notifications.scss @@ -1,6 +1,4 @@ .notification-list-item { - line-height: 34px; - .dropdown-menu { @extend .dropdown-menu-right; } @@ -37,8 +35,4 @@ .notification { position: relative; top: 1px; - - .fa { - font-size: 18px; - } } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index b37aa6cd285..89be1c024db 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -46,11 +46,6 @@ fill: $gl-text-color; } - .fa { - font-size: 12px; - color: $gl-text-color; - } - .commit-sha { color: $blue-600; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 09501d3713d..7fafd28be56 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -10,12 +10,6 @@ } .input-group { - .select2-container { - display: unset; - max-width: unset; - flex-grow: 1; - } - > div { &:last-child { padding-right: 0; @@ -52,7 +46,6 @@ flex-grow: 1; } - + .select2 a, + .btn-default { border-radius: 0 $border-radius-base $border-radius-base 0; } @@ -147,23 +140,10 @@ margin-left: 0; } - .fa { - color: $layout-link-gray; - } - svg { fill: $layout-link-gray; } - .fa-caret-down { - margin-left: 3px; - line-height: 0; - - &.dropdown-btn-icon { - margin-left: 0; - } - } - .notifications-icon { top: 1px; margin-right: 0; @@ -179,13 +159,6 @@ height: 24px; } - .dropdown-toggle, - .clone-dropdown-btn { - .fa { - color: unset; - } - } - .home-panel-action-button, .project-action-button { margin: $gl-padding $gl-padding-8 0 0; @@ -258,10 +231,6 @@ color: $gray-700; } -.transfer-project .select2-container { - min-width: 200px; -} - .deploy-key { // Ensure that the fingerprint does not overflow on small screens .fingerprint { @@ -512,7 +481,7 @@ top: 0; height: calc(100% - #{$browser-scrollbar-size}); - .fa { + svg { top: 50%; margin-top: -$gl-padding-8; } @@ -1057,11 +1026,6 @@ pre.light-well { margin-bottom: 0; } } - - .select2-choice { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } } .project-home-empty { diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss index 8ed6936475b..856e49bd144 100644 --- a/app/assets/stylesheets/pages/runners.scss +++ b/app/assets/stylesheets/pages/runners.scss @@ -12,16 +12,18 @@ } } -.runner-status-online { - color: $green-600; -} +.runner-status { + &.runner-status-online { + background-color: $green-600; + } -.runner-status-offline { - color: $gray-darkest; -} + &.runner-status-offline { + background-color: $gray-darkest; + } -.runner-status-paused { - color: $red-500; + &.runner-status-paused { + background-color: $red-500; + } } .runner { diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 502a1881fd2..cd99c667001 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -1,5 +1,7 @@ $search-dropdown-max-height: 400px; $search-avatar-size: 16px; +$search-sidebar-min-width: 240px; +$search-sidebar-max-width: 300px; .search-results { .search-result-row { @@ -17,6 +19,13 @@ $search-avatar-size: 16px; } } +.search-sidebar { + @include media-breakpoint-up(md) { + min-width: $search-sidebar-min-width; + max-width: $search-sidebar-max-width; + } +} + .search form:hover, .file-finder-input:hover, .issuable-search-form:hover, diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 7b18e3774d8..335e177d169 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -169,11 +169,6 @@ .form-check { margin-bottom: 10px; - i.fa { - margin: 2px 0; - font-size: 20px; - } - .option-title { font-weight: $gl-font-weight-normal; display: inline-block; @@ -193,7 +188,7 @@ } &.disabled { - i.fa { + svg { opacity: 0.5; } diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 429181c2ad4..8f3574a337b 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -1,8 +1,11 @@ +.project-last-commit { + min-height: 4.75rem; +} + .tree-holder { .nav-block { margin: 16px 0; - .btn .fa, .btn svg { color: $gl-text-color-secondary; } @@ -69,7 +72,7 @@ } .btn { - margin: 10px 0 0; + margin-top: 10px; } } } diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index b3e53e35f6e..af43c532b7c 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -977,12 +977,12 @@ body.navless { border-color: #e3e3e3; color: #303030; } -.btn.btn-success, .btn.btn-register { +.btn.btn-success { background-color: #108548; border-color: #217645; color: #fff; } -.btn.btn-success:active, .btn.btn-success.active, .btn.btn-register:active, .btn.btn-register.active { +.btn.btn-success:active, .btn.btn-success.active { box-shadow: rgba(0, 0, 0, 0.16); background-color: #24663b; border-color: #0d532a; diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index 6ab02bd5e27..7f2bea9bf26 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -1,63 +1,63 @@ $gray-10: #1f1f1f; -$gray-50: #2e2e2e; -$gray-100: #4f4f4f; -$gray-200: #707070; -$gray-300: #919191; -$gray-400: #a7a7a7; -$gray-500: #bababa; -$gray-600: #ccc; -$gray-700: #dfdfdf; -$gray-800: #f2f2f2; +$gray-50: #303030; +$gray-100: #404040; +$gray-200: #525252; +$gray-300: #5e5e5e; +$gray-400: #868686; +$gray-500: #999; +$gray-600: #bfbfbf; +$gray-700: #dbdbdb; +$gray-800: #f0f0f0; $gray-900: #fafafa; $gray-950: #fff; -$green-50: #072b15; -$green-100: #0a4020; -$green-200: #0e5a2d; -$green-300: #12753a; -$green-400: #168f48; -$green-500: #1aaa55; -$green-600: #37b96d; -$green-700: #75d09b; -$green-800: #b3e6c8; -$green-900: #dcf5e7; +$green-50: #0a4020; +$green-100: #0d532a; +$green-200: #24663b; +$green-300: #217645; +$green-400: #108548; +$green-500: #2da160; +$green-600: #52b87a; +$green-700: #91d4a8; +$green-800: #c3e6cd; +$green-900: #ecf4ee; $green-950: #f1fdf6; -$blue-50: #0a2744; -$blue-100: #0f3b66; -$blue-200: #134a81; -$blue-300: #17599c; -$blue-400: #1b69b6; -$blue-500: #1f78d1; -$blue-600: #418cd8; -$blue-700: #73afea; -$blue-800: #b8d6f4; -$blue-900: #e4f0fb; -$blue-950: #f6fafe; - -$orange-50: #592800; -$orange-100: #853c00; -$orange-200: #a35200; -$orange-300: #c26700; -$orange-400: #de7e00; -$orange-500: #fc9403; -$orange-600: #fca429; -$orange-700: #fdbc60; -$orange-800: #fed69f; -$orange-900: #fff1de; -$orange-950: #fffaf4; - -$red-50: #4b140b; -$red-100: #711e11; -$red-200: #8b2615; -$red-300: #a62d19; -$red-400: #c0341d; -$red-500: #db3b21; -$red-600: #e05842; -$red-700: #ea8271; -$red-800: #f2b4a9; -$red-900: #fbe5e1; -$red-950: #fef6f5; +$blue-50: #033464; +$blue-100: #064787; +$blue-200: #0b5cad; +$blue-300: #1068bf; +$blue-400: #1f75cb; +$blue-500: #428fdc; +$blue-600: #63a6e9; +$blue-700: #9dc7f1; +$blue-800: #cbe2f9; +$blue-900: #e9f3fc; +$blue-950: #f2f9ff; + +$orange-50: #5c2900; +$orange-100: #703800; +$orange-200: #8f4700; +$orange-300: #9e5400; +$orange-400: #ab6100; +$orange-500: #c17d10; +$orange-600: #d99530; +$orange-700: #e9be74; +$orange-800: #f5d9a8; +$orange-900: #fdf1dd; +$orange-950: #fff4e1; + +$red-50: #660e00; +$red-100: #8d1300; +$red-200: #ae1800; +$red-300: #c91c00; +$red-400: #dd2b0e; +$red-500: #ec5941; +$red-600: #f57f6c; +$red-700: #fcb5aa; +$red-800: #fdd4cd; +$red-900: #fcf1ef; +$red-950: #fff4f3; $indigo-50: #1a1a40; $indigo-100: #292961; @@ -166,14 +166,16 @@ body.gl-dark { --white: #{$white}; --black: #{$black}; + + --svg-status-bg: #{$white}; } $border-white-light: $gray-900; $border-white-normal: $gray-900; -$body-bg: $gray-50; -$input-bg: $gray-100; -$input-focus-bg: $gray-100; +$body-bg: $gray-10; +$input-bg: $white; +$input-focus-bg: $white; $input-color: $gray-900; $input-group-addon-bg: $gray-900; diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index 85115cfd5d9..417377b514e 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -64,14 +64,20 @@ color: $search-and-nav-links; > a { + .notification-dot { + border: 2px solid $nav-svg-color; + } + + &.header-help-dropdown-toggle { + .notification-dot { + background-color: $search-and-nav-links; + } + } + &.header-user-dropdown-toggle { .header-user-avatar { border-color: $search-and-nav-links; } - - .header-user-notification-dot { - border: 2px solid $nav-svg-color; - } } &:hover, @@ -84,9 +90,14 @@ fill: currentColor; } - &.header-user-dropdown-toggle .header-user-notification-dot { + .notification-dot { + will-change: border-color, background-color; border-color: $nav-svg-color + 33; } + + &.header-help-dropdown-toggle .notification-dot { + background-color: $white; + } } } @@ -101,9 +112,15 @@ } } - &.header-user-dropdown-toggle .header-user-notification-dot { + .notification-dot { border-color: $white; } + + &.header-help-dropdown-toggle { + .notification-dot { + background-color: $nav-svg-color; + } + } } .impersonated-user, diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index a3bb7c868df..bf251993c38 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -129,3 +129,30 @@ content: ''; display: flex; } + +// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1085 +.gl-md-flex-direction-column { + @media (min-width: $breakpoint-md) { + flex-direction: column; + } +} + +// Same as above +.gl-md-flex-direction-column\! { + @media (min-width: $breakpoint-md) { + flex-direction: column !important; + } +} + +// These will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1091 +.gl-w-10p { + width: 10%; +} + +.gl-w-20p { + width: 20%; +} + +.gl-w-40p { + width: 40%; +} |