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 | |
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')
1514 files changed, 20910 insertions, 11841 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%; +} diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb index d5cd9c55422..a26dc554506 100644 --- a/app/controllers/admin/cohorts_controller.rb +++ b/app/controllers/admin/cohorts_controller.rb @@ -5,7 +5,7 @@ class Admin::CohortsController < Admin::ApplicationController track_unique_visits :index, target_id: 'i_analytics_cohorts' - feature_category :instance_statistics + feature_category :devops_reports def index if Gitlab::CurrentSettings.usage_ping_enabled diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 33a8cc4ae42..da89276f5eb 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -2,7 +2,6 @@ class Admin::DashboardController < Admin::ApplicationController include CountHelper - helper_method :show_license_breakdown? COUNTED_ITEMS = [Project, User, Group].freeze @@ -23,10 +22,6 @@ class Admin::DashboardController < Admin::ApplicationController def stats @users_statistics = UsersStatistics.latest end - - def show_license_breakdown? - false - end end Admin::DashboardController.prepend_if_ee('EE::Admin::DashboardController') diff --git a/app/controllers/admin/instance_review_controller.rb b/app/controllers/admin/instance_review_controller.rb index db304c82dd6..88ca2c88aab 100644 --- a/app/controllers/admin/instance_review_controller.rb +++ b/app/controllers/admin/instance_review_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true class Admin::InstanceReviewController < Admin::ApplicationController - feature_category :instance_statistics + feature_category :devops_reports def index redirect_to("#{::Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL}/instance_review?#{instance_review_params}") diff --git a/app/controllers/admin/instance_statistics_controller.rb b/app/controllers/admin/instance_statistics_controller.rb index 05a0a1ce314..30891fcfe7c 100644 --- a/app/controllers/admin/instance_statistics_controller.rb +++ b/app/controllers/admin/instance_statistics_controller.rb @@ -7,7 +7,7 @@ class Admin::InstanceStatisticsController < Admin::ApplicationController track_unique_visits :index, target_id: 'i_analytics_instance_statistics' - feature_category :instance_statistics + feature_category :devops_reports def index end diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb index aab8705f5cb..4247446365c 100644 --- a/app/controllers/admin/integrations_controller.rb +++ b/app/controllers/admin/integrations_controller.rb @@ -4,6 +4,8 @@ class Admin::IntegrationsController < Admin::ApplicationController include IntegrationsActions include ServicesHelper + before_action :not_found, unless: -> { instance_level_integrations? } + feature_category :integrations private @@ -12,10 +14,6 @@ class Admin::IntegrationsController < Admin::ApplicationController Service.find_or_initialize_non_project_specific_integration(name, instance: true) end - def integrations_enabled? - instance_level_integrations? - end - def scoped_edit_integration_path(integration) edit_admin_application_settings_integration_path(integration) end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 2d0bb0bfebc..3fe972d1917 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -72,6 +72,16 @@ class Admin::UsersController < Admin::ApplicationController end end + def reject + result = Users::RejectService.new(current_user).execute(user) + + if result[:status] == :success + redirect_to admin_users_path, status: :found, notice: _("You've rejected %{user}" % { user: user.name }) + else + redirect_back_or_admin_user(alert: result[:message]) + end + end + def activate return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked? diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c38c6abddc1..b78029a52cd 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -61,8 +61,7 @@ class ApplicationController < ActionController::Base :gitea_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, - :bitbucket_server_import_enabled?, - :google_code_import_enabled?, :fogbugz_import_enabled?, + :bitbucket_server_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled?, :manifest_import_enabled?, :phabricator_import_enabled? @@ -434,10 +433,6 @@ class ApplicationController < ActionController::Base Gitlab::Auth::OAuth::Provider.enabled?(:bitbucket) end - def google_code_import_enabled? - Gitlab::CurrentSettings.import_sources.include?('google_code') - end - def fogbugz_import_enabled? Gitlab::CurrentSettings.import_sources.include?('fogbugz') end diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb index aecd287370f..19a4508c061 100644 --- a/app/controllers/boards/lists_controller.rb +++ b/app/controllers/boards/lists_controller.rb @@ -19,12 +19,12 @@ module Boards end def create - list = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board) + response = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board) - if list.valid? - render json: serialize_as_json(list) + if response.success? + render json: serialize_as_json(response.payload[:list]) else - render json: list.errors, status: :unprocessable_entity + render json: { errors: response.errors }, status: :unprocessable_entity end end diff --git a/app/controllers/concerns/dependency_proxy/auth.rb b/app/controllers/concerns/dependency_proxy/auth.rb new file mode 100644 index 00000000000..1276feedba6 --- /dev/null +++ b/app/controllers/concerns/dependency_proxy/auth.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module DependencyProxy + module Auth + extend ActiveSupport::Concern + + included do + # We disable `authenticate_user!` since the `DependencyProxy::Auth` performs auth using JWT token + skip_before_action :authenticate_user!, raise: false + prepend_before_action :authenticate_user_from_jwt_token! + end + + def authenticate_user_from_jwt_token! + return unless dependency_proxy_for_private_groups? + + authenticate_with_http_token do |token, _| + user = user_from_token(token) + sign_in(user) if user + end + + request_bearer_token! unless current_user + end + + private + + def dependency_proxy_for_private_groups? + Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true) + end + + def request_bearer_token! + # unfortunately, we cannot use https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token.html#method-i-authentication_request + response.headers['WWW-Authenticate'] = ::DependencyProxy::Registry.authenticate_header + render plain: '', status: :unauthorized + end + + def user_from_token(token) + token_payload = DependencyProxy::AuthTokenService.decoded_token_payload(token) + User.find(token_payload['user_id']) + rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature + nil + end + end +end diff --git a/app/controllers/concerns/dependency_proxy/group_access.rb b/app/controllers/concerns/dependency_proxy/group_access.rb new file mode 100644 index 00000000000..2a923d02752 --- /dev/null +++ b/app/controllers/concerns/dependency_proxy/group_access.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module DependencyProxy + module GroupAccess + extend ActiveSupport::Concern + + included do + before_action :verify_dependency_proxy_enabled! + before_action :authorize_read_dependency_proxy! + end + + private + + def verify_dependency_proxy_enabled! + render_404 unless group.dependency_proxy_feature_available? + end + + def authorize_read_dependency_proxy! + access_denied! unless can?(current_user, :read_dependency_proxy, group) + end + + def authorize_admin_dependency_proxy! + access_denied! unless can?(current_user, :admin_dependency_proxy, group) + end + end +end diff --git a/app/controllers/concerns/dependency_proxy_access.rb b/app/controllers/concerns/dependency_proxy_access.rb deleted file mode 100644 index 5036d0cfce4..00000000000 --- a/app/controllers/concerns/dependency_proxy_access.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module DependencyProxyAccess - extend ActiveSupport::Concern - - included do - before_action :verify_dependency_proxy_enabled! - before_action :authorize_read_dependency_proxy! - end - - private - - def verify_dependency_proxy_enabled! - render_404 unless group.dependency_proxy_feature_available? - end - - def authorize_read_dependency_proxy! - access_denied! unless can?(current_user, :read_dependency_proxy, group) - end - - def authorize_admin_dependency_proxy! - access_denied! unless can?(current_user, :admin_dependency_proxy, group) - end -end diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb index 8e9b038437d..baebedb8e5d 100644 --- a/app/controllers/concerns/integrations_actions.rb +++ b/app/controllers/concerns/integrations_actions.rb @@ -6,7 +6,6 @@ module IntegrationsActions included do include ServiceParams - before_action :not_found, unless: :integrations_enabled? before_action :integration, only: [:edit, :update, :test] end @@ -43,12 +42,16 @@ module IntegrationsActions render json: {}, status: :ok end - private + def reset + integration.destroy! + + flash[:notice] = s_('Integrations|This integration, and inheriting projects were reset.') - def integrations_enabled? - false + render json: {}, status: :ok end + private + def integration # Using instance variable `@service` still required as it's used in ServiceParams. # Should be removed once that is refactored to use `@integration`. diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 0d7af57328a..3f5f3b6e9df 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -150,7 +150,7 @@ module IssuableCollections common_attributes + [:project, project: :namespace] when 'MergeRequest' common_attributes + [ - :target_project, :latest_merge_request_diff, :approvals, :approved_by_users, + :target_project, :latest_merge_request_diff, :approvals, :approved_by_users, :reviewers, source_project: :route, head_pipeline: :project, target_project: :namespace ] end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index a19c43a227a..c295290a123 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -23,6 +23,9 @@ module ServiceParams :comment_detail, :confidential_issues_events, :confluence_url, + :datadog_site, + :datadog_env, + :datadog_service, :default_irc_uri, :device, :disable_diffs, diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index 0153ede2821..c93e75b438b 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -9,11 +9,14 @@ module SnippetsActions include Gitlab::NoteableMetadata include Snippets::SendBlob include SnippetsSort + include RedisTracking included do skip_before_action :verify_authenticity_token, if: -> { action_name == 'show' && js_request? } + track_redis_hll_event :show, name: 'i_snippets_show', feature: :usage_data_i_snippets_show, feature_default_enabled: true + respond_to :html end diff --git a/app/controllers/concerns/sorting_preference.rb b/app/controllers/concerns/sorting_preference.rb index a51b68147d5..8d8845e2f41 100644 --- a/app/controllers/concerns/sorting_preference.rb +++ b/app/controllers/concerns/sorting_preference.rb @@ -4,8 +4,11 @@ module SortingPreference include SortingHelper include CookiesHelper - def set_sort_order - set_sort_order_from_user_preference || set_sort_order_from_cookie || params[:sort] || default_sort_order + def set_sort_order(field = sorting_field, default_order = default_sort_order) + set_sort_order_from_user_preference(field) || + set_sort_order_from_cookie(field) || + params[:sort] || + default_order end # Implement sorting_field method on controllers @@ -29,42 +32,42 @@ module SortingPreference private - def set_sort_order_from_user_preference + def set_sort_order_from_user_preference(field = sorting_field) return unless current_user - return unless sorting_field + return unless field user_preference = current_user.user_preference sort_param = params[:sort] - sort_param ||= user_preference[sorting_field] + sort_param ||= user_preference[field] return sort_param if Gitlab::Database.read_only? - if user_preference[sorting_field] != sort_param - user_preference.update(sorting_field => sort_param) + if user_preference[field] != sort_param + user_preference.update(field => sort_param) end sort_param end - def set_sort_order_from_cookie + def set_sort_order_from_cookie(field = sorting_field) return unless legacy_sort_cookie_name sort_param = params[:sort] if params[:sort].present? # fallback to legacy cookie value for backward compatibility sort_param ||= cookies[legacy_sort_cookie_name] - sort_param ||= cookies[remember_sorting_key] + sort_param ||= cookies[remember_sorting_key(field)] sort_value = update_cookie_value(sort_param) - set_secure_cookie(remember_sorting_key, sort_value) + set_secure_cookie(remember_sorting_key(field), sort_value) sort_value end # Convert sorting_field to legacy cookie name for backwards compatibility # :merge_requests_sort => 'mergerequest_sort' # :issues_sort => 'issue_sort' - def remember_sorting_key - @remember_sorting_key ||= sorting_field + def remember_sorting_key(field = sorting_field) + @remember_sorting_key ||= field .to_s .split('_')[0..-2] .map(&:singularize) diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index 6abb2e16226..1ae90edd8f7 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -8,6 +8,8 @@ module WikiActions include RedisTracking extend ActiveSupport::Concern + RESCUE_GIT_TIMEOUTS_IN = %w[show edit history diff pages].freeze + included do before_action { respond_to :html } @@ -38,6 +40,12 @@ module WikiActions feature: :track_unique_wiki_page_views, feature_default_enabled: true helper_method :view_file_button, :diff_file_html_data + + rescue_from ::Gitlab::Git::CommandTimedOut do |exc| + raise exc unless RESCUE_GIT_TIMEOUTS_IN.include?(action_name) + + render 'shared/wikis/git_error' + end end def new @@ -46,11 +54,7 @@ module WikiActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def pages - @wiki_pages = Kaminari.paginate_array( - wiki.list_pages(sort: params[:sort], direction: params[:direction]) - ).page(params[:page]) - - @wiki_entries = WikiDirectory.group_pages(@wiki_pages) + @wiki_entries = WikiDirectory.group_pages(wiki_pages) render 'shared/wikis/pages' end @@ -182,6 +186,10 @@ module WikiActions end # rubocop:enable Gitlab/ModuleWithInstanceVariables + def git_access + render 'shared/wikis/git_access' + end + private def container @@ -225,9 +233,19 @@ module WikiActions unless @sidebar_page # Fallback to default sidebar @sidebar_wiki_entries, @sidebar_limited = wiki.sidebar_entries end + rescue ::Gitlab::Git::CommandTimedOut => e + @sidebar_error = e end # rubocop:enable Gitlab/ModuleWithInstanceVariables + def wiki_pages + strong_memoize(:wiki_pages) do + Kaminari.paginate_array( + wiki.list_pages(sort: params[:sort], direction: params[:direction]) + ).page(params[:page]) + end + end + def wiki_params params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha) end diff --git a/app/controllers/concerns/workhorse_import_export_upload.rb b/app/controllers/concerns/workhorse_authorization.rb index 3c52f4d7adf..a290ba256b6 100644 --- a/app/controllers/concerns/workhorse_import_export_upload.rb +++ b/app/controllers/concerns/workhorse_authorization.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module WorkhorseImportExportUpload +module WorkhorseAuthorization extend ActiveSupport::Concern include WorkhorseRequest @@ -12,10 +12,9 @@ module WorkhorseImportExportUpload def authorize set_workhorse_internal_api_content_type - authorized = ImportExportUploader.workhorse_authorize( + authorized = uploader_class.workhorse_authorize( has_length: false, - maximum_size: Gitlab::CurrentSettings.max_import_size.megabytes - ) + maximum_size: maximum_size.to_i) render json: authorized rescue SocketError @@ -27,7 +26,18 @@ module WorkhorseImportExportUpload def file_is_valid?(file) return false unless file.is_a?(::UploadedFile) + file_extension_whitelist.include?(File.extname(file.original_filename).downcase.delete('.')) + end + + def uploader_class + raise NotImplementedError + end + + def maximum_size + raise NotImplementedError + end + + def file_extension_whitelist ImportExportUploader::EXTENSION_WHITELIST - .include?(File.extname(file.original_filename).delete('.')) end end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index f7a74f40e4b..aa3592ff209 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -108,7 +108,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end def default_sort_order - sort_value_latest_activity + sort_value_name end def sorting_field diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 7a485eebfe3..d210d0f66fd 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -94,7 +94,7 @@ class Explore::ProjectsController < Explore::ApplicationController end def default_sort_order - sort_value_latest_activity + sort_value_name end def sorting_field diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index b5deed70380..1852405e7cf 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -37,7 +37,11 @@ class GraphqlController < ApplicationController rescue_from StandardError do |exception| log_exception(exception) - render_error("Internal server error") + if Rails.env.test? || Rails.env.development? + render_error("Internal server error: #{exception.message}") + else + render_error("Internal server error") + end end rescue_from Gitlab::Graphql::Variables::Invalid do |exception| diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index 9c2e361e92f..a504d2ce991 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -3,11 +3,14 @@ class Groups::ApplicationController < ApplicationController include RoutableActions include ControllerWithCrossProjectAccessCheck + include SortingHelper + include SortingPreference layout 'group' skip_before_action :authenticate_user! before_action :group + before_action :set_sorting requires_cross_project_access private @@ -57,6 +60,16 @@ class Groups::ApplicationController < ApplicationController url_for(safe_params) end + + def set_sorting + if has_project_list? + @group_projects_sort = set_sort_order(Project::SORTING_PREFERENCE_FIELD, sort_value_name) + end + end + + def has_project_list? + false + end end Groups::ApplicationController.prepend_if_ee('EE::Groups::ApplicationController') diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index c2d72610c66..093cdf258b2 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -8,7 +8,6 @@ class Groups::BoardsController < Groups::ApplicationController before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false) - push_frontend_feature_flag(:boards_with_swimlanes, group, default_enabled: true) end feature_category :boards diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb index 718914dea35..10a6ad06ae5 100644 --- a/app/controllers/groups/children_controller.rb +++ b/app/controllers/groups/children_controller.rb @@ -2,12 +2,15 @@ module Groups class ChildrenController < Groups::ApplicationController + extend ::Gitlab::Utils::Override + before_action :group skip_cross_project_access_check :index feature_category :subgroups def index + params[:sort] ||= @group_projects_sort parent = if params[:parent_id].present? GroupFinder.new(current_user).execute(id: params[:parent_id]) else @@ -40,5 +43,12 @@ module Groups params: params.to_unsafe_h).execute @children = @children.page(params[:page]) end + + private + + override :has_project_list? + def has_project_list? + true + end end end diff --git a/app/controllers/groups/dependency_proxies_controller.rb b/app/controllers/groups/dependency_proxies_controller.rb index 367dbafdd59..b896b240daf 100644 --- a/app/controllers/groups/dependency_proxies_controller.rb +++ b/app/controllers/groups/dependency_proxies_controller.rb @@ -2,7 +2,7 @@ module Groups class DependencyProxiesController < Groups::ApplicationController - include DependencyProxyAccess + include DependencyProxy::GroupAccess before_action :authorize_admin_dependency_proxy!, only: :update before_action :dependency_proxy diff --git a/app/controllers/groups/dependency_proxy_auth_controller.rb b/app/controllers/groups/dependency_proxy_auth_controller.rb new file mode 100644 index 00000000000..e3e9bd88e24 --- /dev/null +++ b/app/controllers/groups/dependency_proxy_auth_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Groups::DependencyProxyAuthController < ApplicationController + include DependencyProxy::Auth + + feature_category :dependency_proxy + + def authenticate + render plain: '', status: :ok + end +end diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb index f46902ef90f..0f640397320 100644 --- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb +++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class Groups::DependencyProxyForContainersController < Groups::ApplicationController - include DependencyProxyAccess + include DependencyProxy::Auth + include DependencyProxy::GroupAccess include SendFileUpload before_action :ensure_token_granted! @@ -9,13 +10,13 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro attr_reader :token - feature_category :package_registry + feature_category :dependency_proxy def manifest - result = DependencyProxy::PullManifestService.new(image, tag, token).execute + result = DependencyProxy::FindOrCreateManifestService.new(group, image, tag, token).execute if result[:status] == :success - render json: result[:manifest] + send_upload(result[:manifest].file) else render status: result[:http_status], json: result[:message] end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 5df7ff0632a..d1b09e1b49e 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -14,6 +14,10 @@ class Groups::GroupMembersController < Groups::ApplicationController # Authorize before_action :authorize_admin_group_member!, except: admin_not_required_endpoints + before_action do + push_frontend_feature_flag(:group_members_filtered_search, @group, default_enabled: true) + end + skip_before_action :check_two_factor_requirement, only: :leave skip_cross_project_access_check :index, :create, :update, :destroy, :request_access, :approve_access_request, :leave, :resend_invite, diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 03d41f1dd6d..84dc570a1e9 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -5,9 +5,6 @@ class Groups::MilestonesController < Groups::ApplicationController before_action :milestone, only: [:edit, :show, :update, :issues, :merge_requests, :participants, :labels, :destroy] before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy] - before_action do - push_frontend_feature_flag(:burnup_charts, @group, default_enabled: true) - end feature_category :issue_tracking diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb index a66372b3571..8903feaff04 100644 --- a/app/controllers/groups/settings/integrations_controller.rb +++ b/app/controllers/groups/settings/integrations_controller.rb @@ -25,10 +25,6 @@ module Groups Service.find_or_initialize_non_project_specific_integration(name, group_id: group.id) end - def integrations_enabled? - Feature.enabled?(:group_level_integrations, group, default_enabled: true) - end - def scoped_edit_integration_path(integration) edit_group_settings_integration_path(group, integration) end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 8d528e123e1..068815f7f07 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -69,7 +69,7 @@ class GroupsController < Groups::ApplicationController @group = Groups::CreateService.new(current_user, group_params).execute if @group.persisted? - track_experiment_event(:onboarding_issues, 'created_namespace') + successful_creation_hooks notice = if @group.chat_team.present? "Group '#{@group.name}' and its Mattermost team were successfully created." @@ -319,6 +319,10 @@ class GroupsController < Groups::ApplicationController private + def successful_creation_hooks + track_experiment_event(:onboarding_issues, 'created_namespace') + end + def groups if @group.supports_events? @group.self_and_descendants.public_or_visible_to_user(current_user) @@ -329,6 +333,11 @@ class GroupsController < Groups::ApplicationController def markdown_service_params params.merge(group: group) end + + override :has_project_list? + def has_project_list? + %w(details show index).include?(action_name) + end end GroupsController.prepend_if_ee('EE::GroupsController') diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index 78f4a0cffca..4417cfe9098 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -20,8 +20,9 @@ class Import::BulkImportsController < ApplicationController format.json do render json: { importable_data: serialized_importable_data } end - - format.html + format.html do + @source_url = session[url_key] + end end end @@ -57,7 +58,7 @@ class Import::BulkImportsController < ApplicationController end def create_params - params.permit(:bulk_import, [*bulk_import_params]) + params.permit(bulk_import: bulk_import_params)[:bulk_import] end def bulk_import_params @@ -84,11 +85,9 @@ class Import::BulkImportsController < ApplicationController def verify_blocked_uri Gitlab::UrlBlocker.validate!( session[url_key], - **{ - allow_localhost: allow_local_requests?, - allow_local_network: allow_local_requests?, - schemes: %w(http https) - } + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + schemes: %w(http https) ) rescue Gitlab::UrlBlocker::BlockedUrlError => e clear_session_data @@ -129,7 +128,7 @@ class Import::BulkImportsController < ApplicationController def credentials { url: session[url_key], - access_token: [access_token_key] + access_token: session[access_token_key] } end end diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index bcbf5938e11..17f937a3dfd 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -136,11 +136,9 @@ class Import::FogbugzController < Import::BaseController def verify_blocked_uri Gitlab::UrlBlocker.validate!( params[:uri], - **{ - allow_localhost: allow_local_requests?, - allow_local_network: allow_local_requests?, - schemes: %w(http https) - } + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + schemes: %w(http https) ) rescue Gitlab::UrlBlocker::BlockedUrlError => e redirect_to new_import_fogbugz_url, alert: _('Specified URL cannot be used: "%{reason}"') % { reason: e.message } diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb index 4785a71b8a1..5a4eef352b8 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -72,11 +72,9 @@ class Import::GiteaController < Import::GithubController def verify_blocked_uri Gitlab::UrlBlocker.validate!( provider_url, - { - allow_localhost: allow_local_requests?, - allow_local_network: allow_local_requests?, - schemes: %w(http https) - } + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + schemes: %w(http https) ) rescue Gitlab::UrlBlocker::BlockedUrlError => e session[access_token_key] = nil diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 8ac93aeb9c0..beb3e92b5ea 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -17,6 +17,8 @@ class Import::GithubController < Import::BaseController rescue_from Octokit::TooManyRequests, with: :provider_rate_limit rescue_from Gitlab::GithubImport::RateLimitError, with: :rate_limit_threshold_exceeded + PAGE_LENGTH = 25 + def new if !ci_cd_only? && github_import_configured? && logged_in_with_provider? go_to_provider_for_permissions @@ -115,19 +117,16 @@ class Import::GithubController < Import::BaseController def client_repos @client_repos ||= if Feature.enabled?(:remove_legacy_github_client) - concatenated_repos + if sanitized_filter_param + client.search_repos_by_name(sanitized_filter_param, pagination_options)[:items] + else + client.octokit.repos(nil, pagination_options) + end else filtered(client.repos) end end - def concatenated_repos - return [] unless client.respond_to?(:each_page) - return client.each_page(:repos).flat_map(&:objects) unless sanitized_filter_param - - client.search_repos_by_name(sanitized_filter_param).flat_map(&:objects).flat_map(&:items) - end - def sanitized_filter_param super @@ -257,6 +256,13 @@ class Import::GithubController < Import::BaseController def rate_limit_threshold_exceeded head :too_many_requests end + + def pagination_options + { + page: [1, params[:page].to_i].max, + per_page: PAGE_LENGTH + } + end end Import::GithubController.prepend_if_ee('EE::Import::GithubController') diff --git a/app/controllers/import/gitlab_groups_controller.rb b/app/controllers/import/gitlab_groups_controller.rb index d8118477a80..f68b76a7b36 100644 --- a/app/controllers/import/gitlab_groups_controller.rb +++ b/app/controllers/import/gitlab_groups_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Import::GitlabGroupsController < ApplicationController - include WorkhorseImportExportUpload + include WorkhorseAuthorization before_action :ensure_group_import_enabled before_action :import_rate_limit, only: %i[create] @@ -64,4 +64,12 @@ class Import::GitlabGroupsController < ApplicationController redirect_to new_group_path end end + + def uploader_class + ImportExportUploader + end + + def maximum_size + Gitlab::CurrentSettings.max_import_size.megabytes + end end diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb index 39d053347f0..0e6b0af6baf 100644 --- a/app/controllers/import/gitlab_projects_controller.rb +++ b/app/controllers/import/gitlab_projects_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Import::GitlabProjectsController < Import::BaseController - include WorkhorseImportExportUpload + include WorkhorseAuthorization before_action :whitelist_query_limiting, only: [:create] before_action :verify_gitlab_project_import_enabled @@ -45,4 +45,12 @@ class Import::GitlabProjectsController < Import::BaseController def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42437') end + + def uploader_class + ImportExportUploader + end + + def maximum_size + Gitlab::CurrentSettings.max_import_size.megabytes + end end diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb deleted file mode 100644 index 03bde0345e3..00000000000 --- a/app/controllers/import/google_code_controller.rb +++ /dev/null @@ -1,123 +0,0 @@ -# frozen_string_literal: true - -class Import::GoogleCodeController < Import::BaseController - before_action :verify_google_code_import_enabled - before_action :user_map, only: [:new_user_map, :create_user_map] - - def new - end - - def callback - dump_file = params[:dump_file] - - unless dump_file.respond_to?(:read) - return redirect_back_or_default(options: { alert: _("You need to upload a Google Takeout archive.") }) - end - - begin - dump = Gitlab::Json.parse(dump_file.read) - rescue - return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") }) - end - - client = Gitlab::GoogleCodeImport::Client.new(dump) - unless client.valid? - return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") }) - end - - session[:google_code_dump] = dump - - if params[:create_user_map] == "1" - redirect_to new_user_map_import_google_code_path - else - redirect_to status_import_google_code_path - end - end - - def new_user_map - end - - def create_user_map - user_map_json = params[:user_map] - user_map_json = "{}" if user_map_json.blank? - - begin - user_map = Gitlab::Json.parse(user_map_json) - rescue - flash.now[:alert] = _("The entered user map is not a valid JSON user map.") - - return render "new_user_map" - end - - unless user_map.is_a?(Hash) && user_map.all? { |k, v| k.is_a?(String) && v.is_a?(String) } - flash.now[:alert] = _("The entered user map is not a valid JSON user map.") - - return render "new_user_map" - end - - # This is the default, so let's not save it into the database. - user_map.reject! do |key, value| - value == Gitlab::GoogleCodeImport::Client.mask_email(key) - end - - session[:google_code_user_map] = user_map - - flash[:notice] = _("The user map has been saved. Continue by selecting the projects you want to import.") - - redirect_to status_import_google_code_path - end - - # rubocop: disable CodeReuse/ActiveRecord - def status - unless client.valid? - return redirect_to new_import_google_code_path - end - - @repos = client.repos - @incompatible_repos = client.incompatible_repos - - @already_added_projects = find_already_added_projects('google_code') - already_added_projects_names = @already_added_projects.pluck(:import_source) - - @repos.reject! { |repo| already_added_projects_names.include? repo.name } - end - # rubocop: enable CodeReuse/ActiveRecord - - def jobs - render json: find_jobs('google_code') - end - - def create - repo = client.repo(params[:repo_id]) - user_map = session[:google_code_user_map] - - project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, current_user.namespace, current_user, user_map).execute - - if project.persisted? - render json: ProjectSerializer.new.represent(project) - else - render json: { errors: project_save_error(project) }, status: :unprocessable_entity - end - end - - private - - def client - @client ||= Gitlab::GoogleCodeImport::Client.new(session[:google_code_dump]) - end - - def verify_google_code_import_enabled - render_404 unless google_code_import_enabled? - end - - def user_map - @user_map ||= begin - user_map = client.user_map - - stored_user_map = session[:google_code_user_map] - user_map.update(stored_user_map) if stored_user_map - - Hash[user_map.sort] - end - end -end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 26fc1c11f6d..ad92645c23e 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -20,7 +20,6 @@ class InvitesController < ApplicationController def accept if member.accept_invite!(current_user) - track_invitation_reminders_experiment('accepted') redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") % { member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] } else @@ -107,17 +106,4 @@ class InvitesController < ApplicationController } end end - - def track_invitation_reminders_experiment(action) - return unless Gitlab::Experimentation.enabled?(:invitation_reminders) - - property = Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group' - - Gitlab::Tracking.event( - Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category, - action, - property: property, - label: Digest::MD5.hexdigest(member.to_global_id.to_s) - ) - end end diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb index bf53c61601b..d1ba8a98c64 100644 --- a/app/controllers/jira_connect/app_descriptor_controller.rb +++ b/app/controllers/jira_connect/app_descriptor_controller.rb @@ -27,29 +27,9 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController authentication: { type: 'jwt' }, + modules: modules, scopes: %w(READ WRITE DELETE), apiVersion: 1, - modules: { - jiraDevelopmentTool: { - key: 'gitlab-development-tool', - application: { - value: 'GitLab' - }, - name: { - value: 'GitLab' - }, - url: 'https://gitlab.com', - logoUrl: view_context.image_url('gitlab_logo.png'), - capabilities: %w(branch commit pull_request) - }, - postInstallPage: { - key: 'gitlab-configuration', - name: { - value: 'GitLab Configuration' - }, - url: relative_to_base_path(jira_connect_subscriptions_path) - } - }, apiMigrations: { gdpr: true } @@ -58,6 +38,55 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController private + HOME_URL = 'https://gitlab.com' + DOC_URL = 'https://docs.gitlab.com/ee/user/project/integrations/jira.html#gitlab-jira-integration' + + def modules + modules = { + jiraDevelopmentTool: { + key: 'gitlab-development-tool', + application: { + value: 'GitLab' + }, + name: { + value: 'GitLab' + }, + url: HOME_URL, + logoUrl: logo_url, + capabilities: %w(branch commit pull_request) + }, + postInstallPage: { + key: 'gitlab-configuration', + name: { + value: 'GitLab Configuration' + }, + url: relative_to_base_path(jira_connect_subscriptions_path) + } + } + + modules.merge!(build_information_module) + + modules + end + + def logo_url + view_context.image_url('gitlab_logo.png') + end + + # See: https://developer.atlassian.com/cloud/jira/software/modules/build/ + def build_information_module + { + jiraBuildInfoProvider: { + homeUrl: HOME_URL, + logoUrl: logo_url, + documentationUrl: DOC_URL, + actions: {}, + name: { value: "GitLab CI" }, + key: "gitlab-ci" + } + } + end + def relative_to_base_path(full_path) full_path.sub(/^#{jira_connect_base_path}/, '') end diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 5199bb25c8c..85ee2204324 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -11,7 +11,8 @@ class JwtController < ApplicationController feature_category :authentication_and_authorization SERVICES = { - Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService + ::Auth::ContainerRegistryAuthenticationService::AUDIENCE => ::Auth::ContainerRegistryAuthenticationService, + ::Auth::DependencyProxyAuthenticationService::AUDIENCE => ::Auth::DependencyProxyAuthenticationService }.freeze def auth diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index 1e6340f285e..3a189c900ac 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Profiles::KeysController < Profiles::ApplicationController - skip_before_action :authenticate_user!, only: [:get_keys] - feature_category :users def index @@ -35,25 +33,6 @@ class Profiles::KeysController < Profiles::ApplicationController end end - # Get all keys of a user(params[:username]) in a text format - # Helpful for sysadmins to put in respective servers - def get_keys - if params[:username].present? - begin - user = UserFinder.new(params[:username]).find_by_username - if user.present? - render plain: user.all_ssh_keys.join("\n") - else - render_404 - end - rescue => e - render html: e.message - end - else - render_404 - end - end - private def key_params diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb index 8ecf8fadefd..ebe867d915d 100644 --- a/app/controllers/projects/alert_management_controller.rb +++ b/app/controllers/projects/alert_management_controller.rb @@ -3,7 +3,7 @@ class Projects::AlertManagementController < Projects::ApplicationController before_action :authorize_read_alert_management_alert! - feature_category :alert_management + feature_category :incident_management def index end diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb index a3f4d784f25..db5d91308db 100644 --- a/app/controllers/projects/alerting/notifications_controller.rb +++ b/app/controllers/projects/alerting/notifications_controller.rb @@ -10,7 +10,7 @@ module Projects prepend_before_action :repository, :project_without_auth - feature_category :alert_management + feature_category :incident_management def create token = extract_alert_manager_token(request) @@ -31,7 +31,7 @@ module Projects end def notify_service - notify_service_class.new(project, current_user, notification_payload) + notify_service_class.new(project, notification_payload) end def notify_service_class diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 02e941db636..8f16650a6f2 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -32,11 +32,6 @@ class Projects::BlobController < Projects::ApplicationController before_action :validate_diff_params, only: :diff before_action :set_last_commit_sha, only: [:edit, :update] - before_action only: :show do - push_frontend_feature_flag(:suggest_pipeline, default_enabled: true) - push_frontend_feature_flag(:gitlab_ci_yml_preview, @project, default_enabled: false) - end - track_redis_hll_event :create, :update, name: 'g_edit_by_sfe', feature: :track_editor_edit_actions, feature_default_enabled: true feature_category :source_code_management diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index fe4502a0e06..51c9bf3699a 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -8,7 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController before_action :authorize_read_board!, only: [:index, :show] before_action :assign_endpoint_vars before_action do - push_frontend_feature_flag(:boards_with_swimlanes, project, default_enabled: true) + push_frontend_feature_flag(:add_issues_button) end feature_category :boards diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index cf1efda5d13..a753d5705aa 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -18,8 +18,8 @@ class Projects::BranchesController < Projects::ApplicationController def index respond_to do |format| format.html do - @sort = params[:sort].presence || sort_value_recently_updated @mode = params[:state].presence || 'overview' + @sort = sort_value_for_mode @overview_max_branches = 5 # Fetch branches for the specified mode @@ -42,10 +42,6 @@ class Projects::BranchesController < Projects::ApplicationController end end - def recent - @branches = @repository.recent_branches - end - def diverging_commit_counts respond_to do |format| format.json do @@ -129,6 +125,12 @@ class Projects::BranchesController < Projects::ApplicationController private + def sort_value_for_mode + return params[:sort] if params[:sort].present? + + 'stale' == @mode ? sort_value_oldest_updated : sort_value_recently_updated + end + # It can be expensive to calculate the diverging counts for each # branch. Normally the frontend should be specifying a set of branch # names, but prior to @@ -173,19 +175,32 @@ class Projects::BranchesController < Projects::ApplicationController end def fetch_branches_by_mode - if @mode == 'overview' - # overview mode - @active_branches, @stale_branches = BranchesFinder.new(@repository, sort: sort_value_recently_updated).execute.partition(&:active?) - # Here we get one more branch to indicate if there are more data we're not showing - @active_branches = @active_branches.first(@overview_max_branches + 1) - @stale_branches = @stale_branches.first(@overview_max_branches + 1) - @branches = @active_branches + @stale_branches + return fetch_branches_for_overview if @mode == 'overview' + + # active/stale/all view mode + @branches = BranchesFinder.new(@repository, params.merge(sort: @sort)).execute + @branches = @branches.select { |b| b.state.to_s == @mode } if %w[active stale].include?(@mode) + @branches = Kaminari.paginate_array(@branches).page(params[:page]) + end + + def fetch_branches_for_overview + # Here we get one more branch to indicate if there are more data we're not showing + limit = @overview_max_branches + 1 + + if Feature.enabled?(:branch_list_keyset_pagination, project, default_enabled: true) + @active_branches = + BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_recently_updated }) + .execute(gitaly_pagination: true).select(&:active?) + @stale_branches = + BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_oldest_updated }) + .execute(gitaly_pagination: true).select(&:stale?) else - # active/stale/all view mode - @branches = BranchesFinder.new(@repository, params.merge(sort: @sort)).execute - @branches = @branches.select { |b| b.state.to_s == @mode } if %w[active stale].include?(@mode) - @branches = Kaminari.paginate_array(@branches).page(params[:page]) + @active_branches, @stale_branches = BranchesFinder.new(@repository, sort: sort_value_recently_updated).execute.partition(&:active?) + @active_branches = @active_branches.first(limit) + @stale_branches = @stale_branches.first(limit) end + + @branches = @active_branches + @stale_branches end def confidential_issue_project diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index c2428270fa6..cc391868df0 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -2,6 +2,9 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController before_action :check_can_collaborate! + before_action do + push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: false) + end feature_category :pipeline_authoring diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 1ddc9d567e0..ab1cf63c885 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -17,8 +17,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController def show @cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_project_params)) - @cycle_analytics_no_data = @cycle_analytics.no_stats? - respond_to do |format| format.html do Gitlab::UsageDataCounters::CycleAnalyticsCounter.count(:views) diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb index 9142f769b28..da9dcd1c09c 100644 --- a/app/controllers/projects/feature_flags_controller.rb +++ b/app/controllers/projects/feature_flags_controller.rb @@ -14,7 +14,6 @@ class Projects::FeatureFlagsController < Projects::ApplicationController before_action do push_frontend_feature_flag(:feature_flag_permissions) - push_frontend_feature_flag(:feature_flags_new_version, project, default_enabled: true) push_frontend_feature_flag(:feature_flags_legacy_read_only, project, default_enabled: true) push_frontend_feature_flag(:feature_flags_legacy_read_only_override, project) end @@ -101,15 +100,7 @@ class Projects::FeatureFlagsController < Projects::ApplicationController protected def feature_flag - @feature_flag ||= @noteable = if new_version_feature_flags_enabled? - project.operations_feature_flags.find_by_iid!(params[:iid]) - else - project.operations_feature_flags.legacy_flag.find_by_iid!(params[:iid]) - end - end - - def new_version_feature_flags_enabled? - ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true) + @feature_flag ||= @noteable = project.operations_feature_flags.find_by_iid!(params[:iid]) end def ensure_legacy_flags_writable! diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 3a1b4f380a2..3a0e40f9745 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -44,14 +44,14 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:vue_issuable_sidebar, project.group) push_frontend_feature_flag(:tribute_autocomplete, @project) push_frontend_feature_flag(:vue_issuables_list, project) - push_frontend_feature_flag(:vue_issue_header, @project, default_enabled: true) + push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true) end before_action only: :show do real_time_feature_flag = :real_time_issue_sidebar real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(real_time_feature_flag, @project) - push_to_gon_features(real_time_feature_flag, real_time_enabled) + push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled) record_experiment_user(:invite_members_version_a) record_experiment_user(:invite_members_version_b) @@ -59,6 +59,10 @@ class Projects::IssuesController < Projects::ApplicationController around_action :allow_gitaly_ref_name_caching, only: [:discussions] + before_action :run_null_hypothesis_experiment, + only: [:index, :new, :create], + if: -> { Feature.enabled?(:gitlab_experiments) } + respond_to :html alias_method :designs, :show @@ -74,6 +78,8 @@ class Projects::IssuesController < Projects::ApplicationController feature_category :service_desk, [:service_desk] feature_category :importers, [:import_csv, :export_csv] + attr_accessor :vulnerability_id + def index @issues = @issuables @@ -125,6 +131,8 @@ class Projects::IssuesController < Projects::ApplicationController service = ::Issues::CreateService.new(project, current_user, create_params) @issue = service.execute + create_vulnerability_issue_link(issue) + if service.discussions_to_resolve.count(&:resolved?) > 0 flash[:notice] = if service.discussion_to_resolve_id _("Resolved 1 discussion.") @@ -385,6 +393,17 @@ class Projects::IssuesController < Projects::ApplicationController def service_desk? action_name == 'service_desk' end + + def run_null_hypothesis_experiment + experiment(:null_hypothesis, project: project) do |e| + e.use { } # define the control + e.try { } # define the candidate + e.track(action_name) # track the action so we can build a funnel + end + end + + # Overridden in EE + def create_vulnerability_issue_link(issue); end end Projects::IssuesController.prepend_if_ee('EE::Projects::IssuesController') diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 07e38c80291..900ebc61856 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -6,6 +6,7 @@ class Projects::JobsController < Projects::ApplicationController before_action :find_job_as_build, except: [:index, :play] before_action :find_job_as_processable, only: [:play] + before_action :authorize_read_build_trace!, only: [:trace, :raw] before_action :authorize_read_build! before_action :authorize_update_build!, except: [:index, :show, :status, :raw, :trace, :erase] @@ -14,8 +15,8 @@ class Projects::JobsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize before_action :verify_proxy_request!, only: :proxy_websocket_authorize - before_action do - push_frontend_feature_flag(:ci_job_line_links, @project) + before_action only: :index do + frontend_experimentation_tracking_data(:jobs_empty_state, 'click_button') end layout 'project' @@ -157,6 +158,18 @@ class Projects::JobsController < Projects::ApplicationController private + def authorize_read_build_trace! + return if can?(current_user, :read_build_trace, @build) + + msg = _( + "You must have developer or higher permissions in the associated project to view job logs when debug trace is enabled. To disable debug trace, set the 'CI_DEBUG_TRACE' variable to 'false' in your pipeline configuration or CI/CD settings. " \ + "If you need to view this job log, a project maintainer must add you to the project with developer permissions or higher." + ) + return access_denied!(msg) if @build.debug_mode? + + access_denied!(_('The current user is not authorized to access the job log.')) + end + def authorize_update_build! return access_denied! unless can?(current_user, :update_build, @build) end @@ -204,11 +217,7 @@ class Projects::JobsController < Projects::ApplicationController end def find_job_as_processable - if ::Gitlab::Ci::Features.manual_bridges_enabled?(project) - @build = project.processables.find(params[:id]) - else - find_job_as_build - end + @build = project.processables.find(params[:id]) end def build_path(build) diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 3e077c1af37..7d3e7759081 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -11,6 +11,12 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path] before_action :build_merge_request, except: [:create] + before_action do + push_frontend_feature_flag(:merge_request_reviewers, @project, default_enabled: true) + push_frontend_feature_flag(:mr_collapsed_approval_rules, @project) + push_frontend_feature_flag(:reviewer_approval_rules, @project) + end + def new define_new_vars end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 7fbeac12644..da19ddf6105 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -69,7 +69,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic } options = additional_attributes.merge( - diff_view: unified_diff_lines_view_type(@merge_request.project), + diff_view: "inline", merge_ref_head_diff: render_merge_ref_head_diff? ) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index f2b41294a85..382fbfaac25 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -21,13 +21,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo :exposed_artifacts, :coverage_reports, :terraform_reports, - :accessibility_reports + :accessibility_reports, + :codequality_reports ] before_action :set_issuables_index, only: [:index] before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] before_action only: [:show] do - push_frontend_feature_flag(:suggest_pipeline, default_enabled: true) push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true) push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true) push_frontend_feature_flag(:multiline_comments, @project, default_enabled: true) @@ -36,13 +36,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true) push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true) push_frontend_feature_flag(:merge_request_widget_graphql, @project) - push_frontend_feature_flag(:unified_diff_lines, @project, default_enabled: true) push_frontend_feature_flag(:unified_diff_components, @project) - push_frontend_feature_flag(:highlight_current_diff_row, @project) push_frontend_feature_flag(:default_merge_ref_for_diffs, @project) push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true) + push_frontend_feature_flag(:core_security_mr_widget_counts, @project) + push_frontend_feature_flag(:core_security_mr_widget_downloads, @project, default_enabled: true) push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true) push_frontend_feature_flag(:test_failure_history, @project) + push_frontend_feature_flag(:diffs_gradual_load, @project, default_enabled: true) record_experiment_user(:invite_members_version_a) record_experiment_user(:invite_members_version_b) @@ -50,6 +51,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action do push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) + push_frontend_feature_flag(:merge_request_reviewers, @project, default_enabled: true) + push_frontend_feature_flag(:mr_collapsed_approval_rules, @project) + push_frontend_feature_flag(:reviewer_approval_rules, @project) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] @@ -98,9 +102,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @noteable = @merge_request @commits_count = @merge_request.commits_count + @merge_request.context_commits_count @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') - @current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json + @current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestCurrentUserEntity).to_json @show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs - @file_by_file_default = Feature.enabled?(:view_diffs_file_by_file, default_enabled: true) && current_user&.view_diffs_file_by_file + @file_by_file_default = current_user&.view_diffs_file_by_file @coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json) if @merge_request.has_coverage_reports? @endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request) @@ -193,6 +197,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end + def codequality_reports + reports_response(@merge_request.compare_codequality_reports) + end + def terraform_reports reports_response(@merge_request.find_terraform_reports) end @@ -481,7 +489,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def endpoint_metadata_url(project, merge_request) params = request.query_parameters - params[:view] = unified_diff_lines_view_type(project) + params[:view] = "inline" if Feature.enabled?(:default_merge_ref_for_diffs, project) params = params.merge(diff_head: true) diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 31189c888b7..dcd3c49441e 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -6,9 +6,6 @@ class Projects::MilestonesController < Projects::ApplicationController before_action :check_issuables_available! before_action :milestone, only: [:edit, :update, :destroy, :show, :issues, :merge_requests, :participants, :labels, :promote] - before_action do - push_frontend_feature_flag(:burnup_charts, @project, default_enabled: true) - end # Allow read any milestone before_action :authorize_read_milestone! diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index f71a92ee874..74513da8675 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -17,7 +17,8 @@ class Projects::PipelinesController < Projects::ApplicationController push_frontend_feature_flag(:new_pipeline_form, project, default_enabled: true) push_frontend_feature_flag(:graphql_pipeline_header, project, type: :development, default_enabled: false) push_frontend_feature_flag(:graphql_pipeline_details, project, type: :development, default_enabled: false) - push_frontend_feature_flag(:new_pipeline_form_prefilled_vars, project, type: :development) + push_frontend_feature_flag(:graphql_pipeline_analytics, project, type: :development) + push_frontend_feature_flag(:new_pipeline_form_prefilled_vars, project, type: :development, default_enabled: true) end before_action :ensure_pipeline, only: [:show] @@ -39,7 +40,7 @@ class Projects::PipelinesController < Projects::ApplicationController .new(project, current_user, index_params) .execute .page(params[:page]) - .per(30) + .per(20) @pipelines_count = limited_pipelines_count(project) @@ -185,12 +186,15 @@ class Projects::PipelinesController < Projects::ApplicationController def charts @charts = {} + @counts = {} + + return if Feature.enabled?(:graphql_pipeline_analytics) + @charts[:week] = Gitlab::Ci::Charts::WeekChart.new(project) @charts[:month] = Gitlab::Ci::Charts::MonthChart.new(project) @charts[:year] = Gitlab::Ci::Charts::YearChart.new(project) @charts[:pipeline_times] = Gitlab::Ci::Charts::PipelineTime.new(project) - @counts = {} @counts[:total] = @project.all_pipelines.count(:all) @counts[:success] = @project.all_pipelines.success.count(:all) @counts[:failed] = @project.all_pipelines.failed.count(:all) @@ -214,7 +218,9 @@ class Projects::PipelinesController < Projects::ApplicationController def config_variables respond_to do |format| format.json do - render json: Ci::ListConfigVariablesService.new(@project, current_user).execute(params[:sha]) + result = Ci::ListConfigVariablesService.new(@project, current_user).execute(params[:sha]) + + result.nil? ? head(:no_content) : render(json: result) end end end diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb index 2892542e63c..19c908026cf 100644 --- a/app/controllers/projects/prometheus/alerts_controller.rb +++ b/app/controllers/projects/prometheus/alerts_controller.rb @@ -16,7 +16,7 @@ module Projects before_action :authorize_read_prometheus_alerts!, except: [:notify] before_action :alert, only: [:update, :show, :destroy, :metrics_dashboard] - feature_category :alert_management + feature_category :incident_management def index render json: serialize_as_json(alerts) @@ -73,7 +73,7 @@ module Projects def notify_service Projects::Prometheus::Alerts::NotifyService - .new(project, current_user, params.permit!) + .new(project, params.permit!) end def create_service diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index d8ba7e4f235..8be7af3e2c5 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -10,29 +10,31 @@ class Projects::RawController < Projects::ApplicationController prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:blob) } + before_action :set_ref_and_path before_action :require_non_empty_project before_action :authorize_download_code! before_action :show_rate_limit, only: [:show], unless: :external_storage_request? - before_action :assign_ref_vars before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled? feature_category :source_code_management def show - @blob = @repository.blob_at(@commit.id, @path) + @blob = @repository.blob_at(@ref, @path) send_blob(@repository, @blob, inline: (params[:inline] != 'false'), allow_caching: @project.public?) end private - def show_rate_limit + def set_ref_and_path # This bypasses assign_ref_vars to avoid a Gitaly FindCommit lookup. - # When rate limiting, we really don't care if a different commit is - # being requested. - _ref, path = extract_ref(get_id) + # We don't need to find the commit to either rate limit or send the + # blob. + @ref, @path = extract_ref(get_id) + end - if rate_limiter.throttled?(:show_raw_controller, scope: [@project, path], threshold: raw_blob_request_limit) + def show_rate_limit + if rate_limiter.throttled?(:show_raw_controller, scope: [@project, @path], threshold: raw_blob_request_limit) rate_limiter.log_request(request, :raw_blob_request_limit, current_user) render plain: _('You cannot access the raw file. Please wait a minute.'), status: :too_many_requests diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index 24fa0894a9c..b7a5a63e642 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -53,12 +53,23 @@ class Projects::RunnersController < Projects::ApplicationController def toggle_shared_runners if !project.shared_runners_enabled && project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable' - return redirect_to project_runners_path(@project), alert: _("Cannot enable shared runners because parent group does not allow it") + + if Feature.enabled?(:vueify_shared_runners_toggle, @project) + render json: { error: _('Cannot enable shared runners because parent group does not allow it') }, status: :unauthorized + else + redirect_to project_runners_path(@project), alert: _('Cannot enable shared runners because parent group does not allow it') + end + + return end project.toggle!(:shared_runners_enabled) - redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings') + if Feature.enabled?(:vueify_shared_runners_toggle, @project) + render json: {}, status: :ok + else + redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings') + end end def toggle_group_runners diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index f76278a12a4..31533dfeea0 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -11,6 +11,7 @@ module Projects before_action :define_variables before_action do push_frontend_feature_flag(:ajax_new_deploy_token, @project) + push_frontend_feature_flag(:vueify_shared_runners_toggle, @project) end helper_method :highlight_badge diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index c9386a2edec..f8155b77e60 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -7,7 +7,6 @@ module Projects before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token] before_action do - push_frontend_feature_flag(:http_integrations_list, @project) push_frontend_feature_flag(:multiple_http_integrations_custom_mapping, @project) end diff --git a/app/controllers/projects/static_site_editor_controller.rb b/app/controllers/projects/static_site_editor_controller.rb index 5c3d9b60877..0d9a6f568a1 100644 --- a/app/controllers/projects/static_site_editor_controller.rb +++ b/app/controllers/projects/static_site_editor_controller.rb @@ -19,6 +19,10 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController feature_category :static_site_editor + def index + render_404 + end + def show service_response = ::StaticSiteEditor::ConfigService.new( container: project, diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 8f794512486..d1486f765e4 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -6,7 +6,4 @@ class Projects::WikisController < Projects::ApplicationController alias_method :container, :project feature_category :wiki - - def git_access - end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index c03a820b384..3744517934a 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -19,9 +19,6 @@ class ProjectsController < Projects::ApplicationController before_action :redirect_git_extension, only: [:show] before_action :project, except: [:index, :new, :create, :resolve] before_action :repository, except: [:index, :new, :create, :resolve] - before_action :assign_ref_vars, if: -> { action_name == 'show' && repo_exists? } - before_action :tree, - if: -> { action_name == 'show' && repo_exists? && project_view_files? } before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] before_action :present_project, only: [:edit] before_action :authorize_download_code!, only: [:refs] @@ -34,15 +31,9 @@ class ProjectsController < Projects::ApplicationController # Project Export Rate Limit before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export] - # Experiments - before_action only: [:new, :create] do - frontend_experimentation_tracking_data(:new_create_project_ui, 'click_tab') - push_frontend_experiment(:new_create_project_ui) - end - before_action only: [:edit] do - push_frontend_feature_flag(:service_desk_custom_address, @project) push_frontend_feature_flag(:approval_suggestions, @project, default_enabled: true) + push_frontend_feature_flag(:allow_editing_commit_messages, @project) end layout :determine_layout @@ -80,8 +71,6 @@ class ProjectsController < Projects::ApplicationController @project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute if @project.saved? - cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.zone.at(0) } - redirect_to( project_path(@project, custom_import_params), notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name } @@ -147,6 +136,8 @@ class ProjectsController < Projects::ApplicationController end def show + @id, @ref, @path = extract_ref_path + if @project.import_in_progress? redirect_to project_import_path(@project, custom_import_params) return @@ -334,7 +325,11 @@ class ProjectsController < Projects::ApplicationController if can?(current_user, :download_code, @project) return render 'projects/no_repo' unless @project.repository_exists? - render 'projects/empty' if @project.empty_repo? + if @project.empty_repo? + record_experiment_user(:invite_members_empty_project_version_a) + + render 'projects/empty' + end else if can?(current_user, :read_wiki, @project) @wiki = @project.wiki @@ -392,6 +387,8 @@ class ProjectsController < Projects::ApplicationController wiki_access_level pages_access_level metrics_dashboard_access_level + analytics_access_level + operations_access_level ] end @@ -435,6 +432,7 @@ class ProjectsController < Projects::ApplicationController project_setting_attributes: %i[ show_default_award_emojis squash_option + allow_editing_commit_messages ] ] + [project_feature_attributes: project_feature_attributes] end diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index 5b3f78a92ad..4a6fef56ef5 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -45,7 +45,7 @@ module Registrations end def update_params - params.require(:user).permit(:role, :setup_for_company) + params.require(:user).permit(:role, :other_role, :setup_for_company) end def requires_confirmation?(user) diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 04cb9616cf6..e7872eeac27 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -6,8 +6,6 @@ class RegistrationsController < Devise::RegistrationsController include RecaptchaHelper include InvisibleCaptchaOnSignup - BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'.freeze - layout 'devise' prepend_before_action :check_captcha, only: :create @@ -167,12 +165,18 @@ class RegistrationsController < Devise::RegistrationsController end def set_user_state - return unless Gitlab::CurrentSettings.require_admin_approval_after_user_signup + return unless set_blocked_pending_approval? + + resource.state = User::BLOCKED_PENDING_APPROVAL_STATE + end - resource.state = BLOCKED_PENDING_APPROVAL_STATE + def set_blocked_pending_approval? + Gitlab::CurrentSettings.require_admin_approval_after_user_signup end def set_invite_params @invite_email = ActionController::Base.helpers.sanitize(params[:invite_email]) end end + +RegistrationsController.prepend_if_ee('EE::RegistrationsController') diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb index ec854bd0dde..a5b81054ee4 100644 --- a/app/controllers/repositories/git_http_client_controller.rb +++ b/app/controllers/repositories/git_http_client_controller.rb @@ -87,8 +87,12 @@ module Repositories @project end + def repository_path + @repository_path ||= params[:repository_path] + end + def parse_repo_path - @container, @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:repository_id]}") + @container, @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse(repository_path) end def render_missing_personal_access_token diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index aa6609bef2a..3cf0a23b7f6 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -80,6 +80,8 @@ module Repositories return if Gitlab::Database.read_only? return unless repo_type.project? + OnboardingProgressService.new(project.namespace).execute(action: :git_read) + if Feature.enabled?(:project_statistics_sync, project, default_enabled: true) Projects::FetchStatisticsIncrementService.new(project).execute else @@ -90,7 +92,6 @@ module Repositories def access @access ||= access_klass.new(access_actor, container, 'http', authentication_abilities: authentication_abilities, - namespace_path: params[:namespace_id], repository_path: repository_path, redirected_path: redirected_path, auth_result_type: auth_result_type) @@ -113,10 +114,6 @@ module Repositories @access_klass ||= repo_type.access_checker_class end - def repository_path - @repository_path ||= params[:repository_id].sub(/\.git$/, '') - end - def log_user_activity Users::ActivityService.new(user).execute end diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb index 96185608c09..248323a0bb5 100644 --- a/app/controllers/repositories/lfs_api_controller.rb +++ b/app/controllers/repositories/lfs_api_controller.rb @@ -92,16 +92,26 @@ module Repositories { upload: { href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}", - header: { - Authorization: authorization_header, - # git-lfs v2.5.0 sets the Content-Type based on the uploaded file. This - # ensures that Workhorse can intercept the request. - 'Content-Type': LFS_TRANSFER_CONTENT_TYPE - }.compact + header: upload_headers } } end + def upload_headers + headers = { + Authorization: authorization_header, + # git-lfs v2.5.0 sets the Content-Type based on the uploaded file. This + # ensures that Workhorse can intercept the request. + 'Content-Type': LFS_TRANSFER_CONTENT_TYPE + } + + if Feature.enabled?(:lfs_chunked_encoding, project, default_enabled: true) + headers['Transfer-Encoding'] = 'chunked' + end + + headers + end + def lfs_check_batch_operation! if batch_operation_disallowed? render( diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index c92b3457640..196b1887ca7 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -3,16 +3,8 @@ class SearchController < ApplicationController include ControllerWithCrossProjectAccessCheck include SearchHelper - include RendersCommits include RedisTracking - SCOPE_PRELOAD_METHOD = { - projects: :with_web_entity_associations, - issues: :with_web_entity_associations, - merge_requests: :with_web_entity_associations, - epics: :with_web_entity_associations - }.freeze - track_redis_hll_event :show, name: 'i_search_total', feature: :search_track_unique_users, feature_default_enabled: true around_action :allow_gitaly_ref_name_caching @@ -41,14 +33,12 @@ class SearchController < ApplicationController @search_term = params[:search] @sort = params[:sort] || default_sort - @scope = search_service.scope - @show_snippets = search_service.show_snippets? - @search_results = search_service.search_results - @search_objects = search_service.search_objects(preload_method) - @search_highlight = search_service.search_highlight - - render_commits if @scope == 'commits' - eager_load_user_status if @scope == 'users' + @search_service = Gitlab::View::Presenter::Factory.new(search_service, current_user: current_user).fabricate! + @scope = @search_service.scope + @show_snippets = @search_service.show_snippets? + @search_results = @search_service.search_results + @search_objects = @search_service.search_objects + @search_highlight = @search_service.search_highlight increment_search_counters end @@ -79,10 +69,6 @@ class SearchController < ApplicationController private - def preload_method - SCOPE_PRELOAD_METHOD[@scope.to_sym] - end - # overridden in EE def default_sort 'created_desc' @@ -102,14 +88,6 @@ class SearchController < ApplicationController true end - def render_commits - @search_objects = prepare_commits_for_rendering(@search_objects) - end - - def eager_load_user_status - @search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord - end - def check_single_commit_result? return false if params[:force_search_results] return false unless @project.present? diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 6692c285335..2c827292928 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -27,6 +27,10 @@ class UploadsController < ApplicationController feature_category :not_owned + def self.model_classes + MODEL_CLASSES + end + def uploader_class PersonalFileUploader end @@ -99,7 +103,7 @@ class UploadsController < ApplicationController end def upload_model_class - MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError) + self.class.model_classes[params[:model]] || raise(UnknownUploadModelError) end def upload_model_class_has_mounts? @@ -112,3 +116,5 @@ class UploadsController < ApplicationController upload_model_class.uploader_options.has_key?(upload_mount) end end + +UploadsController.prepend_if_ee('EE::UploadsController') diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 05573255066..46245286820 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -33,18 +33,36 @@ class UsersController < ApplicationController end format.json do + # In 13.8, this endpoint will be removed: + # https://gitlab.com/gitlab-org/gitlab/-/issues/289972 load_events pager_json("events/_events", @events.count, events: @events) end end end + # Get all keys of a user(params[:username]) in a text format + # Helpful for sysadmins to put in respective servers + def ssh_keys + render plain: user.all_ssh_keys.join("\n") + end + def activity respond_to do |format| format.html { render 'show' } + + format.json do + load_events + pager_json("events/_events", @events.count, events: @events) + end end end + # Get all gpg keys of a user(params[:username]) in a text format + def gpg_keys + render plain: user.gpg_keys.select(&:verified?).map(&:key).join("\n") + end + def groups load_groups diff --git a/app/controllers/whats_new_controller.rb b/app/controllers/whats_new_controller.rb index 384c984089a..cba86c65848 100644 --- a/app/controllers/whats_new_controller.rb +++ b/app/controllers/whats_new_controller.rb @@ -1,18 +1,19 @@ # frozen_string_literal: true class WhatsNewController < ApplicationController - include Gitlab::WhatsNew + include Gitlab::Utils::StrongMemoize skip_before_action :authenticate_user! - before_action :check_feature_flag, :check_valid_page_param, :set_pagination_headers + before_action :check_feature_flag + before_action :check_valid_page_param, :set_pagination_headers, unless: -> { has_version_param? } feature_category :navigation def index respond_to do |format| format.js do - render json: whats_new_release_items(page: current_page) + render json: highlight_items end end end @@ -27,18 +28,29 @@ class WhatsNewController < ApplicationController render_404 if current_page < 1 end - def set_pagination_headers - response.set_header('X-Next-Page', next_page) - end - def current_page params[:page]&.to_i || 1 end - def next_page - next_page = current_page + 1 - next_index = next_page - 1 + def highlights + strong_memoize(:highlights) do + if has_version_param? + ReleaseHighlight.for_version(version: params[:version]) + else + ReleaseHighlight.paginated(page: current_page) + end + end + end + + def highlight_items + highlights.map {|item| Gitlab::WhatsNew::ItemPresenter.present(item) } + end + + def set_pagination_headers + response.set_header('X-Next-Page', highlights.next_page) + end - next_page if whats_new_file_paths[next_index] + def has_version_param? + params[:version].present? end end diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb new file mode 100644 index 00000000000..e8f7d22bf77 --- /dev/null +++ b/app/experiments/application_experiment.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +class ApplicationExperiment < Gitlab::Experiment + def publish(_result) + track(:assignment) # track that we've assigned a variant for this context + Gon.global.push({ experiment: { name => signature } }, true) # push to client + end + + def track(action, **event_args) + return if excluded? # no events for opted out actors or excluded subjects + + Gitlab::Tracking.event(name, action.to_s, **event_args.merge( + context: (event_args[:context] || []) << SnowplowTracker::SelfDescribingJson.new( + 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-3-0', signature + ) + )) + end + + private + + def resolve_variant_name + variant_names.first if Feature.enabled?(name, self, type: :experiment) + end + + # Cache is an implementation on top of Gitlab::Redis::SharedState that also + # adheres to the ActiveSupport::Cache::Store interface and uses the redis + # hash data type. + # + # Since Gitlab::Experiment can use any type of caching layer, utilizing the + # long lived shared state interface here gives us an efficient way to store + # context keys and the variant they've been assigned -- while also giving us + # a simple way to clean up an experiments data upon resolution. + # + # The data structure: + # key: experiment.name + # fields: context key => variant name + # + # The keys are expected to be `experiment_name:context_key`, which is the + # default cache key strategy. So running `cache.fetch("foo:bar", "value")` + # would create/update a hash with the key of "foo", with a field named + # "bar" that has "value" assigned to it. + class Cache < ActiveSupport::Cache::Store + # Clears the entire cache for a given experiment. Be careful with this + # since it would reset all resolved variants for the entire experiment. + def clear(key:) + key = hkey(key)[0] # extract only the first part of the key + pool do |redis| + case redis.type(key) + when 'hash', 'none' then redis.del(key) + else raise ArgumentError, 'invalid call to clear a non-hash cache key' + end + end + end + + private + + def pool + raise ArgumentError, 'missing block' unless block_given? + + Gitlab::Redis::SharedState.with { |redis| yield redis } + end + + def hkey(key) + key.split(':') # this assumes the default strategy in gitlab-experiment + end + + def read_entry(key, **options) + value = pool { |redis| redis.hget(*hkey(key)) } + value.nil? ? nil : ActiveSupport::Cache::Entry.new(value) + end + + def write_entry(key, entry, **options) + return false unless Feature.enabled?(:caching_experiments) + return false if entry.value.blank? # don't cache any empty values + + pool { |redis| redis.hset(*hkey(key), entry.value) } + end + + def delete_entry(key, **options) + pool { |redis| redis.hdel(*hkey(key)) } + end + end +end diff --git a/app/finders/alert_management/alerts_finder.rb b/app/finders/alert_management/alerts_finder.rb index 1d6f790af31..be3b329fb6f 100644 --- a/app/finders/alert_management/alerts_finder.rb +++ b/app/finders/alert_management/alerts_finder.rb @@ -18,6 +18,7 @@ module AlertManagement return AlertManagement::Alert.none unless authorized? collection = project.alert_management_alerts + collection = by_domain(collection) collection = by_status(collection) collection = by_iid(collection) collection = by_assignee(collection) @@ -30,6 +31,10 @@ module AlertManagement attr_reader :current_user, :project, :params + def by_domain(collection) + collection.with_operations_alerts + end + def by_iid(collection) return collection unless params[:iid] @@ -59,3 +64,5 @@ module AlertManagement end end end + +AlertManagement::AlertsFinder.prepend_if_ee('EE::AlertManagement::AlertsFinder') diff --git a/app/finders/ci/daily_build_group_report_results_finder.rb b/app/finders/ci/daily_build_group_report_results_finder.rb index ec41d9d2c45..ef97ccb4c0f 100644 --- a/app/finders/ci/daily_build_group_report_results_finder.rb +++ b/app/finders/ci/daily_build_group_report_results_finder.rb @@ -4,7 +4,7 @@ module Ci class DailyBuildGroupReportResultsFinder include Gitlab::Allowable - def initialize(current_user:, project:, ref_path:, start_date:, end_date:, limit: nil) + def initialize(current_user:, project:, ref_path: nil, start_date:, end_date:, limit: nil) @current_user = current_user @project = project @ref_path = ref_path @@ -35,11 +35,18 @@ module Ci end def query_params - { + params = { project_id: project, - ref_path: ref_path, date: start_date..end_date } + + if ref_path.present? + params[:ref_path] = ref_path + else + params[:default_branch] = true + end + + params end def none diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb index 7347a83d294..a77faebb160 100644 --- a/app/finders/ci/pipelines_finder.rb +++ b/app/finders/ci/pipelines_finder.rb @@ -18,7 +18,9 @@ module Ci return Ci::Pipeline.none end - items = pipelines.no_child + items = pipelines + items = items.no_child unless params[:iids].present? + items = by_iids(items) items = by_scope(items) items = by_status(items) items = by_ref(items) @@ -52,6 +54,14 @@ module Ci project.repository.tag_names end + def by_iids(items) + if params[:iids].present? + items.for_iid(params[:iids]) + else + items + end + end + def by_scope(items) case params[:scope] when 'running' diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb index 93d139652b9..da8dfc2579a 100644 --- a/app/finders/ci/pipelines_for_merge_request_finder.rb +++ b/app/finders/ci/pipelines_for_merge_request_finder.rb @@ -5,8 +5,6 @@ module Ci class PipelinesForMergeRequestFinder include Gitlab::Utils::StrongMemoize - EVENT = 'merge_request_event' - def initialize(merge_request, current_user) @merge_request = merge_request @current_user = current_user @@ -36,7 +34,11 @@ module Ci pipelines = if merge_request.persisted? - pipelines_using_cte + if Feature.enabled?(:ci_pipelines_for_merge_request_finder_new_cte, target_project) + pipelines_using_cte + else + pipelines_using_legacy_cte + end else triggered_for_branch.for_sha(commit_shas) end @@ -47,7 +49,7 @@ module Ci private - def pipelines_using_cte + def pipelines_using_legacy_cte cte = Gitlab::SQL::CTE.new(:shas, merge_request.all_commits.select(:sha)) source_sha_join = cte.table[:sha].eq(Ci::Pipeline.arel_table[:source_sha]) @@ -59,6 +61,16 @@ module Ci .from_union([merged_result_pipelines, detached_merge_request_pipelines, pipelines_for_branch]) end + def pipelines_using_cte + cte = Gitlab::SQL::CTE.new(:shas, merge_request.all_commits.select(:sha)) + + pipelines_for_merge_requests = triggered_by_merge_request + pipelines_for_branch = filter_by_sha(triggered_for_branch, cte) + + Ci::Pipeline.with(cte.to_arel) # rubocop: disable CodeReuse/ActiveRecord + .from_union([pipelines_for_merge_requests, pipelines_for_branch]) + end + def filter_by_sha(pipelines, cte) hex = Arel::Nodes::SqlLiteral.new("'hex'") string_sha = Arel::Nodes::NamedFunction.new('encode', [cte.table[:sha], hex]) @@ -84,14 +96,7 @@ module Ci end def triggered_for_branch - source_project.ci_pipelines - .where(source: branch_pipeline_sources, ref: source_branch, tag: false) # rubocop: disable CodeReuse/ActiveRecord - end - - def branch_pipeline_sources - strong_memoize(:branch_pipeline_sources) do - Ci::Pipeline.sources.reject { |source| source == EVENT }.values - end + source_project.all_pipelines.ci_branch_sources.for_branch(source_branch) end def sort(pipelines) diff --git a/app/finders/feature_flags_finder.rb b/app/finders/feature_flags_finder.rb index 9cb3bf7fa23..7b38841970d 100644 --- a/app/finders/feature_flags_finder.rb +++ b/app/finders/feature_flags_finder.rb @@ -24,11 +24,7 @@ class FeatureFlagsFinder private def feature_flags - if Feature.enabled?(:feature_flags_new_version, project, default_enabled: true) - project.operations_feature_flags - else - project.operations_feature_flags.legacy_flag - end + project.operations_feature_flags end def by_scope(items) diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 1f6829a97d6..18ccea330af 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -176,7 +176,7 @@ class GroupDescendantsFinder end def sort - params.fetch(:sort, 'created_desc') + params.fetch(:sort, 'name_asc') end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index 09283f061c0..2417b1e0771 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class GroupMembersFinder < UnionFinder + RELATIONS = %i(direct inherited descendants).freeze + DEFAULT_RELATIONS = %i(direct inherited).freeze + include CreatedAtFilter # Params can be any of the following: @@ -17,11 +20,11 @@ class GroupMembersFinder < UnionFinder @params = params end - def execute(include_relations: [:inherited, :direct]) + def execute(include_relations: DEFAULT_RELATIONS) group_members = group_members_list relations = [] - return group_members if include_relations == [:direct] + return filter_members(group_members) if include_relations == [:direct] relations << group_members if include_relations.include?(:direct) diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index d431c3e3699..922b53b514d 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -339,15 +339,6 @@ class IssuableFinder cte << items items = klass.with(cte.to_arel).from(klass.table_name) - elsif Feature.enabled?(:pg_hint_plan_for_issuables, params.project) - items = items.optimizer_hints(<<~HINTS) - BitmapScan( - issues idx_issues_on_project_id_and_created_at_and_id_and_state_id - idx_issues_on_project_id_and_due_date_and_id_and_state_id - idx_issues_on_project_id_and_updated_at_and_id_and_state_id - index_issues_on_project_id_and_iid - ) - HINTS end items.full_search(search, matched_columns: params[:in], use_minimum_char_limit: !use_cte_for_search?) diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb index 8a194f34f74..b481afee338 100644 --- a/app/finders/issuable_finder/params.rb +++ b/app/finders/issuable_finder/params.rb @@ -257,6 +257,10 @@ class IssuableFinder params.merge!(other) end + def parent + project || group + end + private def projects_public_or_visible_to_user diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index 013ed03a789..1ff2ad01b63 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class MembersFinder + RELATIONS = %i(direct inherited descendants invited_groups).freeze + DEFAULT_RELATIONS = %i(direct inherited).freeze + # Params can be any of the following: # sort: string # search: string @@ -13,7 +16,7 @@ class MembersFinder @params = params end - def execute(include_relations: [:inherited, :direct]) + def execute(include_relations: DEFAULT_RELATIONS) members = find_members(include_relations) filter_members(members) @@ -56,7 +59,7 @@ class MembersFinder def group_union_members(include_relations) [].tap do |members| members << direct_group_members(include_relations.include?(:descendants)) if group - members << project_invited_groups_members if include_relations.include?(:invited_groups_members) + members << project_invited_groups if include_relations.include?(:invited_groups) end end @@ -66,7 +69,7 @@ class MembersFinder GroupMembersFinder.new(group).execute(include_relations: requested_relations).non_invite.non_minimal_access # rubocop: disable CodeReuse/Finder end - def project_invited_groups_members + def project_invited_groups invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy .new(project.invited_groups) .base_and_ancestors diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 1f847b09752..978550aedaf 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -41,6 +41,8 @@ class MergeRequestsFinder < IssuableFinder :environment, :merged_after, :merged_before, + :reviewer_id, + :reviewer_username, :target_branch, :wip ] @@ -54,6 +56,10 @@ class MergeRequestsFinder < IssuableFinder MergeRequest end + def params_class + MergeRequestsFinder::Params + end + def filter_items(_items) items = by_commit(super) items = by_source_branch(items) @@ -62,12 +68,14 @@ class MergeRequestsFinder < IssuableFinder items = by_merged_at(items) items = by_approvals(items) items = by_deployments(items) + items = by_reviewer(items) by_source_project_id(items) end def filter_negated_items(items) items = super(items) + items = by_negated_reviewer(items) by_negated_target_branch(items) end @@ -186,6 +194,30 @@ class MergeRequestsFinder < IssuableFinder items.where_exists(deploys) end + + def by_reviewer(items) + return items unless params.reviewer_id? || params.reviewer_username? + + if params.filter_by_no_reviewer? + items.no_review_requested + elsif params.filter_by_any_reviewer? + items.review_requested + elsif params.reviewer + items.review_requested_to(params.reviewer) + else # reviewer not found + items.none + end + end + + def by_negated_reviewer(items) + return items unless not_params.reviewer_id? || not_params.reviewer_username? + + if not_params.reviewer.present? + items.no_review_requested_to(not_params.reviewer) + else # reviewer not found + items.none + end + end end MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder') diff --git a/app/finders/merge_requests_finder/params.rb b/app/finders/merge_requests_finder/params.rb new file mode 100644 index 00000000000..e44e96054d3 --- /dev/null +++ b/app/finders/merge_requests_finder/params.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class MergeRequestsFinder + class Params < IssuableFinder::Params + def filter_by_no_reviewer? + params[:reviewer_id].to_s.downcase == FILTER_NONE + end + + def filter_by_any_reviewer? + params[:reviewer_id].to_s.downcase == FILTER_ANY + end + + def reviewer + strong_memoize(:reviewer) do + if reviewer_id? + User.find_by_id(params[:reviewer_id]) + elsif reviewer_username? + User.find_by_username(params[:reviewer_username]) + else + nil + end + end + end + end +end diff --git a/app/finders/releases/evidence_pipeline_finder.rb b/app/finders/releases/evidence_pipeline_finder.rb new file mode 100644 index 00000000000..2e706087feb --- /dev/null +++ b/app/finders/releases/evidence_pipeline_finder.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Releases + class EvidencePipelineFinder + include Gitlab::Utils::StrongMemoize + + attr_reader :project, :params + + def initialize(project, params = {}) + @project = project + @params = params + end + + def execute + # TODO: remove this with the release creation moved to it's own form https://gitlab.com/gitlab-org/gitlab/-/issues/214245 + return params[:evidence_pipeline] if params[:evidence_pipeline] + + sha = existing_tag&.dereferenced_target&.sha + sha ||= repository&.commit(ref)&.sha + + return unless sha + + project.ci_pipelines.for_sha(sha).last + end + + private + + def repository + strong_memoize(:repository) do + project.repository + end + end + + def existing_tag + repository.find_tag(tag_name) + end + + def tag_name + params[:tag] + end + + def ref + params[:ref] + end + end +end diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb index 81d5ee95f06..8c6b4005cf8 100644 --- a/app/graphql/mutations/alert_management/base.rb +++ b/app/graphql/mutations/alert_management/base.rb @@ -11,7 +11,7 @@ module Mutations argument :iid, GraphQL::STRING_TYPE, required: true, - description: "The iid of the alert to mutate" + description: "The IID of the alert to mutate" field :alert, Types::AlertManagement::AlertType, diff --git a/app/graphql/mutations/alert_management/create_alert_issue.rb b/app/graphql/mutations/alert_management/create_alert_issue.rb index 2ddb94700c2..2c128e1b339 100644 --- a/app/graphql/mutations/alert_management/create_alert_issue.rb +++ b/app/graphql/mutations/alert_management/create_alert_issue.rb @@ -10,6 +10,7 @@ module Mutations result = create_alert_issue(alert, current_user) track_usage_event(:incident_management_incident_created, current_user.id) + track_usage_event(:incident_management_alert_create_incident, current_user.id) prepare_response(alert, result) end diff --git a/app/graphql/mutations/alert_management/http_integration/destroy.rb b/app/graphql/mutations/alert_management/http_integration/destroy.rb index 0f478760aab..45d4bd778da 100644 --- a/app/graphql/mutations/alert_management/http_integration/destroy.rb +++ b/app/graphql/mutations/alert_management/http_integration/destroy.rb @@ -8,7 +8,7 @@ module Mutations argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration], required: true, - description: "The id of the integration to remove" + description: "The ID of the integration to remove" def resolve(id:) integration = authorized_find!(id: id) diff --git a/app/graphql/mutations/alert_management/http_integration/reset_token.rb b/app/graphql/mutations/alert_management/http_integration/reset_token.rb index eefab156825..3938b38260e 100644 --- a/app/graphql/mutations/alert_management/http_integration/reset_token.rb +++ b/app/graphql/mutations/alert_management/http_integration/reset_token.rb @@ -8,7 +8,7 @@ module Mutations argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration], required: true, - description: "The id of the integration to mutate" + description: "The ID of the integration to mutate" def resolve(id:) integration = authorized_find!(id: id) diff --git a/app/graphql/mutations/alert_management/http_integration/update.rb b/app/graphql/mutations/alert_management/http_integration/update.rb index 309c45b04ac..98e0f7eb14f 100644 --- a/app/graphql/mutations/alert_management/http_integration/update.rb +++ b/app/graphql/mutations/alert_management/http_integration/update.rb @@ -8,7 +8,7 @@ module Mutations argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration], required: true, - description: "The id of the integration to mutate" + description: "The ID of the integration to mutate" argument :name, GraphQL::STRING_TYPE, required: false, diff --git a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb index 745ac51f6e3..effecd8364d 100644 --- a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb +++ b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb @@ -8,7 +8,7 @@ module Mutations argument :id, Types::GlobalIDType[::PrometheusService], required: true, - description: "The id of the integration to mutate" + description: "The ID of the integration to mutate" def resolve(id:) integration = authorized_find!(id: id) diff --git a/app/graphql/mutations/alert_management/prometheus_integration/update.rb b/app/graphql/mutations/alert_management/prometheus_integration/update.rb index 1f0dea119c5..46f4c23b739 100644 --- a/app/graphql/mutations/alert_management/prometheus_integration/update.rb +++ b/app/graphql/mutations/alert_management/prometheus_integration/update.rb @@ -8,7 +8,7 @@ module Mutations argument :id, Types::GlobalIDType[::PrometheusService], required: true, - description: "The id of the integration to mutate" + description: "The ID of the integration to mutate" argument :active, GraphQL::BOOLEAN_TYPE, required: false, diff --git a/app/graphql/mutations/award_emojis/add.rb b/app/graphql/mutations/award_emojis/add.rb index 856fdd5fb14..e7ee2ec4fad 100644 --- a/app/graphql/mutations/award_emojis/add.rb +++ b/app/graphql/mutations/award_emojis/add.rb @@ -8,8 +8,6 @@ module Mutations def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) - check_object_is_awardable!(awardable) - service = ::AwardEmojis::AddService.new(awardable, args[:name], current_user).execute { diff --git a/app/graphql/mutations/award_emojis/base.rb b/app/graphql/mutations/award_emojis/base.rb index df6b883529e..4bd8304c3fc 100644 --- a/app/graphql/mutations/award_emojis/base.rb +++ b/app/graphql/mutations/award_emojis/base.rb @@ -3,12 +3,16 @@ module Mutations module AwardEmojis class Base < BaseMutation + include ::Mutations::FindsByGid + + NOT_EMOJI_AWARDABLE = 'You cannot award emoji to this resource.' + authorize :award_emoji argument :awardable_id, ::Types::GlobalIDType[::Awardable], required: true, - description: 'The global id of the awardable resource' + description: 'The global ID of the awardable resource' argument :name, GraphQL::STRING_TYPE, @@ -22,20 +26,15 @@ module Mutations private + # TODO: remove this method when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 def find_object(id:) - # TODO: remove this line when the compatibility layer is removed - # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 - id = ::Types::GlobalIDType[::Awardable].coerce_isolated_input(id) - GitlabSchema.find_by_gid(id) + super(id: ::Types::GlobalIDType[::Awardable].coerce_isolated_input(id)) end - # Called by mutations methods after performing an authorization check - # of an awardable object. - def check_object_is_awardable!(object) - unless object.is_a?(Awardable) && object.emoji_awardable? - raise Gitlab::Graphql::Errors::ResourceNotAvailable, - 'Cannot award emoji to this resource' - end + def authorize!(object) + super + raise_resource_not_available_error!(NOT_EMOJI_AWARDABLE) unless object.emoji_awardable? end end end diff --git a/app/graphql/mutations/award_emojis/remove.rb b/app/graphql/mutations/award_emojis/remove.rb index c654688c6dc..a9655daeea7 100644 --- a/app/graphql/mutations/award_emojis/remove.rb +++ b/app/graphql/mutations/award_emojis/remove.rb @@ -8,8 +8,6 @@ module Mutations def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) - check_object_is_awardable!(awardable) - service = ::AwardEmojis::DestroyService.new(awardable, args[:name], current_user).execute { diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb index 679ec7a14ff..e741f972b1b 100644 --- a/app/graphql/mutations/award_emojis/toggle.rb +++ b/app/graphql/mutations/award_emojis/toggle.rb @@ -12,8 +12,6 @@ module Mutations def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) - check_object_is_awardable!(awardable) - service = ::AwardEmojis::ToggleService.new(awardable, args[:name], current_user).execute toggled_on = awardable.awarded_emoji?(args[:name], current_user) diff --git a/app/graphql/mutations/boards/common_mutation_arguments.rb b/app/graphql/mutations/boards/common_mutation_arguments.rb new file mode 100644 index 00000000000..c4f8d299318 --- /dev/null +++ b/app/graphql/mutations/boards/common_mutation_arguments.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Mutations + module Boards + module CommonMutationArguments + extend ActiveSupport::Concern + + included do + argument :name, + GraphQL::STRING_TYPE, + required: false, + description: 'The board name.' + argument :hide_backlog_list, + GraphQL::BOOLEAN_TYPE, + required: false, + description: copy_field_description(Types::BoardType, :hide_backlog_list) + argument :hide_closed_list, + GraphQL::BOOLEAN_TYPE, + required: false, + description: copy_field_description(Types::BoardType, :hide_closed_list) + end + end + end +end diff --git a/app/graphql/mutations/boards/create.rb b/app/graphql/mutations/boards/create.rb index ebbd19930ec..92bce557446 100644 --- a/app/graphql/mutations/boards/create.rb +++ b/app/graphql/mutations/boards/create.rb @@ -7,36 +7,18 @@ module Mutations graphql_name 'CreateBoard' + include Mutations::Boards::CommonMutationArguments + field :board, Types::BoardType, null: true, description: 'The board after mutation.' - argument :name, - GraphQL::STRING_TYPE, - required: false, - description: 'The board name.' - argument :assignee_id, - GraphQL::STRING_TYPE, - required: false, - description: 'The ID of the user to be assigned to the board.' - argument :milestone_id, - Types::GlobalIDType[Milestone], - required: false, - description: 'The ID of the milestone to be assigned to the board.' - argument :weight, - GraphQL::BOOLEAN_TYPE, - required: false, - description: 'The weight of the board.' - argument :label_ids, - [Types::GlobalIDType[Label]], - required: false, - description: 'The IDs of labels to be added to the board.' - authorize :admin_board def resolve(args) board_parent = authorized_resource_parent_find!(args) + response = ::Boards::CreateService.new(board_parent, current_user, args).execute { @@ -47,3 +29,5 @@ module Mutations end end end + +Mutations::Boards::Create.prepend_if_ee('::EE::Mutations::Boards::Create') diff --git a/app/graphql/mutations/boards/lists/create.rb b/app/graphql/mutations/boards/lists/create.rb index 3fe1052315f..f6df63365b2 100644 --- a/app/graphql/mutations/boards/lists/create.rb +++ b/app/graphql/mutations/boards/lists/create.rb @@ -27,30 +27,16 @@ module Mutations board = authorized_find!(id: args[:board_id]) params = create_list_params(args) - authorize_list_type_resource!(board, params) - - list = create_list(board, params) + response = create_list(board, params) { - list: list.valid? ? list : nil, - errors: errors_on_object(list) + list: response.success? ? response.payload[:list] : nil, + errors: response.errors } end private - # Overridden in EE - def authorize_list_type_resource!(board, params) - return unless params[:label_id] - - labels = ::Labels::AvailableLabelsService.new(current_user, board.resource_parent, params) - .filter_labels_ids_in_param(:label_id) - - unless labels.present? - raise Gitlab::Graphql::Errors::ArgumentError, 'Label not found!' - end - end - def create_list(board, params) create_list_service = ::Boards::Lists::CreateService.new(board.resource_parent, current_user, params) diff --git a/app/graphql/mutations/boards/update.rb b/app/graphql/mutations/boards/update.rb new file mode 100644 index 00000000000..5cb434e41fd --- /dev/null +++ b/app/graphql/mutations/boards/update.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Mutations + module Boards + class Update < ::Mutations::BaseMutation + graphql_name 'UpdateBoard' + + include Mutations::Boards::CommonMutationArguments + + argument :id, + ::Types::GlobalIDType[::Board], + required: true, + description: 'The board global ID.' + + field :board, + Types::BoardType, + null: true, + description: 'The board after mutation.' + + authorize :admin_board + + def resolve(id:, **args) + board = authorized_find!(id: id) + + ::Boards::UpdateService.new(board.resource_parent, current_user, args).execute(board) + + { + board: board, + errors: errors_on_object(board) + } + end + + def find_object(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::Board].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end +end + +Mutations::Boards::Update.prepend_if_ee('::EE::Mutations::Boards::Update') diff --git a/app/graphql/mutations/ci/base.rb b/app/graphql/mutations/ci/base.rb index aaece2a3021..0ccee5661b7 100644 --- a/app/graphql/mutations/ci/base.rb +++ b/app/graphql/mutations/ci/base.rb @@ -7,7 +7,7 @@ module Mutations argument :id, PipelineID, required: true, - description: 'The id of the pipeline to mutate' + description: 'The ID of the pipeline to mutate' private diff --git a/app/graphql/mutations/concerns/mutations/finds_by_gid.rb b/app/graphql/mutations/concerns/mutations/finds_by_gid.rb new file mode 100644 index 00000000000..157f87a413d --- /dev/null +++ b/app/graphql/mutations/concerns/mutations/finds_by_gid.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Mutations + module FindsByGid + def find_object(id:) + GitlabSchema.find_by_gid(id) + end + end +end diff --git a/app/graphql/mutations/container_repositories/destroy.rb b/app/graphql/mutations/container_repositories/destroy.rb index 8312193147f..90fba66e7b3 100644 --- a/app/graphql/mutations/container_repositories/destroy.rb +++ b/app/graphql/mutations/container_repositories/destroy.rb @@ -2,9 +2,7 @@ module Mutations module ContainerRepositories - class Destroy < Mutations::BaseMutation - include ::Mutations::PackageEventable - + class Destroy < ::Mutations::ContainerRepositories::DestroyBase graphql_name 'DestroyContainerRepository' authorize :destroy_container_image @@ -31,15 +29,6 @@ module Mutations errors: [] } end - - private - - def find_object(id:) - # TODO: remove this line when the compatibility layer is removed - # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 - id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/container_repositories/destroy_base.rb b/app/graphql/mutations/container_repositories/destroy_base.rb new file mode 100644 index 00000000000..ddaa6c52121 --- /dev/null +++ b/app/graphql/mutations/container_repositories/destroy_base.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module ContainerRepositories + class DestroyBase < Mutations::BaseMutation + include ::Mutations::PackageEventable + + private + + def find_object(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end +end diff --git a/app/graphql/mutations/container_repositories/destroy_tags.rb b/app/graphql/mutations/container_repositories/destroy_tags.rb new file mode 100644 index 00000000000..ca6a67867c3 --- /dev/null +++ b/app/graphql/mutations/container_repositories/destroy_tags.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Mutations + module ContainerRepositories + class DestroyTags < ::Mutations::ContainerRepositories::DestroyBase + LIMIT = 20.freeze + + TOO_MANY_TAGS_ERROR_MESSAGE = "Number of tags is greater than #{LIMIT}" + + graphql_name 'DestroyContainerRepositoryTags' + + authorize :destroy_container_image + + argument :id, + ::Types::GlobalIDType[::ContainerRepository], + required: true, + description: 'ID of the container repository.' + + argument :tag_names, + [GraphQL::STRING_TYPE], + required: true, + description: "Container repository tag(s) to delete. Total number can't be greater than #{LIMIT}", + prepare: ->(tag_names, _) do + raise Gitlab::Graphql::Errors::ArgumentError, TOO_MANY_TAGS_ERROR_MESSAGE if tag_names.size > LIMIT + + tag_names + end + + field :deleted_tag_names, + [GraphQL::STRING_TYPE], + description: 'Deleted container repository tags', + null: false + + def resolve(id:, tag_names:) + container_repository = authorized_find!(id: id) + + result = ::Projects::ContainerRepository::DeleteTagsService + .new(container_repository.project, current_user, tags: tag_names) + .execute(container_repository) + + track_event(:delete_tag_bulk, :tag) if result[:status] == :success + + { + errors: Array(result[:message]), + deleted_tag_names: result[:deleted] || [] + } + end + end + end +end diff --git a/app/graphql/mutations/design_management/base.rb b/app/graphql/mutations/design_management/base.rb index 918e5709b94..69fd22e46cd 100644 --- a/app/graphql/mutations/design_management/base.rb +++ b/app/graphql/mutations/design_management/base.rb @@ -11,7 +11,7 @@ module Mutations argument :iid, GraphQL::ID_TYPE, required: true, - description: "The iid of the issue to modify designs for" + description: "The IID of the issue to modify designs for" private diff --git a/app/graphql/mutations/discussions/toggle_resolve.rb b/app/graphql/mutations/discussions/toggle_resolve.rb index 4492da74706..0e3baf8d548 100644 --- a/app/graphql/mutations/discussions/toggle_resolve.rb +++ b/app/graphql/mutations/discussions/toggle_resolve.rb @@ -10,7 +10,7 @@ module Mutations argument :id, Types::GlobalIDType[Discussion], required: true, - description: 'The global id of the discussion' + description: 'The global ID of the discussion' argument :resolve, GraphQL::BOOLEAN_TYPE, diff --git a/app/graphql/mutations/environments/canary_ingress/update.rb b/app/graphql/mutations/environments/canary_ingress/update.rb new file mode 100644 index 00000000000..1798143053a --- /dev/null +++ b/app/graphql/mutations/environments/canary_ingress/update.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module Environments + module CanaryIngress + class Update < ::Mutations::BaseMutation + graphql_name 'EnvironmentsCanaryIngressUpdate' + + authorize :update_environment + + argument :id, + ::Types::GlobalIDType[::Environment], + required: true, + description: 'The global ID of the environment to update' + + argument :weight, + GraphQL::INT_TYPE, + required: true, + description: 'The weight of the Canary Ingress' + + def resolve(id:, **kwargs) + environment = authorized_find!(id: id) + + result = ::Environments::CanaryIngress::UpdateService + .new(environment.project, current_user, kwargs) + .execute_async(environment) + + { errors: Array.wrap(result[:message]) } + end + + def find_object(id:) + # TODO: remove as part of https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::Environment].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end + end +end diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb index 9b216b31f9b..d34e351b2a6 100644 --- a/app/graphql/mutations/issues/update.rb +++ b/app/graphql/mutations/issues/update.rb @@ -11,7 +11,7 @@ module Mutations required: false, description: copy_field_description(Types::IssueType, :title) - argument :milestone_id, GraphQL::ID_TYPE, + argument :milestone_id, GraphQL::ID_TYPE, # rubocop: disable Graphql/IDType required: false, description: 'The ID of the milestone to assign to the issue. On update milestone will be removed if set to null' diff --git a/app/graphql/mutations/merge_requests/base.rb b/app/graphql/mutations/merge_requests/base.rb index 96228855ace..57920259cf7 100644 --- a/app/graphql/mutations/merge_requests/base.rb +++ b/app/graphql/mutations/merge_requests/base.rb @@ -11,7 +11,7 @@ module Mutations argument :iid, GraphQL::STRING_TYPE, required: true, - description: "The iid of the merge request to mutate" + description: "The IID of the merge request to mutate" field :merge_request, Types::MergeRequestType, diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb index b064f55825f..c2ec88c68ed 100644 --- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb +++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb @@ -20,12 +20,12 @@ module Mutations argument :environment_id, ::Types::GlobalIDType[::Environment], required: false, - description: 'The global id of the environment to add an annotation to' + description: 'The global ID of the environment to add an annotation to' argument :cluster_id, ::Types::GlobalIDType[::Clusters::Cluster], required: false, - description: 'The global id of the cluster to add an annotation to' + description: 'The global ID of the cluster to add an annotation to' argument :starting_at, Types::TimeType, required: true, diff --git a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb index d6731dfcafd..5d6763d8711 100644 --- a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb +++ b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb @@ -11,7 +11,7 @@ module Mutations argument :id, ::Types::GlobalIDType[::Metrics::Dashboard::Annotation], required: true, - description: 'The global ID of the annotation to delete' + description: 'Global ID of the annotation to delete' def resolve(id:) annotation = authorized_find!(id: id) diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb index 3cfdaf84760..a1d81c62d91 100644 --- a/app/graphql/mutations/notes/create/base.rb +++ b/app/graphql/mutations/notes/create/base.rb @@ -11,7 +11,7 @@ module Mutations argument :noteable_id, ::Types::GlobalIDType[::Noteable], required: true, - description: 'The global id of the resource to add a note to' + description: 'The global ID of the resource to add a note to' argument :body, GraphQL::STRING_TYPE, diff --git a/app/graphql/mutations/notes/create/note.rb b/app/graphql/mutations/notes/create/note.rb index e97037171f7..f1cd3bddca8 100644 --- a/app/graphql/mutations/notes/create/note.rb +++ b/app/graphql/mutations/notes/create/note.rb @@ -9,7 +9,7 @@ module Mutations argument :discussion_id, ::Types::GlobalIDType[::Discussion], required: false, - description: 'The global id of the discussion this note is in reply to' + description: 'The global ID of the discussion this note is in reply to' private diff --git a/app/graphql/mutations/notes/destroy.rb b/app/graphql/mutations/notes/destroy.rb index 63e5eeb5ecf..0e6a215bf00 100644 --- a/app/graphql/mutations/notes/destroy.rb +++ b/app/graphql/mutations/notes/destroy.rb @@ -10,7 +10,7 @@ module Mutations argument :id, ::Types::GlobalIDType[::Note], required: true, - description: 'The global id of the note to destroy' + description: 'The global ID of the note to destroy' def resolve(id:) note = authorized_find!(id: id) diff --git a/app/graphql/mutations/notes/reposition_image_diff_note.rb b/app/graphql/mutations/notes/reposition_image_diff_note.rb index 0d88bcd9a30..15bfb361b13 100644 --- a/app/graphql/mutations/notes/reposition_image_diff_note.rb +++ b/app/graphql/mutations/notes/reposition_image_diff_note.rb @@ -16,7 +16,7 @@ module Mutations loads: Types::Notes::NoteType, as: :note, required: true, - description: 'The global id of the DiffNote to update' + description: 'The global ID of the DiffNote to update' argument :position, Types::Notes::UpdateDiffImagePositionInputType, diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb index 1d5738ada77..42dac20f5d3 100644 --- a/app/graphql/mutations/notes/update/base.rb +++ b/app/graphql/mutations/notes/update/base.rb @@ -11,7 +11,7 @@ module Mutations argument :id, ::Types::GlobalIDType[::Note], required: true, - description: 'The global id of the note to update' + description: 'The global ID of the note to update' def resolve(args) note = authorized_find!(id: args[:id]) diff --git a/app/graphql/mutations/releases/create.rb b/app/graphql/mutations/releases/create.rb index 57c1541c368..156cd252848 100644 --- a/app/graphql/mutations/releases/create.rb +++ b/app/graphql/mutations/releases/create.rb @@ -40,12 +40,11 @@ module Mutations authorize :create_release - def resolve(project_path:, milestones: nil, assets: nil, **scalars) + def resolve(project_path:, assets: nil, **scalars) project = authorized_find!(full_path: project_path) params = { **scalars, - milestones: milestones.presence || [], assets: assets.to_h }.with_indifferent_access diff --git a/app/graphql/mutations/releases/delete.rb b/app/graphql/mutations/releases/delete.rb new file mode 100644 index 00000000000..e887b702cce --- /dev/null +++ b/app/graphql/mutations/releases/delete.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Mutations + module Releases + class Delete < Base + graphql_name 'ReleaseDelete' + + field :release, + Types::ReleaseType, + null: true, + description: 'The deleted release.' + + argument :tag_name, GraphQL::STRING_TYPE, + required: true, as: :tag, + description: 'Name of the tag associated with the release to delete.' + + authorize :destroy_release + + def resolve(project_path:, tag:) + project = authorized_find!(full_path: project_path) + + params = { tag: tag }.with_indifferent_access + + result = ::Releases::DestroyService.new(project, current_user, params).execute + + if result[:status] == :success + { + release: result[:release], + errors: [] + } + else + { + release: nil, + errors: [result[:message]] + } + end + end + end + end +end diff --git a/app/graphql/mutations/releases/update.rb b/app/graphql/mutations/releases/update.rb new file mode 100644 index 00000000000..bf72b907679 --- /dev/null +++ b/app/graphql/mutations/releases/update.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Mutations + module Releases + class Update < Base + graphql_name 'ReleaseUpdate' + + field :release, + Types::ReleaseType, + null: true, + description: 'The release after mutation.' + + argument :tag_name, GraphQL::STRING_TYPE, + required: true, as: :tag, + description: 'Name of the tag associated with the release' + + argument :name, GraphQL::STRING_TYPE, + required: false, + description: 'Name of the release' + + argument :description, GraphQL::STRING_TYPE, + required: false, + description: 'Description (release notes) of the release' + + argument :released_at, Types::TimeType, + required: false, + description: 'The release date' + + argument :milestones, [GraphQL::STRING_TYPE], + required: false, + description: 'The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones.' + + authorize :update_release + + def ready?(**args) + if args.key?(:released_at) && args[:released_at].nil? + raise Gitlab::Graphql::Errors::ArgumentError, + 'if the releasedAt argument is provided, it cannot be null' + end + + if args.key?(:milestones) && args[:milestones].nil? + raise Gitlab::Graphql::Errors::ArgumentError, + 'if the milestones argument is provided, it cannot be null' + end + + super + end + + def resolve(project_path:, **scalars) + project = authorized_find!(full_path: project_path) + + params = scalars.with_indifferent_access + + release_result = ::Releases::UpdateService.new(project, current_user, params).execute + + if release_result[:status] == :success + { + release: release_result[:release], + errors: [] + } + else + { + release: nil, + errors: [release_result[:message]] + } + end + end + end + end +end diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index 37c0f80310c..56c3b398949 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -4,7 +4,8 @@ module Mutations module Snippets class Create < BaseMutation include SpammableMutationFields - include ResolvesProject + + authorize :create_snippet graphql_name 'CreateSnippet' @@ -37,17 +38,15 @@ module Mutations description: 'Actions to perform over the snippet repository and blobs', required: false - def resolve(args) - project_path = args.delete(:project_path) - + def resolve(project_path: nil, **args) if project_path.present? - project = find_project!(project_path: project_path) - elsif !can_create_personal_snippet? - raise_resource_not_available_error! + project = authorized_find!(project_path) + else + authorize!(:global) end service_response = ::Snippets::CreateService.new(project, - context[:current_user], + current_user, create_params(args)).execute snippet = service_response.payload[:snippet] @@ -67,20 +66,8 @@ module Mutations private - def find_project!(project_path:) - authorized_find!(full_path: project_path) - end - - def find_object(full_path:) - resolve_project(full_path: full_path) - end - - def authorized_resource?(project) - Ability.allowed?(context[:current_user], :create_snippet, project) - end - - def can_create_personal_snippet? - Ability.allowed?(context[:current_user], :create_snippet) + def find_object(full_path) + Project.find_by_full_path(full_path) end def create_params(args) diff --git a/app/graphql/mutations/snippets/destroy.rb b/app/graphql/mutations/snippets/destroy.rb index 4915d7dd77a..bee6503372d 100644 --- a/app/graphql/mutations/snippets/destroy.rb +++ b/app/graphql/mutations/snippets/destroy.rb @@ -9,7 +9,7 @@ module Mutations argument :id, ::Types::GlobalIDType[::Snippet], required: true, - description: 'The global id of the snippet to destroy' + description: 'The global ID of the snippet to destroy' def resolve(id:) snippet = authorized_find!(id: id) diff --git a/app/graphql/mutations/snippets/mark_as_spam.rb b/app/graphql/mutations/snippets/mark_as_spam.rb index d6b96c699c0..2d6fea1f5ec 100644 --- a/app/graphql/mutations/snippets/mark_as_spam.rb +++ b/app/graphql/mutations/snippets/mark_as_spam.rb @@ -7,7 +7,7 @@ module Mutations argument :id, ::Types::GlobalIDType[::Snippet], required: true, - description: 'The global id of the snippet to update' + description: 'The global ID of the snippet to update' def resolve(id:) snippet = authorized_find!(id: id) @@ -23,7 +23,7 @@ module Mutations private def mark_as_spam(snippet) - Spam::MarkAsSpamService.new(spammable: snippet).execute + Spam::MarkAsSpamService.new(target: snippet).execute end def authorized_resource?(snippet) diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb index bcaa807e4c1..6df1ad6d8b9 100644 --- a/app/graphql/mutations/snippets/update.rb +++ b/app/graphql/mutations/snippets/update.rb @@ -9,7 +9,7 @@ module Mutations argument :id, ::Types::GlobalIDType[::Snippet], required: true, - description: 'The global id of the snippet to update' + description: 'The global ID of the snippet to update' argument :title, GraphQL::STRING_TYPE, required: false, @@ -27,11 +27,11 @@ module Mutations description: 'Actions to perform over the snippet repository and blobs', required: false - def resolve(args) - snippet = authorized_find!(id: args.delete(:id)) + def resolve(id:, **args) + snippet = authorized_find!(id: id) result = ::Snippets::UpdateService.new(snippet.project, - context[:current_user], + current_user, update_params(args)).execute(snippet) snippet = result.payload[:snippet] diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb index 3d73022f266..2ae50846108 100644 --- a/app/graphql/mutations/todos/mark_done.rb +++ b/app/graphql/mutations/todos/mark_done.rb @@ -10,7 +10,7 @@ module Mutations argument :id, ::Types::GlobalIDType[::Todo], required: true, - description: 'The global id of the todo to mark as done' + description: 'The global ID of the todo to mark as done' field :todo, Types::TodoType, null: false, diff --git a/app/graphql/mutations/todos/restore.rb b/app/graphql/mutations/todos/restore.rb index 7c8f92d32f5..c532b455a16 100644 --- a/app/graphql/mutations/todos/restore.rb +++ b/app/graphql/mutations/todos/restore.rb @@ -10,7 +10,7 @@ module Mutations argument :id, ::Types::GlobalIDType[::Todo], required: true, - description: 'The global id of the todo to restore' + description: 'The global ID of the todo to restore' field :todo, Types::TodoType, null: false, diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb index 9e0a95c48ec..59965589856 100644 --- a/app/graphql/mutations/todos/restore_many.rb +++ b/app/graphql/mutations/todos/restore_many.rb @@ -10,11 +10,11 @@ module Mutations argument :ids, [::Types::GlobalIDType[::Todo]], required: true, - description: 'The global ids of the todos to restore (a maximum of 50 is supported at once)' + description: 'The global IDs of the todos to restore (a maximum of 50 is supported at once)' field :updated_ids, [::Types::GlobalIDType[Todo]], null: false, - description: 'The ids of the updated todo items', + description: 'The IDs of the updated todo items', deprecated: { reason: 'Use todos', milestone: '13.2' } field :todos, [::Types::TodoType], diff --git a/app/graphql/queries/epic/epic_children.query.graphql b/app/graphql/queries/epic/epic_children.query.graphql new file mode 100644 index 00000000000..c12778109d0 --- /dev/null +++ b/app/graphql/queries/epic/epic_children.query.graphql @@ -0,0 +1,126 @@ +fragment PageInfo on PageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor +} + +fragment RelatedTreeBaseEpic on Epic { + id + iid + title + webPath + relativePosition + userPermissions { + __typename + adminEpic + createEpic + } + descendantCounts { + __typename + openedEpics + closedEpics + openedIssues + closedIssues + } + healthStatus { + __typename + issuesAtRisk + issuesOnTrack + issuesNeedingAttention + } +} + +fragment EpicNode on Epic { + ...RelatedTreeBaseEpic + state + reference(full: true) + relationPath + createdAt + closedAt + hasChildren + hasIssues + group { + __typename + fullPath + } +} + +query childItems( + $fullPath: ID! + $iid: ID + $pageSize: Int = 100 + $epicEndCursor: String = "" + $issueEndCursor: String = "" +) { + group(fullPath: $fullPath) { + __typename + id + path + fullPath + epic(iid: $iid) { + __typename + ...RelatedTreeBaseEpic + children(first: $pageSize, after: $epicEndCursor) { + __typename + edges { + __typename + node { + __typename + ...EpicNode + } + } + pageInfo { + __typename + ...PageInfo + } + } + issues(first: $pageSize, after: $issueEndCursor) { + __typename + edges { + __typename + node { + __typename + iid + epicIssueId + title + closedAt + state + createdAt + confidential + dueDate + weight + webPath + reference(full: true) + relationPath + relativePosition + assignees { + __typename + edges { + __typename + node { + __typename + webUrl + name + username + avatarUrl + } + } + } + milestone { + __typename + title + startDate + dueDate + } + healthStatus + } + } + pageInfo { + __typename + ...PageInfo + } + } + } + } +} diff --git a/app/graphql/queries/epic/epic_details.query.graphql b/app/graphql/queries/epic/epic_details.query.graphql new file mode 100644 index 00000000000..406d630b180 --- /dev/null +++ b/app/graphql/queries/epic/epic_details.query.graphql @@ -0,0 +1,20 @@ +query epicDetails($fullPath: ID!, $iid: ID!) { + group(fullPath: $fullPath) { + __typename + epic(iid: $iid) { + __typename + participants { + __typename + edges { + __typename + node { + __typename + name + avatarUrl + webUrl + } + } + } + } + } +} diff --git a/app/graphql/resolvers/alert_management/alert_resolver.rb b/app/graphql/resolvers/alert_management/alert_resolver.rb index c3219d9cdc3..b115bd80113 100644 --- a/app/graphql/resolvers/alert_management/alert_resolver.rb +++ b/app/graphql/resolvers/alert_management/alert_resolver.rb @@ -18,6 +18,11 @@ module Resolvers description: 'Sort alerts by this criteria', required: false + argument :domain, Types::AlertManagement::DomainFilterEnum, + description: 'Filter query for given domain', + required: true, + default_value: 'operations' + argument :search, GraphQL::STRING_TYPE, description: 'Search query for title, description, service, or monitoring_tool.', required: false diff --git a/app/graphql/resolvers/assigned_merge_requests_resolver.rb b/app/graphql/resolvers/assigned_merge_requests_resolver.rb index 30415ef5d2d..385f8db51b0 100644 --- a/app/graphql/resolvers/assigned_merge_requests_resolver.rb +++ b/app/graphql/resolvers/assigned_merge_requests_resolver.rb @@ -4,6 +4,7 @@ module Resolvers class AssignedMergeRequestsResolver < UserMergeRequestsResolverBase type ::Types::MergeRequestType.connection_type, null: true accept_author + accept_reviewer def user_role :assignee diff --git a/app/graphql/resolvers/authored_merge_requests_resolver.rb b/app/graphql/resolvers/authored_merge_requests_resolver.rb index 1426ca83c06..4de1046ce0d 100644 --- a/app/graphql/resolvers/authored_merge_requests_resolver.rb +++ b/app/graphql/resolvers/authored_merge_requests_resolver.rb @@ -4,6 +4,7 @@ module Resolvers class AuthoredMergeRequestsResolver < UserMergeRequestsResolverBase type ::Types::MergeRequestType.connection_type, null: true accept_assignee + accept_reviewer def user_role :author diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 87a63231b22..539e37db1c2 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -8,6 +8,14 @@ module Resolvers argument_class ::Types::BaseArgument + def self.requires_argument! + @requires_argument = true + end + + def self.field_options + super.merge(requires_argument: @requires_argument) + end + def self.singular_type return unless type @@ -109,6 +117,10 @@ module Resolvers [args[:iid], args[:iids]].any? ? 0 : 0.01 end + def offset_pagination(relation) + ::Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(relation) + end + override :object def object super.tap do |obj| diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb index 3421e1024c0..3e4a5a3cb70 100644 --- a/app/graphql/resolvers/board_list_issues_resolver.rb +++ b/app/graphql/resolvers/board_list_issues_resolver.rb @@ -16,7 +16,7 @@ module Resolvers filter_params = issue_filters(args[:filters]).merge(board_id: list.board.id, id: list.id) service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params) - Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(service.execute) + offset_pagination(service.execute) end # https://gitlab.com/gitlab-org/gitlab/-/issues/235681 diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb index ef12dfa19ff..35d938c50be 100644 --- a/app/graphql/resolvers/board_lists_resolver.rb +++ b/app/graphql/resolvers/board_lists_resolver.rb @@ -3,9 +3,13 @@ module Resolvers class BoardListsResolver < BaseResolver include BoardIssueFilterable + prepend ManualAuthorization include Gitlab::Graphql::Authorize::AuthorizeResource type Types::BoardListType, null: true + extras [:lookahead] + + authorize :read_list argument :id, Types::GlobalIDType[List], required: false, @@ -27,7 +31,7 @@ module Resolvers List.preload_preferences_for_user(lists, context[:current_user]) end - Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(lists) + offset_pagination(lists) end private @@ -42,10 +46,6 @@ module Resolvers service.execute(board, create_default_lists: false) end - def authorized_resource?(board) - Ability.allowed?(context[:current_user], :read_list, board) - end - def load_preferences?(lookahead) lookahead&.selection(:edges)&.selection(:node)&.selects?(:collapsed) end diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb new file mode 100644 index 00000000000..d6e7c206691 --- /dev/null +++ b/app/graphql/resolvers/ci/config_resolver.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class ConfigResolver < BaseResolver + type Types::Ci::Config::ConfigType, null: true + + argument :content, GraphQL::STRING_TYPE, + required: true, + description: 'Contents of .gitlab-ci.yml' + + def resolve(content:) + result = ::Gitlab::Ci::YamlProcessor.new(content).execute + + response = if result.errors.empty? + { + status: :valid, + errors: [], + stages: make_stages(result.jobs) + } + else + { + status: :invalid, + errors: result.errors + } + end + + response.merge(merged_yaml: result.merged_yaml) + end + + private + + def make_jobs(config_jobs) + config_jobs.map do |job_name, job| + { + name: job_name, + stage: job[:stage], + group_name: CommitStatus.new(name: job_name).group_name, + needs: job.dig(:needs, :job) || [] + } + end + end + + def make_groups(job_data) + jobs = make_jobs(job_data) + + jobs_by_group = jobs.group_by { |job| job[:group_name] } + jobs_by_group.map do |name, jobs| + { jobs: jobs, name: name, stage: jobs.first[:stage], size: jobs.size } + end + end + + def make_stages(jobs) + make_groups(jobs) + .group_by { |group| group[:stage] } + .map { |name, groups| { name: name, groups: groups } } + end + end + end +end diff --git a/app/graphql/resolvers/ci/jobs_resolver.rb b/app/graphql/resolvers/ci/jobs_resolver.rb index 8a9ae42b375..2c4911748a5 100644 --- a/app/graphql/resolvers/ci/jobs_resolver.rb +++ b/app/graphql/resolvers/ci/jobs_resolver.rb @@ -5,6 +5,8 @@ module Resolvers class JobsResolver < BaseResolver alias_method :pipeline, :object + type ::Types::Ci::JobType.connection_type, null: true + argument :security_report_types, [Types::Security::ReportTypeEnum], required: false, description: 'Filter jobs by the type of security report they produce' diff --git a/app/graphql/resolvers/ci/pipeline_stages_resolver.rb b/app/graphql/resolvers/ci/pipeline_stages_resolver.rb index f9817d8b97b..98170e0cd2e 100644 --- a/app/graphql/resolvers/ci/pipeline_stages_resolver.rb +++ b/app/graphql/resolvers/ci/pipeline_stages_resolver.rb @@ -5,6 +5,9 @@ module Resolvers class PipelineStagesResolver < BaseResolver include LooksAhead + type Types::Ci::StageType.connection_type, null: true + extras [:lookahead] + alias_method :pipeline, :object def resolve_with_lookahead diff --git a/app/graphql/resolvers/ci/runner_setup_resolver.rb b/app/graphql/resolvers/ci/runner_setup_resolver.rb index 241cd57f74b..f68d71174c3 100644 --- a/app/graphql/resolvers/ci/runner_setup_resolver.rb +++ b/app/graphql/resolvers/ci/runner_setup_resolver.rb @@ -23,7 +23,10 @@ module Resolvers def resolve(platform:, architecture:, **args) instructions = Gitlab::Ci::RunnerInstructions.new( - { current_user: current_user, os: platform, arch: architecture }.merge(target_param(args)) + current_user: current_user, + os: platform, + arch: architecture, + **target_param(args) ) { diff --git a/app/graphql/resolvers/concerns/caching_array_resolver.rb b/app/graphql/resolvers/concerns/caching_array_resolver.rb index 4f2c8b98928..e7555dcf42c 100644 --- a/app/graphql/resolvers/concerns/caching_array_resolver.rb +++ b/app/graphql/resolvers/concerns/caching_array_resolver.rb @@ -43,8 +43,10 @@ # (i.e. `resolve(**args).sync == query_for(query_input(**args)).to_a`). # # Classes may implement: -# - #item_found(A, R) (return value is ignored) # - max_union_size Integer (the maximum number of queries to run in any one union) +# - preload -> Preloads|NilClass (a set of preloads to apply to each query) +# - #item_found(A, R) (return value is ignored) +# - allowed?(R) -> Boolean (if this method returns false, the value is not resolved) module CachingArrayResolver MAX_UNION_SIZE = 50 @@ -62,6 +64,7 @@ module CachingArrayResolver queries.in_groups_of(max_union_size, false).each do |group| by_id = model_class .from_union(tag(group), remove_duplicates: false) + .preload(preload) # rubocop: disable CodeReuse/ActiveRecord .group_by { |r| r[primary_key] } by_id.values.each do |item_group| @@ -75,6 +78,16 @@ module CachingArrayResolver end end + # Override to apply filters on a per-item basis + def allowed?(item) + true + end + + # Override to specify preloads for each query + def preload + nil + end + # Override this to intercept the items once they are found def item_found(query_input, item) end @@ -94,6 +107,8 @@ module CachingArrayResolver end def found(loader, key, value) + return unless allowed?(value) + loader.call(key) do |vs| item_found(key, value) vs << value diff --git a/app/graphql/resolvers/concerns/manual_authorization.rb b/app/graphql/resolvers/concerns/manual_authorization.rb new file mode 100644 index 00000000000..182110b9594 --- /dev/null +++ b/app/graphql/resolvers/concerns/manual_authorization.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# TODO: remove this entirely when framework authorization is released +# See: https://gitlab.com/gitlab-org/gitlab/-/issues/290216 +module ManualAuthorization + def resolve(**args) + super + rescue ::Gitlab::Graphql::Errors::ResourceNotAvailable + nil + end +end diff --git a/app/graphql/resolvers/design_management/design_resolver.rb b/app/graphql/resolvers/design_management/design_resolver.rb index e0a68bae397..b60c14ca835 100644 --- a/app/graphql/resolvers/design_management/design_resolver.rb +++ b/app/graphql/resolvers/design_management/design_resolver.rb @@ -5,6 +5,8 @@ module Resolvers class DesignResolver < BaseResolver type ::Types::DesignManagement::DesignType, null: true + requires_argument! + argument :id, ::Types::GlobalIDType[::DesignManagement::Design], required: false, description: 'Find a design by its ID' diff --git a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb index 70021057f71..49a4974bfbf 100644 --- a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb +++ b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb @@ -12,6 +12,8 @@ module Resolvers type Types::DesignManagement::DesignAtVersionType, null: true + requires_argument! + authorize :read_design argument :id, DesignAtVersionID, diff --git a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb index ecd7ab3ee45..7d20cfc2c8e 100644 --- a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb +++ b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb @@ -7,6 +7,8 @@ module Resolvers type Types::DesignManagement::VersionType, null: true + requires_argument! + authorize :read_design alias_method :collection, :object diff --git a/app/graphql/resolvers/design_management/versions_resolver.rb b/app/graphql/resolvers/design_management/versions_resolver.rb index 23858c8e991..3c718a631db 100644 --- a/app/graphql/resolvers/design_management/versions_resolver.rb +++ b/app/graphql/resolvers/design_management/versions_resolver.rb @@ -9,6 +9,8 @@ module Resolvers VersionID = ::Types::GlobalIDType[::DesignManagement::Version] + extras [:parent] + argument :earlier_or_equal_to_sha, GraphQL::STRING_TYPE, as: :sha, required: false, diff --git a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb index 669b487db10..13b5672d750 100644 --- a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb +++ b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb @@ -3,6 +3,8 @@ module Resolvers module ErrorTracking class SentryErrorStackTraceResolver < BaseResolver + type Types::ErrorTracking::SentryErrorStackTraceType, null: true + argument :id, ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError], required: true, description: 'ID of the Sentry issue' diff --git a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb index c5cf924ce7f..e844ffedbeb 100644 --- a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb +++ b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb @@ -4,19 +4,26 @@ module Resolvers module ErrorTracking class SentryErrorsResolver < BaseResolver type Types::ErrorTracking::SentryErrorType.connection_type, null: true + extension Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension + + argument :search_term, ::GraphQL::STRING_TYPE, + description: 'Search query for the Sentry error details', + required: false + + # TODO: convert to Enum + argument :sort, ::GraphQL::STRING_TYPE, + description: 'Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default', + required: false + + delegate :project, to: :object def resolve(**args) args[:cursor] = args.delete(:after) - project = object.project - result = ::ErrorTracking::ListIssuesService.new( - project, - context[:current_user], - args - ).execute + result = ::ErrorTracking::ListIssuesService.new(project, current_user, args).execute - next_cursor = result[:pagination]&.dig('next', 'cursor') - previous_cursor = result[:pagination]&.dig('previous', 'cursor') + next_cursor = result.dig(:pagination, 'next', 'cursor') + previous_cursor = result.dig(:pagination, 'previous', 'cursor') issues = result[:issues] # ReactiveCache is still fetching data @@ -24,6 +31,10 @@ module Resolvers Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *issues) end + + def self.field_options + super.merge(connection: false) # we manage the pagination manually, so opt out of the connection field extension + end end end end diff --git a/app/graphql/resolvers/group_members_resolver.rb b/app/graphql/resolvers/group_members_resolver.rb index d3aa376c29c..fcdf7c01d8b 100644 --- a/app/graphql/resolvers/group_members_resolver.rb +++ b/app/graphql/resolvers/group_members_resolver.rb @@ -6,6 +6,11 @@ module Resolvers authorize :read_group_member + argument :relations, [Types::GroupMemberRelationEnum], + description: 'Filter members by the given member relations', + required: false, + default_value: GroupMembersFinder::DEFAULT_RELATIONS + private def preloads diff --git a/app/graphql/resolvers/issue_status_counts_resolver.rb b/app/graphql/resolvers/issue_status_counts_resolver.rb index 5d0d5693244..58cff559d0d 100644 --- a/app/graphql/resolvers/issue_status_counts_resolver.rb +++ b/app/graphql/resolvers/issue_status_counts_resolver.rb @@ -6,6 +6,8 @@ module Resolvers type Types::IssueStatusCountsType, null: true + extras [:lookahead] + def continue_issue_resolve(parent, finder, **args) finder.parent_param = parent apply_lookahead(Gitlab::IssuablesCountForState.new(finder, parent)) diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index dd35219454f..ae27cce9113 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -10,7 +10,7 @@ module Resolvers argument :sort, Types::IssueSortEnum, description: 'Sort issues by this criteria', required: false, - default_value: 'created_desc' + default_value: :created_desc type Types::IssueType.connection_type, null: true @@ -24,7 +24,7 @@ module Resolvers if non_stable_cursor_sort?(args[:sort]) # Certain complex sorts are not supported by the stable cursor pagination yet. # In these cases, we use offset pagination, so we return the correct connection. - Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(issues) + offset_pagination(issues) else issues end diff --git a/app/graphql/resolvers/members_resolver.rb b/app/graphql/resolvers/members_resolver.rb index 523642e912f..cf51fd298bd 100644 --- a/app/graphql/resolvers/members_resolver.rb +++ b/app/graphql/resolvers/members_resolver.rb @@ -14,7 +14,9 @@ module Resolvers def resolve_with_lookahead(**args) authorize!(object) - apply_lookahead(finder_class.new(object, current_user, params: args).execute) + relations = args.delete(:relations) + + apply_lookahead(finder_class.new(object, current_user, params: args).execute(include_relations: relations)) end private diff --git a/app/graphql/resolvers/merge_request_pipelines_resolver.rb b/app/graphql/resolvers/merge_request_pipelines_resolver.rb index 6590dfdc78c..f84eedb4c3b 100644 --- a/app/graphql/resolvers/merge_request_pipelines_resolver.rb +++ b/app/graphql/resolvers/merge_request_pipelines_resolver.rb @@ -5,14 +5,32 @@ module Resolvers class MergeRequestPipelinesResolver < BaseResolver # The GraphQL type here gets defined in this include include ::ResolvesPipelines + include ::CachingArrayResolver alias_method :merge_request, :object + # Return at most 500 pipelines for each MR. + # Merge requests generally have many fewer pipelines than this. + def self.field_options + super.merge(max_page_size: 500) + end + def resolve(**args) return unless project - resolve_pipelines(project, args) - .merge(merge_request.all_pipelines) + super + end + + def query_for(args) + resolve_pipelines(project, args).merge(merge_request.all_pipelines) + end + + def model_class + ::Ci::Pipeline + end + + def query_input(**args) + args end def project diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb index cb4a76243ae..98c95565778 100644 --- a/app/graphql/resolvers/merge_requests_resolver.rb +++ b/app/graphql/resolvers/merge_requests_resolver.rb @@ -4,6 +4,8 @@ module Resolvers class MergeRequestsResolver < BaseResolver include ResolvesMergeRequests + type ::Types::MergeRequestType.connection_type, null: true + alias_method :project, :synchronized_object def self.accept_assignee @@ -18,6 +20,12 @@ module Resolvers description: 'Username of the author' end + def self.accept_reviewer + argument :reviewer_username, GraphQL::STRING_TYPE, + required: false, + description: 'Username of the reviewer' + end + argument :iids, [GraphQL::STRING_TYPE], required: false, description: 'Array of IIDs of merge requests, for example `[1, 2]`' @@ -52,7 +60,7 @@ module Resolvers argument :sort, Types::MergeRequestSortEnum, description: 'Sort merge requests by this criteria', required: false, - default_value: 'created_desc' + default_value: :created_desc def self.single ::Resolvers::MergeRequestResolver diff --git a/app/graphql/resolvers/project_members_resolver.rb b/app/graphql/resolvers/project_members_resolver.rb index e64e8b845a5..659b12c2563 100644 --- a/app/graphql/resolvers/project_members_resolver.rb +++ b/app/graphql/resolvers/project_members_resolver.rb @@ -5,6 +5,11 @@ module Resolvers class ProjectMembersResolver < MembersResolver authorize :read_project_member + argument :relations, [Types::ProjectMemberRelationEnum], + description: 'Filter members by the given member relations', + required: false, + default_value: MembersFinder::DEFAULT_RELATIONS + private def finder_class diff --git a/app/graphql/resolvers/project_merge_requests_resolver.rb b/app/graphql/resolvers/project_merge_requests_resolver.rb index bf082c0b182..830649d5e52 100644 --- a/app/graphql/resolvers/project_merge_requests_resolver.rb +++ b/app/graphql/resolvers/project_merge_requests_resolver.rb @@ -5,5 +5,6 @@ module Resolvers type ::Types::MergeRequestType.connection_type, null: true accept_assignee accept_author + accept_reviewer end end diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb index 4cf47dbdc60..8bf4e0b08ef 100644 --- a/app/graphql/resolvers/project_pipeline_resolver.rb +++ b/app/graphql/resolvers/project_pipeline_resolver.rb @@ -12,7 +12,9 @@ module Resolvers def resolve(iid:) BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader, args| - args[:key].all_pipelines.for_iid(iids).each { |pl| loader.call(pl.iid.to_s, pl) } + finder = ::Ci::PipelinesFinder.new(project, context[:current_user], iids: iids) + + finder.execute.each { |pipeline| loader.call(pipeline.iid.to_s, pipeline) } end end end diff --git a/app/graphql/resolvers/project_pipeline_statistics_resolver.rb b/app/graphql/resolvers/project_pipeline_statistics_resolver.rb new file mode 100644 index 00000000000..29ab9402f5b --- /dev/null +++ b/app/graphql/resolvers/project_pipeline_statistics_resolver.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Resolvers + class ProjectPipelineStatisticsResolver < BaseResolver + type Types::Ci::AnalyticsType, null: true + + def resolve + weekly_stats = Gitlab::Ci::Charts::WeekChart.new(object) + monthly_stats = Gitlab::Ci::Charts::MonthChart.new(object) + yearly_stats = Gitlab::Ci::Charts::YearChart.new(object) + pipeline_times = Gitlab::Ci::Charts::PipelineTime.new(object) + + { + week_pipelines_labels: weekly_stats.labels, + week_pipelines_totals: weekly_stats.total, + week_pipelines_successful: weekly_stats.success, + month_pipelines_labels: monthly_stats.labels, + month_pipelines_totals: monthly_stats.total, + month_pipelines_successful: monthly_stats.success, + year_pipelines_labels: yearly_stats.labels, + year_pipelines_totals: yearly_stats.total, + year_pipelines_successful: yearly_stats.success, + pipeline_times_labels: pipeline_times.labels, + pipeline_times_values: pipeline_times.pipeline_times + } + end + end +end diff --git a/app/graphql/resolvers/projects/jira_imports_resolver.rb b/app/graphql/resolvers/projects/jira_imports_resolver.rb deleted file mode 100644 index efd45c2c465..00000000000 --- a/app/graphql/resolvers/projects/jira_imports_resolver.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Resolvers - module Projects - class JiraImportsResolver < BaseResolver - type Types::JiraImportType.connection_type, null: true - - include Gitlab::Graphql::Authorize::AuthorizeResource - - alias_method :project, :object - - def resolve(**args) - authorize!(project) - - project.jira_imports - end - - def authorized_resource?(project) - context[:current_user].present? && Ability.allowed?(context[:current_user], :read_project, project) - end - end - end -end diff --git a/app/graphql/resolvers/projects/services_resolver.rb b/app/graphql/resolvers/projects/services_resolver.rb index 17d81e21c28..4f5a6cddbb3 100644 --- a/app/graphql/resolvers/projects/services_resolver.rb +++ b/app/graphql/resolvers/projects/services_resolver.rb @@ -3,9 +3,11 @@ module Resolvers module Projects class ServicesResolver < BaseResolver + prepend ManualAuthorization include Gitlab::Graphql::Authorize::AuthorizeResource type Types::Projects::ServiceType.connection_type, null: true + authorize :admin_project argument :active, GraphQL::BOOLEAN_TYPE, @@ -24,10 +26,6 @@ module Resolvers services(args[:active], args[:type]) end - def authorized_resource?(project) - Ability.allowed?(context[:current_user], :admin_project, project) - end - private def services(active, type) diff --git a/app/graphql/resolvers/review_requested_merge_requests_resolver.rb b/app/graphql/resolvers/review_requested_merge_requests_resolver.rb new file mode 100644 index 00000000000..e0ab7b5b600 --- /dev/null +++ b/app/graphql/resolvers/review_requested_merge_requests_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + class ReviewRequestedMergeRequestsResolver < UserMergeRequestsResolverBase + type ::Types::MergeRequestType.connection_type, null: true + accept_author + accept_assignee + + def user_role + :reviewer + end + end +end diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb index 3a0dcb50faf..cfb1711aed4 100644 --- a/app/graphql/resolvers/snippets/blobs_resolver.rb +++ b/app/graphql/resolvers/snippets/blobs_resolver.rb @@ -3,9 +3,11 @@ module Resolvers module Snippets class BlobsResolver < BaseResolver + prepend ManualAuthorization include Gitlab::Graphql::Authorize::AuthorizeResource type Types::Snippets::BlobType.connection_type, null: true + authorize :read_snippet alias_method :snippet, :object @@ -27,10 +29,6 @@ module Resolvers end end - def authorized_resource?(snippet) - Ability.allowed?(context[:current_user], :read_snippet, snippet) - end - private def transformed_blob_paths(paths) diff --git a/app/graphql/resolvers/user_discussions_count_resolver.rb b/app/graphql/resolvers/user_discussions_count_resolver.rb new file mode 100644 index 00000000000..115997ec666 --- /dev/null +++ b/app/graphql/resolvers/user_discussions_count_resolver.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Resolvers + class UserDiscussionsCountResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type GraphQL::INT_TYPE, null: true + + def resolve + authorize!(object) + + BatchLoader::GraphQL.for(object.id).batch do |ids, loader, args| + counts = Note.count_for_collection(ids, object.class.name, 'COUNT(DISTINCT discussion_id) as count').index_by(&:noteable_id) + + ids.each do |id| + loader.call(id, counts[id]&.count || 0) + end + end + end + + def authorized_resource?(object) + ability = "read_#{object.class.name.underscore}".to_sym + context[:current_user].present? && Ability.allowed?(context[:current_user], ability, object) + end + end +end diff --git a/app/graphql/resolvers/user_notes_count_resolver.rb b/app/graphql/resolvers/user_notes_count_resolver.rb new file mode 100644 index 00000000000..2cb61104c18 --- /dev/null +++ b/app/graphql/resolvers/user_notes_count_resolver.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Resolvers + class UserNotesCountResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type GraphQL::INT_TYPE, null: true + + def resolve + authorize!(object) + + BatchLoader::GraphQL.for(object.id).batch(key: :user_notes_count) do |ids, loader, args| + counts = Note.count_for_collection(ids, object.class.name).index_by(&:noteable_id) + + ids.each do |id| + loader.call(id, counts[id]&.count || 0) + end + end + end + + def authorized_resource?(object) + ability = "read_#{object.class.name.underscore}".to_sym + context[:current_user].present? && Ability.allowed?(context[:current_user], ability, object) + end + end +end diff --git a/app/graphql/resolvers/users/group_count_resolver.rb b/app/graphql/resolvers/users/group_count_resolver.rb index 5033c26554a..ebfe594d31d 100644 --- a/app/graphql/resolvers/users/group_count_resolver.rb +++ b/app/graphql/resolvers/users/group_count_resolver.rb @@ -3,6 +3,8 @@ module Resolvers module Users class GroupCountResolver < BaseResolver + type GraphQL::INT_TYPE, null: true + alias_method :user, :object def resolve(**args) diff --git a/app/graphql/resolvers/users_resolver.rb b/app/graphql/resolvers/users_resolver.rb index f5838642141..a0ed076595d 100644 --- a/app/graphql/resolvers/users_resolver.rb +++ b/app/graphql/resolvers/users_resolver.rb @@ -17,7 +17,7 @@ module Resolvers argument :sort, Types::SortEnum, description: 'Sort users by this criteria', required: false, - default_value: 'created_desc' + default_value: :created_desc argument :search, GraphQL::STRING_TYPE, required: false, diff --git a/app/graphql/types/alert_management/domain_filter_enum.rb b/app/graphql/types/alert_management/domain_filter_enum.rb new file mode 100644 index 00000000000..58dbc8bb2cf --- /dev/null +++ b/app/graphql/types/alert_management/domain_filter_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module AlertManagement + class DomainFilterEnum < BaseEnum + graphql_name 'AlertManagementDomainFilter' + description 'Filters the alerts based on given domain' + + value 'operations', description: 'Alerts for operations domain ' + value 'threat_monitoring', description: 'Alerts for threat monitoring domain' + end + end +end diff --git a/app/graphql/types/alert_management/prometheus_integration_type.rb b/app/graphql/types/alert_management/prometheus_integration_type.rb index f605e325b8b..79f265f2f1e 100644 --- a/app/graphql/types/alert_management/prometheus_integration_type.rb +++ b/app/graphql/types/alert_management/prometheus_integration_type.rb @@ -2,7 +2,7 @@ module Types module AlertManagement - class PrometheusIntegrationType < BaseObject + class PrometheusIntegrationType < ::Types::BaseObject include ::Gitlab::Routing graphql_name 'AlertManagementPrometheusIntegration' diff --git a/app/graphql/types/award_emojis/award_emoji_type.rb b/app/graphql/types/award_emojis/award_emoji_type.rb index fe7affa50cc..cd7a2f34ba6 100644 --- a/app/graphql/types/award_emojis/award_emoji_type.rb +++ b/app/graphql/types/award_emojis/award_emoji_type.rb @@ -38,10 +38,11 @@ module Types field :user, Types::UserType, null: false, - description: 'The user who awarded the emoji', - resolve: -> (award_emoji, _args, _context) { - Gitlab::Graphql::Loaders::BatchModelLoader.new(User, award_emoji.user_id).find - } + description: 'The user who awarded the emoji' + + def user + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find + end end end end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 5c8aabfe163..c4ce2cecd8b 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -12,6 +12,7 @@ module Types def initialize(*args, **kwargs, &block) @calls_gitaly = !!kwargs.delete(:calls_gitaly) @constant_complexity = !!kwargs[:complexity] + @requires_argument = !!kwargs.delete(:requires_argument) kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity]) @feature_flag = kwargs[:feature_flag] kwargs = check_feature_flag(kwargs) @@ -20,6 +21,10 @@ module Types super(*args, **kwargs, &block) end + def requires_argument? + @requires_argument || arguments.values.any? { |argument| argument.type.non_null? } + end + # Based on https://github.com/rmosolgo/graphql-ruby/blob/v1.11.4/lib/graphql/schema/field.rb#L538-L563 # Modified to fix https://github.com/rmosolgo/graphql-ruby/issues/3113 def resolve_field(obj, args, ctx) @@ -73,7 +78,7 @@ module Types attr_reader :feature_flag def feature_documentation_message(key, description) - "#{description}. Available only when feature flag `#{key}` is enabled" + "#{description} Available only when feature flag `#{key}` is enabled." end def check_feature_flag(args) diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb index 3451a195c33..4b1f3193136 100644 --- a/app/graphql/types/base_interface.rb +++ b/app/graphql/types/base_interface.rb @@ -3,5 +3,7 @@ module Types module BaseInterface include GraphQL::Schema::Interface + + field_class ::Types::BaseField end end diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb index 6ee76b0d1f1..7999e77eb30 100644 --- a/app/graphql/types/board_list_type.rb +++ b/app/graphql/types/board_list_type.rb @@ -19,8 +19,7 @@ module Types field :label, Types::LabelType, null: true, description: 'Label of the list' field :collapsed, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Indicates if list is collapsed for this user', - resolve: -> (list, _args, ctx) { list.collapsed?(ctx[:current_user]) } + description: 'Indicates if list is collapsed for this user' field :issues_count, GraphQL::INT_TYPE, null: true, description: 'Count of issues in the list' @@ -32,6 +31,10 @@ module Types metadata[:size] end + def collapsed + object.collapsed?(context[:current_user]) + end + def metadata strong_memoize(:metadata) do list = self.object diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb index 2a7b318e283..f47c744d1bb 100644 --- a/app/graphql/types/board_type.rb +++ b/app/graphql/types/board_type.rb @@ -12,6 +12,12 @@ module Types field :name, type: GraphQL::STRING_TYPE, null: true, description: 'Name of the board' + field :hide_backlog_list, type: GraphQL::BOOLEAN_TYPE, null: true, + description: 'Whether or not backlog list is hidden' + + field :hide_closed_list, type: GraphQL::BOOLEAN_TYPE, null: true, + description: 'Whether or not closed list is hidden' + field :lists, Types::BoardListType.connection_type, null: true, diff --git a/app/graphql/types/ci/analytics_type.rb b/app/graphql/types/ci/analytics_type.rb new file mode 100644 index 00000000000..c8b12c6a9b8 --- /dev/null +++ b/app/graphql/types/ci/analytics_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class AnalyticsType < BaseObject + graphql_name 'PipelineAnalytics' + + field :week_pipelines_totals, [GraphQL::INT_TYPE], null: true, + description: 'Total weekly pipeline count' + field :week_pipelines_successful, [GraphQL::INT_TYPE], null: true, + description: 'Total weekly successful pipeline count' + field :week_pipelines_labels, [GraphQL::STRING_TYPE], null: true, + description: 'Labels for the weekly pipeline count' + field :month_pipelines_totals, [GraphQL::INT_TYPE], null: true, + description: 'Total monthly pipeline count' + field :month_pipelines_successful, [GraphQL::INT_TYPE], null: true, + description: 'Total monthly successful pipeline count' + field :month_pipelines_labels, [GraphQL::STRING_TYPE], null: true, + description: 'Labels for the monthly pipeline count' + field :year_pipelines_totals, [GraphQL::INT_TYPE], null: true, + description: 'Total yearly pipeline count' + field :year_pipelines_successful, [GraphQL::INT_TYPE], null: true, + description: 'Total yearly successful pipeline count' + field :year_pipelines_labels, [GraphQL::STRING_TYPE], null: true, + description: 'Labels for the yearly pipeline count' + field :pipeline_times_values, [GraphQL::INT_TYPE], null: true, + description: 'Pipeline times' + field :pipeline_times_labels, [GraphQL::STRING_TYPE], null: true, + description: 'Pipeline times labels' + end + end +end diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb new file mode 100644 index 00000000000..207c37f9538 --- /dev/null +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Ci + class CiCdSettingType < BaseObject + graphql_name 'ProjectCiCdSetting' + + authorize :admin_project + + field :merge_pipelines_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Whether merge pipelines are enabled.', + method: :merge_pipelines_enabled? + field :merge_trains_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Whether merge trains are enabled.', + method: :merge_trains_enabled? + field :project, Types::ProjectType, null: true, + description: 'Project the CI/CD settings belong to.' + end + end +end diff --git a/app/graphql/types/ci/config/config_type.rb b/app/graphql/types/ci/config/config_type.rb new file mode 100644 index 00000000000..e54b345f3d3 --- /dev/null +++ b/app/graphql/types/ci/config/config_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + module Config + class ConfigType < BaseObject + graphql_name 'CiConfig' + + field :errors, [GraphQL::STRING_TYPE], null: true, + description: 'Linting errors' + field :merged_yaml, GraphQL::STRING_TYPE, null: true, + description: 'Merged CI config YAML' + field :stages, [Types::Ci::Config::StageType], null: true, + description: 'Stages of the pipeline' + field :status, Types::Ci::Config::StatusEnum, null: true, + description: 'Status of linting, can be either valid or invalid' + end + end + end +end diff --git a/app/graphql/types/ci/config/group_type.rb b/app/graphql/types/ci/config/group_type.rb new file mode 100644 index 00000000000..8b0db2934a4 --- /dev/null +++ b/app/graphql/types/ci/config/group_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + module Config + class GroupType < BaseObject + graphql_name 'CiConfigGroup' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the job group' + field :jobs, [Types::Ci::Config::JobType], null: true, + description: 'Jobs in group' + field :size, GraphQL::INT_TYPE, null: true, + description: 'Size of the job group' + end + end + end +end diff --git a/app/graphql/types/ci/config/job_type.rb b/app/graphql/types/ci/config/job_type.rb new file mode 100644 index 00000000000..59bcbd9ef49 --- /dev/null +++ b/app/graphql/types/ci/config/job_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + module Config + class JobType < BaseObject + graphql_name 'CiConfigJob' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the job' + field :group_name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the job group' + field :stage, GraphQL::STRING_TYPE, null: true, + description: 'Name of the job stage' + field :needs, [Types::Ci::Config::NeedType], null: true, + description: 'Builds that must complete before the jobs run' + end + end + end +end diff --git a/app/graphql/types/ci/config/need_type.rb b/app/graphql/types/ci/config/need_type.rb new file mode 100644 index 00000000000..a442450b9ae --- /dev/null +++ b/app/graphql/types/ci/config/need_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + module Config + class NeedType < BaseObject + graphql_name 'CiConfigNeed' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the need' + end + end + end +end diff --git a/app/graphql/types/ci/config/stage_type.rb b/app/graphql/types/ci/config/stage_type.rb new file mode 100644 index 00000000000..20618bc41f8 --- /dev/null +++ b/app/graphql/types/ci/config/stage_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + module Config + class StageType < BaseObject + graphql_name 'CiConfigStage' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the stage' + field :groups, [Types::Ci::Config::GroupType], null: true, + description: 'Groups of jobs for the stage' + end + end + end +end diff --git a/app/graphql/types/ci/config/status_enum.rb b/app/graphql/types/ci/config/status_enum.rb new file mode 100644 index 00000000000..92b04c61679 --- /dev/null +++ b/app/graphql/types/ci/config/status_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + module Config + class StatusEnum < BaseEnum + graphql_name 'CiConfigStatus' + description 'Values for YAML processor result' + + value 'VALID', 'The configuration file is valid', value: :valid + value 'INVALID', 'The configuration file is not valid', value: :invalid + end + end + end +end diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index 6d8af400ac4..80d73e9b174 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -25,20 +25,22 @@ module Types description: 'Tooltip associated with the status', method: :status_tooltip field :action, Types::Ci::StatusActionType, null: true, - description: 'Action information for the status. This includes method, button title, icon, path, and title', - resolve: -> (obj, _args, _ctx) { - if obj.has_action? - { - button_title: obj.action_button_title, - icon: obj.action_icon, - method: obj.action_method, - path: obj.action_path, - title: obj.action_title - } - else - nil - end - } + calls_gitaly: true, + description: 'Action information for the status. This includes method, button title, icon, path, and title' + + def action + if object.has_action? + { + button_title: object.action_button_title, + icon: object.action_icon, + method: object.action_method, + path: object.action_path, + title: object.action_title + } + else + nil + end + end end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb index d930ae311b7..03fd50d5dbb 100644 --- a/app/graphql/types/ci/group_type.rb +++ b/app/graphql/types/ci/group_type.rb @@ -13,8 +13,11 @@ module Types field :jobs, Ci::JobType.connection_type, null: true, description: 'Jobs in group' field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the group', - resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } + description: 'Detailed status of the group' + + def detailed_status + object.detailed_status(context[:current_user]) + end end end end diff --git a/app/graphql/types/ci/job_artifact_file_type_enum.rb b/app/graphql/types/ci/job_artifact_file_type_enum.rb new file mode 100644 index 00000000000..4b484dec590 --- /dev/null +++ b/app/graphql/types/ci/job_artifact_file_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Ci + class JobArtifactFileTypeEnum < BaseEnum + graphql_name 'JobArtifactFileType' + + ::Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.keys.each do |file_type| + value file_type.to_s.upcase, value: file_type.to_s + end + end + end +end diff --git a/app/graphql/types/ci/job_artifact_type.rb b/app/graphql/types/ci/job_artifact_type.rb new file mode 100644 index 00000000000..c34a12dcc61 --- /dev/null +++ b/app/graphql/types/ci/job_artifact_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class JobArtifactType < BaseObject + graphql_name 'CiJobArtifact' + + field :download_path, GraphQL::STRING_TYPE, null: true, + description: "URL for downloading the artifact's file" + + field :file_type, ::Types::Ci::JobArtifactFileTypeEnum, null: true, + description: 'File type of the artifact' + + def download_path + ::Gitlab::Routing.url_helpers.download_project_job_artifacts_path( + object.project, + object.job, + file_type: object.file_type + ) + end + end + end +end diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index feaff4e81d8..5b6e8fe8567 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -6,18 +6,32 @@ module Types class JobType < BaseObject graphql_name 'CiJob' - field :pipeline, Types::Ci::PipelineType, null: false, - description: 'Pipeline the job belongs to', - resolve: -> (build, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, build.pipeline_id).find } + field :pipeline, Types::Ci::PipelineType, null: true, + description: 'Pipeline the job belongs to' field :name, GraphQL::STRING_TYPE, null: true, - description: 'Name of the job' + description: 'Name of the job' field :needs, JobType.connection_type, null: true, - description: 'Builds that must complete before the jobs run' + description: 'Builds that must complete before the jobs run' field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the job', - resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } + description: 'Detailed status of the job' field :scheduled_at, Types::TimeType, null: true, - description: 'Schedule for the build' + description: 'Schedule for the build' + field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true, + description: 'Artifacts generated by the job' + + def pipeline + Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, object.pipeline_id).find + end + + def detailed_status + object.detailed_status(context[:current_user]) + end + + def artifacts + if object.is_a?(::Ci::Build) + object.job_artifacts + end + end end end end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index c25db39f600..4709d5e8dd6 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -27,8 +27,7 @@ module Types description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})" field :detailed_status, Types::Ci::DetailedStatusType, null: false, - description: 'Detailed status of the pipeline', - resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } + description: 'Detailed status of the pipeline' field :config_source, PipelineConfigSourceEnum, null: true, description: "Config source of the pipeline (#{::Enums::Ci::Pipeline.config_sources.keys.join(', ').upcase})" @@ -60,8 +59,7 @@ module Types resolver: Resolvers::Ci::PipelineStagesResolver field :user, Types::UserType, null: true, - description: 'Pipeline user', - resolve: -> (pipeline, _args, _context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, pipeline.user_id).find } + description: 'Pipeline user' field :retryable, GraphQL::BOOLEAN_TYPE, description: 'Specifies if a pipeline can be retried', @@ -91,11 +89,25 @@ module Types method: :triggered_by_pipeline field :path, GraphQL::STRING_TYPE, null: true, - description: "Relative path to the pipeline's page", - resolve: -> (obj, _args, _ctx) { ::Gitlab::Routing.url_helpers.project_pipeline_path(obj.project, obj) } + description: "Relative path to the pipeline's page" field :project, Types::ProjectType, null: true, description: 'Project the pipeline belongs to' + + field :active, GraphQL::BOOLEAN_TYPE, null: false, method: :active?, + description: 'Indicates if the pipeline is active' + + def detailed_status + object.detailed_status(context[:current_user]) + end + + def user + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find + end + + def path + ::Gitlab::Routing.url_helpers.project_pipeline_path(object.project, object) + end end end end diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb index fc2c72d0d06..fd0bde90836 100644 --- a/app/graphql/types/ci/stage_type.rb +++ b/app/graphql/types/ci/stage_type.rb @@ -11,8 +11,11 @@ module Types field :groups, Ci::GroupType.connection_type, null: true, description: 'Group of jobs for the stage' field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the stage', - resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } + description: 'Detailed status of the stage' + + def detailed_status + object.detailed_status(context[:current_user]) + end end end end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index c24b47f08ef..37d19b4148b 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -12,6 +12,8 @@ module Types description: 'ID (global ID) of the commit' field :sha, type: GraphQL::STRING_TYPE, null: false, description: 'SHA1 ID of the commit' + field :short_id, type: GraphQL::STRING_TYPE, null: false, + description: 'Short SHA1 ID of the commit' field :title, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true, description: 'Title of the commit message' markdown_field :title_html, null: true @@ -31,10 +33,7 @@ module Types field :author_name, type: GraphQL::STRING_TYPE, null: true, description: 'Commit authors name' field :author_gravatar, type: GraphQL::STRING_TYPE, null: true, - description: 'Commit authors gravatar', - resolve: -> (commit, args, context) do - GravatarService.new.execute(commit.author_email, 40) - end + description: 'Commit authors gravatar' # models/commit lazy loads the author by email field :author, type: Types::UserType, null: true, @@ -44,5 +43,9 @@ module Types null: true, description: 'Pipelines of the commit ordered latest first', resolver: Resolvers::CommitPipelinesResolver + + def author_gravatar + GravatarService.new.execute(object.author_email, 40) + end end end diff --git a/app/graphql/types/concerns/gitlab_style_deprecations.rb b/app/graphql/types/concerns/gitlab_style_deprecations.rb index 2c932f4214b..9f087f3812d 100644 --- a/app/graphql/types/concerns/gitlab_style_deprecations.rb +++ b/app/graphql/types/concerns/gitlab_style_deprecations.rb @@ -23,8 +23,8 @@ module GitlabStyleDeprecations raise ArgumentError, '`milestone` must be a `String`' unless milestone.is_a?(String) deprecated_in = "Deprecated in #{milestone}" - kwargs[:deprecation_reason] = "#{reason}. #{deprecated_in}" - kwargs[:description] += ". #{deprecated_in}: #{reason}" if kwargs[:description] + kwargs[:deprecation_reason] = "#{reason}. #{deprecated_in}." + kwargs[:description] += " #{deprecated_in}: #{reason}." if kwargs[:description] kwargs end diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb index 45d19fdbc50..8735f8a173d 100644 --- a/app/graphql/types/container_repository_type.rb +++ b/app/graphql/types/container_repository_type.rb @@ -19,9 +19,14 @@ module Types field :status, Types::ContainerRepositoryStatusEnum, null: true, description: 'Status of the container repository.' field :tags_count, GraphQL::INT_TYPE, null: false, description: 'Number of tags associated with this image.' field :can_delete, GraphQL::BOOLEAN_TYPE, null: false, description: 'Can the current user delete the container repository.' + field :project, Types::ProjectType, null: false, description: 'Project of the container registry' def can_delete Ability.allowed?(current_user, :update_container_image, object) end + + def project + Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find + end end end diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb index 9af1f4db425..26fbac15b30 100644 --- a/app/graphql/types/design_management/design_collection_type.rb +++ b/app/graphql/types/design_management/design_collection_type.rb @@ -2,7 +2,7 @@ module Types module DesignManagement - class DesignCollectionType < BaseObject + class DesignCollectionType < ::Types::BaseObject graphql_name 'DesignCollection' description 'A collection of designs' diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb index 798e0433d06..49d5d62c860 100644 --- a/app/graphql/types/error_tracking/sentry_error_collection_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb @@ -9,27 +9,12 @@ module Types authorize :read_sentry_issue field :errors, - Types::ErrorTracking::SentryErrorType.connection_type, - connection: false, - null: true, description: "Collection of Sentry Errors", - extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension], - resolver: Resolvers::ErrorTracking::SentryErrorsResolver do - argument :search_term, - String, - description: 'Search query for the Sentry error details', - required: false - argument :sort, - String, - description: 'Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default', - required: false - end - field :detailed_error, Types::ErrorTracking::SentryDetailedErrorType, - null: true, + resolver: Resolvers::ErrorTracking::SentryErrorsResolver + field :detailed_error, description: 'Detailed version of a Sentry error on the project', resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver - field :error_stack_trace, Types::ErrorTracking::SentryErrorStackTraceType, - null: true, + field :error_stack_trace, description: 'Stack Trace of Sentry Error', resolver: Resolvers::ErrorTracking::SentryErrorStackTraceResolver field :external_url, diff --git a/app/graphql/types/group_invitation_type.rb b/app/graphql/types/group_invitation_type.rb index 0372ce178ff..efb0c8a41c8 100644 --- a/app/graphql/types/group_invitation_type.rb +++ b/app/graphql/types/group_invitation_type.rb @@ -11,7 +11,10 @@ module Types description 'Represents a Group Invitation' field :group, Types::GroupType, null: true, - description: 'Group that a User is invited to', - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.source_id).find } + description: 'Group that a User is invited to' + + def group + Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find + end end end diff --git a/app/graphql/types/group_member_relation_enum.rb b/app/graphql/types/group_member_relation_enum.rb new file mode 100644 index 00000000000..aa2e73d4944 --- /dev/null +++ b/app/graphql/types/group_member_relation_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + class GroupMemberRelationEnum < BaseEnum + graphql_name 'GroupMemberRelation' + description 'Group member relation' + + ::GroupMembersFinder::RELATIONS.each do |member_relation| + value member_relation.to_s.upcase, value: member_relation, description: "#{member_relation.to_s.titleize} members" + end + end +end diff --git a/app/graphql/types/group_member_type.rb b/app/graphql/types/group_member_type.rb index 6cca0a50647..204da5a302a 100644 --- a/app/graphql/types/group_member_type.rb +++ b/app/graphql/types/group_member_type.rb @@ -11,7 +11,10 @@ module Types description 'Represents a Group Membership' field :group, Types::GroupType, null: true, - description: 'Group that a User is a member of', - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.source_id).find } + description: 'Group that a User is a member of' + + def group + Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find + end end end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index fb028184488..0ee8a19c1a3 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -12,10 +12,7 @@ module Types description: 'Web URL of the group' field :avatar_url, GraphQL::STRING_TYPE, null: true, - description: 'Avatar URL of the group', - resolve: -> (group, args, ctx) do - group.avatar_url(only_path: false) - end + description: 'Avatar URL of the group' field :custom_emoji, Types::CustomEmojiType.connection_type, null: true, description: 'Custom emoji within this namespace', @@ -44,8 +41,7 @@ module Types description: 'Indicates if a group is disabled from getting mentioned' field :parent, GroupType, null: true, - description: 'Parent group', - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find } + description: 'Parent group' field :issues, Types::IssueType.connection_type, @@ -92,10 +88,13 @@ module Types field :container_repositories, Types::ContainerRepositoryType.connection_type, null: true, - description: 'Container repositories of the project', + description: 'Container repositories of the group', resolver: Resolvers::ContainerRepositoriesResolver, authorize: :read_container_image + field :container_repositories_count, GraphQL::INT_TYPE, null: false, + description: 'Number of container repositories in the group' + def label(title:) BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args| LabelsFinder @@ -120,6 +119,18 @@ module Types .execute end + def avatar_url + object.avatar_url(only_path: false) + end + + def parent + Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.parent_id).find + end + + def container_repositories_count + group.container_repositories.size + end + private def group diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 49c84f75e1a..83b8a834801 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -61,9 +61,11 @@ module Types field :downvotes, GraphQL::INT_TYPE, null: false, description: 'Number of downvotes the issue has received' field :user_notes_count, GraphQL::INT_TYPE, null: false, - description: 'Number of user notes of the issue' + description: 'Number of user notes of the issue', + resolver: Resolvers::UserNotesCountResolver field :user_discussions_count, GraphQL::INT_TYPE, null: false, - description: 'Number of user discussions in the issue' + description: 'Number of user discussions in the issue', + resolver: Resolvers::UserDiscussionsCountResolver field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path, description: 'Web path of the issue' field :web_url, GraphQL::STRING_TYPE, null: false, @@ -119,26 +121,6 @@ module Types field :moved_to, Types::IssueType, null: true, description: 'Updated Issue after it got moved to another project' - def user_notes_count - BatchLoader::GraphQL.for(object.id).batch(key: :issue_user_notes_count) do |ids, loader, args| - counts = Note.count_for_collection(ids, 'Issue').index_by(&:noteable_id) - - ids.each do |id| - loader.call(id, counts[id]&.count || 0) - end - end - end - - def user_discussions_count - BatchLoader::GraphQL.for(object.id).batch(key: :issue_user_discussions_count) do |ids, loader, args| - counts = Note.count_for_collection(ids, 'Issue', 'COUNT(DISTINCT discussion_id) as count').index_by(&:noteable_id) - - ids.each do |id| - loader.call(id, counts[id]&.count || 0) - end - end - end - def author Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find end diff --git a/app/graphql/types/jira_import_type.rb b/app/graphql/types/jira_import_type.rb index cf58a53b40d..b3854487cec 100644 --- a/app/graphql/types/jira_import_type.rb +++ b/app/graphql/types/jira_import_type.rb @@ -2,8 +2,7 @@ module Types # rubocop: disable Graphql/AuthorizeTypes - # Authorization is at project level for owners or admins, - # so it is added directly to the Resolvers::JiraImportsResolver + # Authorization is at project level for owners or admins class JiraImportType < BaseObject graphql_name 'JiraImport' diff --git a/app/graphql/types/jira_users_mapping_input_type.rb b/app/graphql/types/jira_users_mapping_input_type.rb index 61cf1474493..d5b4b2f618a 100644 --- a/app/graphql/types/jira_users_mapping_input_type.rb +++ b/app/graphql/types/jira_users_mapping_input_type.rb @@ -8,7 +8,7 @@ module Types argument :jira_account_id, GraphQL::STRING_TYPE, required: true, - description: 'Jira account id of the user' + description: 'Jira account ID of the user' argument :gitlab_id, GraphQL::INT_TYPE, required: false, diff --git a/app/graphql/types/merge_request_connection_type.rb b/app/graphql/types/merge_request_connection_type.rb new file mode 100644 index 00000000000..da06bb86929 --- /dev/null +++ b/app/graphql/types/merge_request_connection_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class MergeRequestConnectionType < Types::CountableConnectionType + field :total_time_to_merge, GraphQL::FLOAT_TYPE, null: true, + description: 'Total sum of time to merge, in seconds, for the collection of merge requests' + + # rubocop: disable CodeReuse/ActiveRecord + def total_time_to_merge + object.items.reorder(nil).total_time_to_merge + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index e68d6706c43..816160e58f7 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -4,7 +4,7 @@ module Types class MergeRequestType < BaseObject graphql_name 'MergeRequest' - connection_type_class(Types::CountableConnectionType) + connection_type_class(Types::MergeRequestConnectionType) implements(Types::Notes::NoteableType) implements(Types::CurrentUserTodos) @@ -49,6 +49,8 @@ module Types description: 'ID of the merge request target project' field :source_branch, GraphQL::STRING_TYPE, null: false, description: 'Source branch of the merge request' + field :source_branch_protected, GraphQL::BOOLEAN_TYPE, null: false, calls_gitaly: true, + description: 'Indicates if the source branch is protected' field :target_branch, GraphQL::STRING_TYPE, null: false, description: 'Target branch of the merge request' field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false, @@ -67,9 +69,11 @@ module Types field :merge_commit_sha, GraphQL::STRING_TYPE, null: true, description: 'SHA of the merge request commit (set once merged)' field :user_notes_count, GraphQL::INT_TYPE, null: true, - description: 'User notes count of the merge request' + description: 'User notes count of the merge request', + resolver: Resolvers::UserNotesCountResolver field :user_discussions_count, GraphQL::INT_TYPE, null: true, - description: 'Number of user discussions in the merge request' + description: 'Number of user discussions in the merge request', + resolver: Resolvers::UserDiscussionsCountResolver field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true, description: 'Indicates if the source branch of the merge request will be deleted after merge' field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true, @@ -90,6 +94,8 @@ module Types description: 'Indicates if there is a rebase currently in progress for the merge request' field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true, description: 'Default merge commit message of the merge request' + field :default_merge_commit_message_with_description, GraphQL::STRING_TYPE, null: true, + description: 'Default merge commit message of the merge request with description' field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false, description: 'Indicates if a merge is currently occurring' field :source_branch_exists, GraphQL::BOOLEAN_TYPE, @@ -113,7 +119,7 @@ module Types description: 'The pipeline running on the branch HEAD of the merge request' field :pipelines, null: true, - description: 'Pipelines for the merge request', + description: 'Pipelines for the merge request. Note: for performance reasons, no more than the most recent 500 pipelines will be returned.', resolver: Resolvers::MergeRequestPipelinesResolver field :milestone, Types::MilestoneType, null: true, @@ -130,8 +136,7 @@ module Types description: 'Labels of the merge request' field :discussion_locked, GraphQL::BOOLEAN_TYPE, description: 'Indicates if comments on the merge request are locked to members only', - null: false, - resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked } + null: false field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'Time estimate of the merge request' field :total_time_spent, GraphQL::INT_TYPE, null: false, @@ -152,6 +157,18 @@ module Types field :approved_by, Types::UserType.connection_type, null: true, description: 'Users who approved the merge request' + field :squash_on_merge, GraphQL::BOOLEAN_TYPE, null: false, method: :squash_on_merge?, + description: 'Indicates if squash on merge is enabled' + field :available_auto_merge_strategies, [GraphQL::STRING_TYPE], null: true, calls_gitaly: true, + description: 'Array of available auto merge strategies' + field :has_ci, GraphQL::BOOLEAN_TYPE, null: false, method: :has_ci?, + description: 'Indicates if the merge request has CI' + field :mergeable, GraphQL::BOOLEAN_TYPE, null: false, method: :mergeable?, calls_gitaly: true, + description: 'Indicates if the merge request is mergeable' + field :commits_without_merge_commits, Types::CommitType.connection_type, null: true, + calls_gitaly: true, description: 'Merge request commits excluding merge commits' + field :security_auto_fix, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if the merge request is created by @GitLab-Security-Bot.' def approved_by object.approved_by_users @@ -194,6 +211,31 @@ module Types def commit_count object&.metrics&.commits_count end + + def source_branch_protected + object.source_project.present? && ProtectedBranch.protected?(object.source_project, object.source_branch) + end + + def discussion_locked + !!object.discussion_locked + end + + def default_merge_commit_message_with_description + object.default_merge_commit_message(include_description: true) + end + + def available_auto_merge_strategies + AutoMergeService.new(object.project, current_user).available_strategies(object) + end + + def commits_without_merge_commits + object.recent_commits.without_merge_commits + end + + def security_auto_fix + object.author == User.security_bot + end end end + Types::MergeRequestType.prepend_if_ee('::EE::Types::MergeRequestType') diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 75ccac6d590..9eea81c9d3e 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -31,6 +31,7 @@ module Types mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji mount_mutation Mutations::Discussions::ToggleResolve + mount_mutation Mutations::Environments::CanaryIngress::Update mount_mutation Mutations::Issues::Create mount_mutation Mutations::Issues::SetAssignees mount_mutation Mutations::Issues::SetConfidential @@ -65,6 +66,8 @@ module Types mount_mutation Mutations::Notes::RepositionImageDiffNote mount_mutation Mutations::Notes::Destroy mount_mutation Mutations::Releases::Create + mount_mutation Mutations::Releases::Update + mount_mutation Mutations::Releases::Delete mount_mutation Mutations::Terraform::State::Delete mount_mutation Mutations::Terraform::State::Lock mount_mutation Mutations::Terraform::State::Unlock @@ -84,6 +87,7 @@ module Types mount_mutation Mutations::DesignManagement::Move mount_mutation Mutations::ContainerExpirationPolicies::Update mount_mutation Mutations::ContainerRepositories::Destroy + mount_mutation Mutations::ContainerRepositories::DestroyTags mount_mutation Mutations::Ci::PipelineCancel mount_mutation Mutations::Ci::PipelineDestroy mount_mutation Mutations::Ci::PipelineRetry diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index fbdf049b755..4dec6f4c5e6 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -21,6 +21,7 @@ module Types field :description, GraphQL::STRING_TYPE, null: true, description: 'Description of the namespace' markdown_field :description_html, null: true + field :visibility, GraphQL::STRING_TYPE, null: true, description: 'Visibility of the namespace' field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?, @@ -30,12 +31,15 @@ module Types field :root_storage_statistics, Types::RootStorageStatisticsType, null: true, - description: 'Aggregated storage statistics of the namespace. Only available for root namespaces', - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(obj.id).find } + description: 'Aggregated storage statistics of the namespace. Only available for root namespaces' field :projects, Types::ProjectType.connection_type, null: false, description: 'Projects within this namespace', resolver: ::Resolvers::NamespaceProjectsResolver + + def root_storage_statistics + Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find + end end end diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb index cc00feba2e6..13d9be49484 100644 --- a/app/graphql/types/notes/diff_position_type.rb +++ b/app/graphql/types/notes/diff_position_type.rb @@ -21,25 +21,43 @@ module Types # Fields for text positions field :old_line, GraphQL::INT_TYPE, null: true, - description: 'Line on start SHA that was changed', - resolve: -> (position, _args, _ctx) { position.old_line if position.on_text? } + description: 'Line on start SHA that was changed' field :new_line, GraphQL::INT_TYPE, null: true, - description: 'Line on HEAD SHA that was changed', - resolve: -> (position, _args, _ctx) { position.new_line if position.on_text? } + description: 'Line on HEAD SHA that was changed' # Fields for image positions field :x, GraphQL::INT_TYPE, null: true, - description: 'X position of the note', - resolve: -> (position, _args, _ctx) { position.x if position.on_image? } + description: 'X position of the note' field :y, GraphQL::INT_TYPE, null: true, - description: 'Y position of the note', - resolve: -> (position, _args, _ctx) { position.y if position.on_image? } + description: 'Y position of the note' field :width, GraphQL::INT_TYPE, null: true, - description: 'Total width of the image', - resolve: -> (position, _args, _ctx) { position.width if position.on_image? } + description: 'Total width of the image' field :height, GraphQL::INT_TYPE, null: true, - description: 'Total height of the image', - resolve: -> (position, _args, _ctx) { position.height if position.on_image? } + description: 'Total height of the image' + + def old_line + object.old_line if object.on_text? + end + + def new_line + object.new_line if object.on_text? + end + + def x + object.x if object.on_image? + end + + def y + object.y if object.on_image? + end + + def width + object.width if object.on_image? + end + + def height + object.height if object.on_image? + end end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index 5d41f0032bd..f4e05e19eca 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -16,13 +16,11 @@ module Types field :project, Types::ProjectType, null: true, - description: 'Project associated with the note', - resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, note.project_id).find } + description: 'Project associated with the note' field :author, Types::UserType, null: false, - description: 'User who wrote this note', - resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, note.author_id).find } + description: 'User who wrote this note' field :system, GraphQL::BOOLEAN_TYPE, null: false, @@ -52,6 +50,14 @@ module Types def system_note_icon_name SystemNoteHelper.system_note_icon_name(object) if object.system? end + + def project + Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find + end + + def author + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find + end end end end diff --git a/app/graphql/types/permission_types/merge_request.rb b/app/graphql/types/permission_types/merge_request.rb index e9c89b0c92e..52c11fe5588 100644 --- a/app/graphql/types/permission_types/merge_request.rb +++ b/app/graphql/types/permission_types/merge_request.rb @@ -19,7 +19,9 @@ module Types permission_field field_name, method: :"can_#{field_name}?", calls_gitaly: true end - permission_field :can_merge, calls_gitaly: true, resolve: -> (object, args, context) do + permission_field :can_merge, calls_gitaly: true + + def can_merge object.can_be_merged_by?(context[:current_user]) end end diff --git a/app/graphql/types/project_member_relation_enum.rb b/app/graphql/types/project_member_relation_enum.rb new file mode 100644 index 00000000000..fbad23b956f --- /dev/null +++ b/app/graphql/types/project_member_relation_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + class ProjectMemberRelationEnum < BaseEnum + graphql_name 'ProjectMemberRelation' + description 'Project member relation' + + ::MembersFinder::RELATIONS.each do |member_relation| + value member_relation.to_s.upcase, value: member_relation, description: "#{member_relation.to_s.titleize} members" + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 5a436886117..a7d9548610e 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -67,33 +67,25 @@ module Types description: 'E-mail address of the service desk.' field :avatar_url, GraphQL::STRING_TYPE, null: true, calls_gitaly: true, - description: 'URL to avatar image file of the project', - resolve: -> (project, args, ctx) do - project.avatar_url(only_path: false) - end + description: 'URL to avatar image file of the project' %i[issues merge_requests wiki snippets].each do |feature| field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, - description: "Indicates if #{feature.to_s.titleize.pluralize} are enabled for the current user", - resolve: -> (project, args, ctx) do - project.feature_available?(feature, ctx[:current_user]) - end + description: "Indicates if #{feature.to_s.titleize.pluralize} are enabled for the current user" + + define_method "#{feature}_enabled" do + object.feature_available?(feature, context[:current_user]) + end end field :jobs_enabled, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Indicates if CI/CD pipeline jobs are enabled for the current user', - resolve: -> (project, args, ctx) do - project.feature_available?(:builds, ctx[:current_user]) - end + description: 'Indicates if CI/CD pipeline jobs are enabled for the current user' field :public_jobs, GraphQL::BOOLEAN_TYPE, method: :public_builds, null: true, description: 'Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts' field :open_issues_count, GraphQL::INT_TYPE, null: true, - description: 'Number of open issues for the project', - resolve: -> (project, args, ctx) do - project.open_issues_count if project.feature_available?(:issues, ctx[:current_user]) - end + description: 'Number of open issues for the project' field :import_status, GraphQL::STRING_TYPE, null: true, description: 'Status of import background job of the project' @@ -115,6 +107,8 @@ module Types description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically' field :suggestion_commit_message, GraphQL::STRING_TYPE, null: true, description: 'The commit message used to apply merge request suggestions' + field :squash_read_only, GraphQL::BOOLEAN_TYPE, null: false, method: :squash_readonly?, + description: 'Indicates if squash readonly is enabled' field :namespace, Types::NamespaceType, null: true, description: 'Namespace of the project' @@ -123,8 +117,7 @@ module Types field :statistics, Types::ProjectStatisticsType, null: true, - description: 'Statistics of the project', - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find } + description: 'Statistics of the project' field :repository, Types::RepositoryType, null: true, description: 'Git repository of the project' @@ -198,6 +191,11 @@ module Types description: 'Build pipeline of the project', resolver: Resolvers::ProjectPipelineResolver + field :ci_cd_settings, + Types::Ci::CiCdSettingType, + null: true, + description: 'CI/CD settings for the project' + field :sentry_detailed_error, Types::ErrorTracking::SentryDetailedErrorType, null: true, @@ -238,8 +236,7 @@ module Types field :jira_imports, Types::JiraImportType.connection_type, null: true, - description: 'Jira imports into the project', - resolver: Resolvers::Projects::JiraImportsResolver + description: 'Jira imports into the project' field :services, Types::Projects::ServiceType.connection_type, @@ -296,6 +293,9 @@ module Types description: 'Container repositories of the project', resolver: Resolvers::ContainerRepositoriesResolver + field :container_repositories_count, GraphQL::INT_TYPE, null: false, + description: 'Number of container repositories in the project' + field :label, Types::LabelType, null: true, @@ -311,6 +311,13 @@ module Types description: 'Terraform states associated with the project', resolver: Resolvers::Terraform::StatesResolver + field :pipeline_analytics, Types::Ci::AnalyticsType, null: true, + description: 'Pipeline analytics', + resolver: Resolvers::ProjectPipelineStatisticsResolver + + field :total_pipeline_duration, GraphQL::INT_TYPE, null: true, + description: 'Total pipeline duration for all of the pipelines in a project' + def label(title:) BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| LabelsFinder @@ -335,6 +342,30 @@ module Types .execute end + def avatar_url + object.avatar_url(only_path: false) + end + + def jobs_enabled + object.feature_available?(:builds, context[:current_user]) + end + + def open_issues_count + object.open_issues_count if object.feature_available?(:issues, context[:current_user]) + end + + def statistics + Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(object.id).find + end + + def container_repositories_count + project.container_repositories.size + end + + def total_pipeline_duration + object.all_pipelines.total_duration + end + private def project diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index d194b0979b3..05bb371088c 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -24,7 +24,6 @@ module Types field :current_user, Types::UserType, null: true, - resolve: -> (_obj, _args, context) { context[:current_user] }, description: "Get information about current user" field :namespace, Types::NamespaceType, @@ -92,6 +91,11 @@ module Types description: 'Get runner setup instructions', resolver: Resolvers::Ci::RunnerSetupResolver + field :ci_config, Types::Ci::Config::ConfigType, null: true, + description: 'Get linted and processed contents of a CI config. Should not be requested more than once per request.', + resolver: Resolvers::Ci::ConfigResolver, + complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1 + def design_management DesignManagementObject.new(nil) end @@ -116,6 +120,10 @@ module Types id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id) GitlabSchema.find_by_gid(id) end + + def current_user + context[:current_user] + end end end diff --git a/app/graphql/types/snippets/blob_viewer_type.rb b/app/graphql/types/snippets/blob_viewer_type.rb index 50d0b0522d6..a2ffa144066 100644 --- a/app/graphql/types/snippets/blob_viewer_type.rb +++ b/app/graphql/types/snippets/blob_viewer_type.rb @@ -17,14 +17,12 @@ module Types field :collapsed, GraphQL::BOOLEAN_TYPE, description: 'Shows whether the blob should be displayed collapsed', method: :collapsed?, - null: false, - resolve: -> (viewer, _args, _ctx) { !!viewer&.collapsed? } + null: false field :too_large, GraphQL::BOOLEAN_TYPE, description: 'Shows whether the blob too large to be displayed', method: :too_large?, - null: false, - resolve: -> (viewer, _args, _ctx) { !!viewer&.too_large? } + null: false field :render_error, GraphQL::STRING_TYPE, description: 'Error rendering the blob content', @@ -38,6 +36,14 @@ module Types field :loading_partial_name, GraphQL::STRING_TYPE, description: 'Loading partial name', null: false + + def collapsed + !!object&.collapsed? + end + + def too_large + !!object&.too_large? + end end end end diff --git a/app/graphql/types/sort_enum.rb b/app/graphql/types/sort_enum.rb index d0a6eecb672..c3a76330fe9 100644 --- a/app/graphql/types/sort_enum.rb +++ b/app/graphql/types/sort_enum.rb @@ -7,10 +7,10 @@ module Types # Deprecated, as we prefer uppercase enums # https://gitlab.com/groups/gitlab-org/-/epics/1838 - value 'updated_desc', 'Updated at descending order', deprecated: { reason: 'Use UPDATED_DESC', milestone: '13.5' } - value 'updated_asc', 'Updated at ascending order', deprecated: { reason: 'Use UPDATED_ASC', milestone: '13.5' } - value 'created_desc', 'Created at descending order', deprecated: { reason: 'Use CREATED_DESC', milestone: '13.5' } - value 'created_asc', 'Created at ascending order', deprecated: { reason: 'Use CREATED_ASC', milestone: '13.5' } + value 'updated_desc', 'Updated at descending order', value: :updated_desc, deprecated: { reason: 'Use UPDATED_DESC', milestone: '13.5' } + value 'updated_asc', 'Updated at ascending order', value: :updated_asc, deprecated: { reason: 'Use UPDATED_ASC', milestone: '13.5' } + value 'created_desc', 'Created at descending order', value: :created_desc, deprecated: { reason: 'Use CREATED_DESC', milestone: '13.5' } + value 'created_asc', 'Created at ascending order', value: :created_asc, deprecated: { reason: 'Use CREATED_ASC', milestone: '13.5' } value 'UPDATED_DESC', 'Updated at descending order', value: :updated_desc value 'UPDATED_ASC', 'Updated at ascending order', value: :updated_asc diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb index 05b6d130f19..d97e673bf31 100644 --- a/app/graphql/types/terraform/state_type.rb +++ b/app/graphql/types/terraform/state_type.rb @@ -19,9 +19,7 @@ module Types field :locked_by_user, Types::UserType, null: true, - authorize: :read_user, - description: 'The user currently holding a lock on the Terraform state', - resolve: -> (state, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, state.locked_by_user_id).find } + description: 'The user currently holding a lock on the Terraform state' field :locked_at, Types::TimeType, null: true, @@ -39,6 +37,10 @@ module Types field :updated_at, Types::TimeType, null: false, description: 'Timestamp the Terraform state was updated' + + def locked_by_user + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.locked_by_user_id).find + end end end end diff --git a/app/graphql/types/terraform/state_version_type.rb b/app/graphql/types/terraform/state_version_type.rb index b1fbe42ecaf..a3af5c876ca 100644 --- a/app/graphql/types/terraform/state_version_type.rb +++ b/app/graphql/types/terraform/state_version_type.rb @@ -3,6 +3,8 @@ module Types module Terraform class StateVersionType < BaseObject + include ::API::Helpers::RelatedResourcesHelpers + graphql_name 'TerraformStateVersion' authorize :read_terraform_state @@ -13,15 +15,20 @@ module Types field :created_by_user, Types::UserType, null: true, - authorize: :read_user, - description: 'The user that created this version', - resolve: -> (version, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, version.created_by_user_id).find } + description: 'The user that created this version' + + field :download_path, GraphQL::STRING_TYPE, + null: true, + description: "URL for downloading the version's JSON file" field :job, Types::Ci::JobType, null: true, - authorize: :read_build, - description: 'The job that created this version', - resolve: -> (version, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Build, version.ci_build_id).find } + description: 'The job that created this version' + + field :serial, GraphQL::INT_TYPE, + null: true, + description: 'Serial number of the version', + method: :version field :created_at, Types::TimeType, null: false, @@ -30,6 +37,22 @@ module Types field :updated_at, Types::TimeType, null: false, description: 'Timestamp the version was updated' + + def created_by_user + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.created_by_user_id).find + end + + def download_path + expose_path api_v4_projects_terraform_state_versions_path( + id: object.project_id, + name: object.terraform_state.name, + serial: object.version + ) + end + + def job + Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Build, object.ci_build_id).find + end end end end diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb index 4f21da3d897..3694980ef93 100644 --- a/app/graphql/types/todo_type.rb +++ b/app/graphql/types/todo_type.rb @@ -16,19 +16,16 @@ module Types field :project, Types::ProjectType, description: 'The project this todo is associated with', null: true, - authorize: :read_project, - resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, todo.project_id).find } + authorize: :read_project field :group, Types::GroupType, description: 'Group this todo is associated with', null: true, - authorize: :read_group, - resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, todo.group_id).find } + authorize: :read_group field :author, Types::UserType, description: 'The author of this todo', - null: false, - resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, todo.author_id).find } + null: false field :action, Types::TodoActionEnum, description: 'Action of the todo', @@ -50,5 +47,17 @@ module Types field :created_at, Types::TimeType, description: 'Timestamp this todo was created', null: false + + def project + Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find + end + + def group + Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.group_id).find + end + + def author + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find + end end end diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb index cc6bf7b4f00..a7b90d2533b 100644 --- a/app/graphql/types/tree/blob_type.rb +++ b/app/graphql/types/tree/blob_type.rb @@ -15,13 +15,14 @@ module Types field :web_path, GraphQL::STRING_TYPE, null: true, description: 'Web path of the blob' field :lfs_oid, GraphQL::STRING_TYPE, null: true, - description: 'LFS ID of the blob', - resolve: -> (blob, args, ctx) do - Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find - end + description: 'LFS ID of the blob' field :mode, GraphQL::STRING_TYPE, null: true, description: 'Blob mode in numeric format' - # rubocop: enable Graphql/AuthorizeTypes + + def lfs_oid + Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(object.repository, object.id).find + end end + # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb index b9fb6b28e71..fecd6c0f309 100644 --- a/app/graphql/types/tree/tree_type.rb +++ b/app/graphql/types/tree/tree_type.rb @@ -8,27 +8,32 @@ module Types # Complexity 10 as it triggers a Gitaly call on each render field :last_commit, Types::CommitType, - null: true, complexity: 10, calls_gitaly: true, resolver: Resolvers::LastCommitResolver, - description: 'Last commit for the tree' + null: true, complexity: 10, calls_gitaly: true, resolver: Resolvers::LastCommitResolver, + description: 'Last commit for the tree' field :trees, Types::Tree::TreeEntryType.connection_type, null: false, - description: 'Trees of the tree', - resolve: -> (obj, args, ctx) do - Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository) - end + description: 'Trees of the tree' field :submodules, Types::Tree::SubmoduleType.connection_type, null: false, description: 'Sub-modules of the tree', - calls_gitaly: true, resolve: -> (obj, args, ctx) do - Gitlab::Graphql::Representation::SubmoduleTreeEntry.decorate(obj.submodules, obj) - end + calls_gitaly: true field :blobs, Types::Tree::BlobType.connection_type, null: false, description: 'Blobs of the tree', - calls_gitaly: true, resolve: -> (obj, args, ctx) do - Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository) - end - # rubocop: enable Graphql/AuthorizeTypes + calls_gitaly: true + + def trees + Gitlab::Graphql::Representation::TreeEntry.decorate(object.trees, object.repository) + end + + def submodules + Gitlab::Graphql::Representation::SubmoduleTreeEntry.decorate(object.submodules, object) + end + + def blobs + Gitlab::Graphql::Representation::TreeEntry.decorate(object.blobs, object.repository) + end end + # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 783a0d8425a..93503268319 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -19,7 +19,10 @@ module Types field :state, Types::UserStateEnum, null: false, description: 'State of the user' field :email, GraphQL::STRING_TYPE, null: true, - description: 'User email', method: :public_email + description: 'User email', method: :public_email, + deprecated: { reason: 'Use public_email', milestone: '13.7' } + field :public_email, GraphQL::STRING_TYPE, null: true, + description: "User's public email" field :avatar_url, GraphQL::STRING_TYPE, null: true, description: "URL of the user's avatar" field :web_url, GraphQL::STRING_TYPE, null: false, @@ -37,19 +40,24 @@ module Types feature_flag: :user_group_counts field :status, Types::UserStatusType, null: true, description: 'User status' + field :location, ::GraphQL::STRING_TYPE, null: true, + description: 'The location of the user.' field :project_memberships, Types::ProjectMemberType.connection_type, null: true, description: 'Project memberships of the user' field :starred_projects, Types::ProjectType.connection_type, null: true, description: 'Projects starred by the user', resolver: Resolvers::UserStarredProjectsResolver - # Merge request field: MRs can be either authored or assigned: + # Merge request field: MRs can be authored, assigned, or assigned-for-review: field :authored_merge_requests, resolver: Resolvers::AuthoredMergeRequestsResolver, description: 'Merge Requests authored by the user' field :assigned_merge_requests, resolver: Resolvers::AssignedMergeRequestsResolver, description: 'Merge Requests assigned to the user' + field :review_requested_merge_requests, + resolver: Resolvers::ReviewRequestedMergeRequestsResolver, + description: 'Merge Requests assigned to the user for review' field :snippets, Types::SnippetType.connection_type, diff --git a/app/helpers/admin/user_actions_helper.rb b/app/helpers/admin/user_actions_helper.rb new file mode 100644 index 00000000000..cd520a75b44 --- /dev/null +++ b/app/helpers/admin/user_actions_helper.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Admin + module UserActionsHelper + def admin_actions(user) + return [] if user.internal? + + @actions ||= ['edit'] + + return @actions if user == current_user + + @user ||= user + + blocked_actions + deactivate_actions + unlock_actions + delete_actions + + @actions + end + + private + + def blocked_actions + if @user.ldap_blocked? + @actions << 'ldap' + elsif @user.blocked? && @user.blocked_pending_approval? + @actions << 'approve' + @actions << 'reject' + elsif @user.blocked? + @actions << 'unblock' + else + @actions << 'block' + end + end + + def deactivate_actions + if @user.can_be_deactivated? + @actions << 'deactivate' + elsif @user.deactivated? + @actions << 'activate' + end + end + + def unlock_actions + @actions << 'unlock' if @user.access_locked? + end + + def delete_actions + return unless can?(current_user, :destroy_user, @user) && !@user.blocked_pending_approval? && @user.can_be_removed? + + @actions << 'delete' + @actions << 'delete_with_contributions' + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2a6b00c0bd8..512ba7e2a66 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -361,9 +361,13 @@ module ApplicationHelper } end - def add_page_specific_style(path) + def add_page_specific_style(path, defer: true) content_for :page_specific_styles do - stylesheet_link_tag_defer path + if defer + stylesheet_link_tag_defer path + else + stylesheet_link_tag path + end end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 512649b3008..7866e3e3d9f 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -49,12 +49,12 @@ module ApplicationSettingsHelper all_protocols_enabled? || Gitlab::CurrentSettings.enabled_git_access_protocol == 'http' end - def enabled_project_button(project, protocol) + def enabled_protocol_button(container, protocol) case protocol when 'ssh' - ssh_clone_button(project, append_link: false) + ssh_clone_button(container, append_link: false) else - http_clone_button(project, append_link: false) + http_clone_button(container, append_link: false) end end @@ -198,6 +198,7 @@ module ApplicationSettingsHelper :default_project_visibility, :default_projects_limit, :default_snippet_visibility, + :disable_feed_token, :disabled_oauth_sign_in_sources, :domain_denylist, :domain_denylist_enabled, @@ -254,6 +255,9 @@ module ApplicationSettingsHelper :password_authentication_enabled_for_git, :performance_bar_allowed_group_path, :performance_bar_enabled, + :personal_access_token_prefix, + :kroki_enabled, + :kroki_url, :plantuml_enabled, :plantuml_url, :polling_interval_multiplier, diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index cc43ea85a11..0b79d4c36a1 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -113,6 +113,10 @@ module AuthHelper end end + def experiment_enabled_button_based_providers + enabled_button_based_providers & %w(google_oauth2 github).freeze + end + def button_based_providers_enabled? enabled_button_based_providers.any? end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 981b5e4d92b..0c5823894c5 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true module BlobHelper - def no_highlight_files - %w(credits changelog news copying copyright license authors) - end - def edit_blob_path(project = @project, ref = @ref, path = @path, options = {}) project_edit_blob_path(project, tree_join(ref, path), @@ -246,7 +242,7 @@ module BlobHelper def copy_blob_source_button(blob) return unless blob.rendered_as_text?(ignore_errors: false) - clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: _("Copy file contents")) + clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}'] > pre", class: "btn btn-sm js-copy-blob-source-btn", title: _("Copy file contents")) end def open_raw_blob_button(blob) @@ -332,8 +328,9 @@ module BlobHelper end def readable_blob(options, path, project, ref) - blob = options.delete(:blob) - blob ||= project.repository.blob_at(ref, path) rescue nil + blob = options.fetch(:blob) do + project.repository.blob_at(ref, path) rescue nil + end blob if blob&.readable_text? end @@ -382,8 +379,7 @@ module BlobHelper end def show_suggest_pipeline_creation_celebration? - Feature.enabled?(:suggest_pipeline, default_enabled: true) && - @blob.path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] && + @blob.path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] && @blob.auxiliary_viewer&.valid?(project: @project, sha: @commit.sha, user: current_user) && @project.uses_default_ci_config? && cookies[suggest_pipeline_commit_cookie_name].present? diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index c999d1f94ad..ea24f469ffa 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -58,10 +58,10 @@ module ButtonHelper end end - def http_clone_button(project, append_link: true) + def http_clone_button(container, append_link: true) protocol = gitlab_config.protocol.upcase dropdown_description = http_dropdown_description(protocol) - append_url = project.http_url_to_repo if append_link + append_url = container.http_url_to_repo if append_link dropdown_item_with_description(protocol, dropdown_description, href: append_url, data: { clone_type: 'http' }) end @@ -74,13 +74,13 @@ module ButtonHelper end end - def ssh_clone_button(project, append_link: true) + def ssh_clone_button(container, append_link: true) if Gitlab::CurrentSettings.user_show_add_ssh_key_message? && current_user.try(:require_ssh_key?) - dropdown_description = _("You won't be able to pull or push project code via SSH until you add an SSH key to your profile") + dropdown_description = s_("MissingSSHKeyWarningLink|You won't be able to pull or push repositories via SSH until you add an SSH key to your profile") end - append_url = project.ssh_url_to_repo if append_link + append_url = container.ssh_url_to_repo if append_link dropdown_item_with_description('SSH', dropdown_description, href: append_url, data: { clone_type: 'ssh' }) end diff --git a/app/helpers/ci/pipeline_schedules_helper.rb b/app/helpers/ci/pipeline_schedules_helper.rb deleted file mode 100644 index 20e5c90a60e..00000000000 --- a/app/helpers/ci/pipeline_schedules_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Ci - module PipelineSchedulesHelper - def timezone_data - ActiveSupport::TimeZone.all.map do |timezone| - { - name: timezone.name, - offset: timezone.now.utc_offset, - identifier: timezone.tzinfo.identifier - } - end - end - end -end diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 432aad663e4..ba5d4e8c65a 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -8,14 +8,14 @@ module Ci status = runner.status case status when :not_connected - content_tag(:span, title: "New runner. Has not connected yet") do + content_tag(:span, title: _("New runner. Has not connected yet")) do sprite_icon("warning-solid", size: 24, css_class: "gl-vertical-align-bottom!") end when :online, :offline, :paused - content_tag :i, nil, - class: "fa fa-circle runner-status-#{status}", - title: "Runner is #{status}, last contact was #{time_ago_in_words(runner.contacted_at)} ago" + content_tag :span, nil, + class: "gl-display-inline-block gl-avatar gl-avatar-s16 gl-avatar-circle runner-status runner-status-#{status}", + title: _("Runner is %{status}, last contact was %{runner_contact} ago") % { status: status, runner_contact: time_ago_in_words(runner.contacted_at) } end end @@ -49,6 +49,14 @@ module Ci parent_shared_runners_availability: group.parent&.shared_runners_setting } end + + def toggle_shared_runners_settings_data(project) + { + is_enabled: "#{project.shared_runners_enabled?}", + is_disabled_and_unoverridable: "#{project.group&.shared_runners_setting == 'disabled_and_unoverridable'}", + update_path: toggle_shared_runners_project_runners_path(project) + } + end end end diff --git a/app/helpers/container_registry_helper.rb b/app/helpers/container_registry_helper.rb index 9a5d84a90dd..0efc8c50d58 100644 --- a/app/helpers/container_registry_helper.rb +++ b/app/helpers/container_registry_helper.rb @@ -5,4 +5,8 @@ module ContainerRegistryHelper Feature.enabled?(:container_registry_expiration_policies_throttling) && ContainerRegistry::Client.supports_tag_delete? end + + def container_repository_gid_prefix + "gid://#{GlobalID.app}/#{ContainerRepository.name}/" + end end diff --git a/app/helpers/defer_script_tag_helper.rb b/app/helpers/defer_script_tag_helper.rb deleted file mode 100644 index be927c67aaa..00000000000 --- a/app/helpers/defer_script_tag_helper.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module DeferScriptTagHelper - # Override the default ActionView `javascript_include_tag` helper to support page specific deferred loading. - # PLEASE NOTE: `defer` is also critical so that we don't run JavaScript entrypoints before the DOM is ready. - # Please see https://gitlab.com/groups/gitlab-org/-/epics/4538#note_432159769. - def javascript_include_tag(*sources) - super(*sources, defer: true) - end -end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index d6d06434590..69a2efebb1f 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -203,14 +203,6 @@ module DiffHelper set_secure_cookie(:diff_view, params.delete(:view), type: CookiesHelper::COOKIE_TYPE_PERMANENT) if params[:view].present? end - def unified_diff_lines_view_type(project) - if Feature.enabled?(:unified_diff_lines, project, default_enabled: true) - 'inline' - else - diff_view - end - end - private def diff_btn(title, name, selected) diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index e10e9a83b05..45f5281b515 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -51,7 +51,7 @@ module DropdownsHelper default_label = data_attr[:default_label] content_tag(:button, disabled: options[:disabled], class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}") - output << icon('chevron-down') + output << sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3") output.html_safe end end diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index c4487ae8e4a..491d2731e91 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -52,6 +52,8 @@ module EnvironmentHelper s_('Deployment|failed') when 'canceled' s_('Deployment|canceled') + when 'skipped' + s_('Deployment|skipped') end klass = "ci-status ci-#{status.dasherize}" diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index f40755b9439..e6603237676 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -256,7 +256,7 @@ module EventsHelper end else content_tag :div, class: 'system-note-image user-avatar' do - author_avatar(event, size: 40) + author_avatar(event, size: 32) end end end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 8a8d708b0b2..d0276c91316 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -55,7 +55,7 @@ module FormHelper dropdown_data end - def reviewers_dropdown_options(issuable_type) + def reviewers_dropdown_options(issuable_type, iid = nil, target_branch = nil) dropdown_data = { toggle_class: 'js-reviewer-search js-multiselect js-save-user-data', title: 'Request review from', @@ -78,6 +78,14 @@ module FormHelper } } + if iid + dropdown_data[:data][:iid] = iid + end + + if target_branch + dropdown_data[:data][:target_branch] = target_branch + end + if merge_request_supports_multiple_reviewers? dropdown_data = multiple_reviewers_dropdown_options(dropdown_data) end diff --git a/app/helpers/gitlab_script_tag_helper.rb b/app/helpers/gitlab_script_tag_helper.rb new file mode 100644 index 00000000000..467f3f7305b --- /dev/null +++ b/app/helpers/gitlab_script_tag_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module GitlabScriptTagHelper + # Override the default ActionView `javascript_include_tag` helper to support page specific deferred loading. + # PLEASE NOTE: `defer` is also critical so that we don't run JavaScript entrypoints before the DOM is ready. + # Please see https://gitlab.com/groups/gitlab-org/-/epics/4538#note_432159769. + # The helper also makes sure the `nonce` attribute is included in every script when the content security + # policy is enabled. + def javascript_include_tag(*sources) + super(*sources, defer: true, nonce: true) + end + + # The helper makes sure the `nonce` attribute is included in every script when the content security + # policy is enabled. + def javascript_tag(content_or_options_with_block = nil, html_options = {}) + if content_or_options_with_block.is_a?(Hash) + content_or_options_with_block[:nonce] = true + else + html_options[:nonce] = true + end + + super + end +end diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index ee90585112b..adc9d85a384 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -26,7 +26,8 @@ module Groups::GroupMembersHelper { members: members_data_json(group, members), member_path: group_group_member_path(group, ':id'), - group_id: group.id + group_id: group.id, + can_manage_members: can?(current_user, :admin_group_member, group).to_s } end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 29ead76a607..e8eb6a5d417 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -21,7 +21,6 @@ module GroupsHelper integrations#edit ldap_group_links#index hooks#index - audit_events#index pipeline_quota#index ] end @@ -189,6 +188,10 @@ module GroupsHelper params.key?(:purchased_quantity) && params[:purchased_quantity].to_i > 0 end + def project_list_sort_by + @group_projects_sort || @sort || params[:sort] || sort_value_recently_created + end + private def just_created? diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index dc6164ee898..096a3f2269b 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -4,25 +4,9 @@ require 'json' module IconsHelper extend self - include FontAwesome::Rails::IconHelper DEFAULT_ICON_SIZE = 16 - # Creates an icon tag given icon name(s) and possible icon modifiers. - # - # Right now this method simply delegates directly to `fa_icon` from the - # font-awesome-rails gem, but should we ever use a different icon pack in the - # future we won't have to change hundreds of method calls. - def icon(names, options = {}) - if (options.keys & %w[aria-hidden aria-label data-hidden]).empty? - # Add 'aria-hidden' and 'data-hidden' if they are not set in options. - options['aria-hidden'] = true - options['data-hidden'] = true - end - - options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) - end - def custom_icon(icon_name, size: DEFAULT_ICON_SIZE) memoized_icon("#{icon_name}_#{size}") do # We can't simply do the below, because there are some .erb SVGs. @@ -95,9 +79,9 @@ module IconsHelper def boolean_to_icon(value) if value - sprite_icon('check', css_class: 'cgreen') + sprite_icon('check', css_class: 'gl-text-green-500') else - sprite_icon('power', css_class: 'clgray') + sprite_icon('power', css_class: 'gl-text-gray-500') end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 77ced17bc22..15842dec3dd 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -61,16 +61,6 @@ module IssuablesHelper end end - def issuable_json_path(issuable) - project = issuable.project - - if issuable.is_a?(MergeRequest) - project_merge_request_path(project, issuable.iid, :json) - else - project_issue_path(project, issuable.iid, :json) - end - end - def serialize_issuable(issuable, opts = {}) serializer_klass = case issuable when Issue @@ -174,30 +164,26 @@ module IssuablesHelper h(title || default_label) end - def to_url_reference(issuable) - case issuable - when Issue - link_to issuable.to_reference, issue_url(issuable) - when MergeRequest - link_to issuable.to_reference, merge_request_url(issuable) - else - issuable.to_reference - end + def issuable_meta_author_status(author) + return "" unless show_status_emoji?(author&.status) && status = user_status(author) + + "#{status}".html_safe end - def issuable_meta(issuable, project, text) + def issuable_meta(issuable, project) output = [] output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe + if issuable.is_a?(Issue) && issuable.service_desk_reply_to + output << "#{html_escape(issuable.service_desk_reply_to)} via " + end + output << content_tag(:strong) do author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline") author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none") author_output << issuable_meta_author_slot(issuable.author, css_class: 'ml-1') - - if status = user_status(issuable.author) - author_output << "#{status}".html_safe - end + author_output << issuable_meta_author_status(issuable.author) author_output end @@ -336,42 +322,10 @@ module IssuablesHelper issuable_path(issuable, close_reopen_params(issuable, :reopen)) end - def close_reopen_issuable_path(issuable, should_inverse = false) - issuable.closed? ^ should_inverse ? reopen_issuable_path(issuable) : close_issuable_path(issuable) - end - - def toggle_draft_issuable_path(issuable) - wip_event = issuable.work_in_progress? ? 'unwip' : 'wip' - - issuable_path(issuable, { merge_request: { wip_event: wip_event } }) - end - def issuable_path(issuable, *options) polymorphic_path(issuable, *options) end - def issuable_url(issuable, *options) - case issuable - when Issue - issue_url(issuable, *options) - when MergeRequest - merge_request_url(issuable, *options) - end - end - - def issuable_button_visibility(issuable, closed) - return 'hidden' if issuable_button_hidden?(issuable, closed) - end - - def issuable_button_hidden?(issuable, closed) - case issuable - when Issue - issue_button_hidden?(issuable, closed) - when MergeRequest - merge_request_button_hidden?(issuable, closed) - end - end - def issuable_author_is_current_user(issuable) issuable.author == current_user end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index dee009cd3ab..0a9965496b8 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -32,14 +32,6 @@ module IssuesHelper end end - def issue_button_visibility(issue, closed) - return 'hidden' if issue_button_hidden?(issue, closed) - end - - def issue_button_hidden?(issue, closed) - issue.closed? == closed || (!closed && issue.discussion_locked) - end - def confidential_icon(issue) sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential? end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index ed8931fe0f2..25d56ffca2c 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -126,16 +126,7 @@ module MarkupHelper text = wiki_page.content return '' unless text.present? - context.merge!( - pipeline: :wiki, - project: @project, - wiki: @wiki, - repository: @wiki.repository, - page_slug: wiki_page.slug, - issuable_state_filter_enabled: true - ) - - html = markup_unsafe(wiki_page.path, text, context) + html = markup_unsafe(wiki_page.path, text, render_wiki_content_context(@wiki, wiki_page, context)) prepare_for_rendering(html, context) end @@ -182,6 +173,20 @@ module MarkupHelper private + def render_wiki_content_context(wiki, wiki_page, context) + context.merge( + pipeline: :wiki, + wiki: wiki, + repository: wiki.repository, + page_slug: wiki_page.slug, + issuable_state_filter_enabled: true + ).merge(render_wiki_content_context_container(wiki)) + end + + def render_wiki_content_context_container(wiki) + { project: wiki.container } + end + # Return +text+, truncated to +max_chars+ characters, excluding any HTML # tags. def truncate_visible(text, max_chars) @@ -311,3 +316,5 @@ module MarkupHelper extend self end + +MarkupHelper.prepend_if_ee('EE::MarkupHelper') diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 9cb7edbaeb6..37e701c1c2b 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -39,19 +39,6 @@ module MergeRequestsHelper end end - def ci_build_details_path(merge_request) - build_url = merge_request.source_project.ci_service.build_page(merge_request.diff_head_sha, merge_request.source_branch) - return unless build_url - - parsed_url = URI.parse(build_url) - - unless parsed_url.userinfo.blank? - parsed_url.userinfo = '' - end - - parsed_url.to_s - end - def merge_path_description(merge_request, separator) if merge_request.for_fork? "Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.full_path}:#{@merge_request.target_branch}" @@ -96,7 +83,7 @@ module MergeRequestsHelper end def merge_request_button_hidden?(merge_request, closed) - merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_without_fork? + merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_or_merged_without_fork? end def merge_request_version_path(project, merge_request, merge_request_diff, start_sha = nil) @@ -166,6 +153,12 @@ module MergeRequestsHelper current_user.fork_of(project) end end + + def toggle_draft_merge_request_path(issuable) + wip_event = issuable.work_in_progress? ? 'unwip' : 'wip' + + issuable_path(issuable, { merge_request: { wip_event: wip_event } }) + end end MergeRequestsHelper.prepend_if_ee('EE::MergeRequestsHelper') diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 61fcda6a504..2b68d953431 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -106,7 +106,8 @@ module NotificationsHelper when :success_pipeline s_('NotificationEvent|Successful pipeline') else - s_(event.to_s.humanize) + event_name = "NotificationEvent|#{event.to_s.humanize}" + s_(event_name) end end diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb index fb68029928c..db7527d9d58 100644 --- a/app/helpers/notify_helper.rb +++ b/app/helpers/notify_helper.rb @@ -5,7 +5,7 @@ module NotifyHelper link_to(entity.to_reference, merge_request_url(entity, *args)) end - def issue_reference_link(entity, *args) - link_to(entity.to_reference, issue_url(entity, *args)) + def issue_reference_link(entity, *args, full: false) + link_to(entity.to_reference(full: full), issue_url(entity, *args)) end end diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb index 8105fce10cf..6d721776f0d 100644 --- a/app/helpers/operations_helper.rb +++ b/app/helpers/operations_helper.rb @@ -9,24 +9,14 @@ module OperationsHelper end end - def alerts_service - strong_memoize(:alerts_service) do - @project.find_or_initialize_service(::AlertsService.to_param) - end - end - def alerts_settings_data(disabled: false) { 'prometheus_activated' => prometheus_service.manual_configuration?.to_s, - 'activated' => alerts_service.activated?.to_s, 'prometheus_form_path' => scoped_integration_path(prometheus_service), - 'form_path' => scoped_integration_path(alerts_service), 'prometheus_reset_key_path' => reset_alerting_token_project_settings_operations_path(@project), 'prometheus_authorization_key' => @project.alerting_setting&.token, 'prometheus_api_url' => prometheus_service.api_url, - 'authorization_key' => alerts_service.token, 'prometheus_url' => notify_project_prometheus_alerts_url(@project, format: :json), - 'url' => alerts_service.url, 'alerts_setup_url' => help_page_path('operations/incident_management/alert_integrations.md', anchor: 'generic-http-endpoint'), 'alerts_usage_url' => project_alert_management_index_path(@project), 'disabled' => disabled.to_s, diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 04a3b915493..87187e97df4 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -37,10 +37,4 @@ module ProfilesHelper def user_status_set_to_busy?(status) status&.availability == availability_values[:busy] end - - def show_status_emoji?(status) - return false unless status - - status.message.present? || status.emoji != UserStatus::DEFAULT_EMOJI - end end diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb index c6ad6bfac01..997551d9659 100644 --- a/app/helpers/projects/alert_management_helper.rb +++ b/app/helpers/projects/alert_management_helper.rb @@ -28,7 +28,7 @@ module Projects::AlertManagementHelper def alert_management_enabled?(project) !!( - project.alerts_service_activated? || + project.alert_management_alerts.any? || project.prometheus_service_active? || AlertManagement::HttpIntegrationsFinder.new(project, active: true).execute.any? ) diff --git a/app/helpers/projects/terraform_helper.rb b/app/helpers/projects/terraform_helper.rb index b286bc4d7a5..621d97ffb69 100644 --- a/app/helpers/projects/terraform_helper.rb +++ b/app/helpers/projects/terraform_helper.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true module Projects::TerraformHelper - def js_terraform_list_data(project) + def js_terraform_list_data(current_user, project) { empty_state_image: image_path('illustrations/empty-state/empty-serverless-lg.svg'), - project_path: project.full_path + project_path: project.full_path, + terraform_admin: current_user&.can?(:admin_terraform_state, project) } end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index f25b229d198..80206654cd1 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -463,11 +463,12 @@ module ProjectsHelper issues: :read_issue, project_members: :read_project_member, wiki: :read_wiki, - feature_flags: :read_feature_flag + feature_flags: :read_feature_flag, + analytics: :read_analytics } end - def can_view_operations_tab?(current_user, project) + def view_operations_tab_ability [ :metrics_dashboard, :read_alert_management_alert, @@ -477,7 +478,13 @@ module ProjectsHelper :read_cluster, :read_feature_flag, :read_terraform_state - ].any? do |ability| + ] + end + + def can_view_operations_tab?(current_user, project) + return false unless project.feature_available?(:operations, current_user) + + view_operations_tab_ability.any? do |ability| can?(current_user, ability, project) end end @@ -606,6 +613,7 @@ module ProjectsHelper def project_permissions_settings(project) feature = project.project_feature + { packagesEnabled: !!project.packages_enabled, visibilityLevel: project.visibility_level, @@ -618,11 +626,14 @@ module ProjectsHelper wikiAccessLevel: feature.wiki_access_level, snippetsAccessLevel: feature.snippets_access_level, pagesAccessLevel: feature.pages_access_level, + analyticsAccessLevel: feature.analytics_access_level, containerRegistryEnabled: !!project.container_registry_enabled, lfsEnabled: !!project.lfs_enabled, emailsDisabled: project.emails_disabled?, metricsDashboardAccessLevel: feature.metrics_dashboard_access_level, - showDefaultAwardEmojis: project.show_default_award_emojis? + operationsAccessLevel: feature.operations_access_level, + showDefaultAwardEmojis: project.show_default_award_emojis?, + allowEditingCommitMessages: project.allow_editing_commit_messages? } end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index de1e0e4e05e..bdc86043ddc 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -31,7 +31,7 @@ module SearchHelper [ resources_results, generic_results - ].flatten.uniq do |item| + ].flatten do |item| item[:label] end end @@ -370,7 +370,7 @@ module SearchHelper def highlight_and_truncate_issuable(issuable, search_term, _search_highlight) return unless issuable.description.present? - simple_search_highlight_and_truncate(issuable.description, search_term, highlighter: '<span class="gl-text-black-normal gl-font-weight-bold">\1</span>') + simple_search_highlight_and_truncate(issuable.description, search_term, highlighter: '<span class="gl-text-gray-900 gl-font-weight-bold">\1</span>') end def show_user_search_tab? diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index 96eb14be4b4..3516000e296 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -75,7 +75,15 @@ module ServicesHelper end end - def integration_form_data(integration) + def scoped_reset_integration_path(integration, group: nil) + if group.present? + reset_group_settings_integration_path(group, integration) + else + reset_admin_application_settings_integration_path(integration) + end + end + + def integration_form_data(integration, group: nil) { id: integration.id, show_active: integration.show_active_box?.to_s, @@ -94,7 +102,7 @@ module ServicesHelper cancel_path: scoped_integrations_path, can_test: integration.can_test?.to_s, test_path: scoped_test_integration_path(integration), - reset_path: '' + reset_path: reset_integration?(integration, group: group) ? scoped_reset_integration_path(integration, group: group) : '' } end @@ -114,14 +122,14 @@ module ServicesHelper false end - def group_level_integrations? - @group.present? && Feature.enabled?(:group_level_integrations, @group, default_enabled: true) - end - def instance_level_integrations? !Gitlab.com? end + def reset_integration?(integration, group: nil) + integration.persisted? && Feature.enabled?(:reset_integrations, group, type: :development) + end + extend self private diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 10174e5d719..38758957dba 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module SortingHelper + include SortingTitlesValuesHelper + def sort_options_hash { sort_value_created_date => sort_title_created_date, @@ -40,6 +42,7 @@ module SortingHelper sort_value_latest_activity => sort_title_latest_activity, sort_value_recently_created => sort_title_created_date, sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name_desc, sort_value_stars_desc => sort_title_stars } @@ -95,8 +98,8 @@ module SortingHelper sort_value_name_desc => sort_title_name_desc, sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created, - sort_value_recently_updated => sort_title_recently_updated, - sort_value_oldest_updated => sort_title_oldest_updated + sort_value_latest_activity => sort_title_recently_updated, + sort_value_oldest_activity => sort_title_oldest_updated } end @@ -112,19 +115,6 @@ module SortingHelper ) end - def member_sort_options_hash - { - sort_value_access_level_asc => sort_title_access_level_asc, - sort_value_access_level_desc => sort_title_access_level_desc, - sort_value_last_joined => sort_title_last_joined, - sort_value_name => sort_title_name_asc, - sort_value_name_desc => sort_title_name_desc, - sort_value_oldest_joined => sort_title_oldest_joined, - sort_value_oldest_signin => sort_title_oldest_signin, - sort_value_recently_signin => sort_title_recently_signin - } - end - def milestone_sort_options_hash { sort_value_name => sort_title_name_asc, @@ -186,6 +176,19 @@ module SortingHelper } end + def member_sort_options_hash + { + sort_value_access_level_asc => sort_title_access_level_asc, + sort_value_access_level_desc => sort_title_access_level_desc, + sort_value_last_joined => sort_title_last_joined, + sort_value_name => sort_title_name_asc, + sort_value_name_desc => sort_title_name_desc, + sort_value_oldest_joined => sort_title_oldest_joined, + sort_value_oldest_signin => sort_title_oldest_signin, + sort_value_recently_signin => sort_title_recently_signin + } + end + def sortable_item(item, path, sorted_by) link_to item, path, class: sorted_by == item ? 'is-active' : '' end @@ -275,340 +278,6 @@ module SortingHelper sort_direction_button(url, reverse_sort, sort_value) end - # Titles. - def sort_title_access_level_asc - s_('SortOptions|Access level, ascending') - end - - def sort_title_access_level_desc - s_('SortOptions|Access level, descending') - end - - def sort_title_created_date - s_('SortOptions|Created date') - end - - def sort_title_downvotes - s_('SortOptions|Least popular') - end - - def sort_title_due_date - s_('SortOptions|Due date') - end - - def sort_title_due_date_later - s_('SortOptions|Due later') - end - - def sort_title_due_date_soon - s_('SortOptions|Due soon') - end - - def sort_title_label_priority - s_('SortOptions|Label priority') - end - - def sort_title_largest_group - s_('SortOptions|Largest group') - end - - def sort_title_largest_repo - s_('SortOptions|Largest repository') - end - - def sort_title_last_joined - s_('SortOptions|Last joined') - end - - def sort_title_latest_activity - s_('SortOptions|Last updated') - end - - def sort_title_milestone - s_('SortOptions|Milestone due date') - end - - def sort_title_milestone_later - s_('SortOptions|Milestone due later') - end - - def sort_title_milestone_soon - s_('SortOptions|Milestone due soon') - end - - def sort_title_name - s_('SortOptions|Name') - end - - def sort_title_name_asc - s_('SortOptions|Name, ascending') - end - - def sort_title_name_desc - s_('SortOptions|Name, descending') - end - - def sort_title_oldest_activity - s_('SortOptions|Oldest updated') - end - - def sort_title_oldest_created - s_('SortOptions|Oldest created') - end - - def sort_title_oldest_joined - s_('SortOptions|Oldest joined') - end - - def sort_title_oldest_signin - s_('SortOptions|Oldest sign in') - end - - def sort_title_oldest_starred - s_('SortOptions|Oldest starred') - end - - def sort_title_oldest_updated - s_('SortOptions|Oldest updated') - end - - def sort_title_popularity - s_('SortOptions|Popularity') - end - - def sort_title_priority - s_('SortOptions|Priority') - end - - def sort_title_recently_created - s_('SortOptions|Last created') - end - - def sort_title_recently_signin - s_('SortOptions|Recent sign in') - end - - def sort_title_recently_starred - s_('SortOptions|Recently starred') - end - - def sort_title_recently_updated - s_('SortOptions|Last updated') - end - - def sort_title_start_date_later - s_('SortOptions|Start later') - end - - def sort_title_start_date_soon - s_('SortOptions|Start soon') - end - - def sort_title_upvotes - s_('SortOptions|Most popular') - end - - def sort_title_contacted_date - s_('SortOptions|Last Contact') - end - - def sort_title_most_stars - s_('SortOptions|Most stars') - end - - def sort_title_stars - s_('SortOptions|Stars') - end - - def sort_title_oldest_last_activity - s_('SortOptions|Oldest last activity') - end - - def sort_title_recently_last_activity - s_('SortOptions|Recent last activity') - end - - def sort_title_relative_position - s_('SortOptions|Manual') - end - - def sort_title_size - s_('SortOptions|Size') - end - - def sort_title_expire_date - s_('SortOptions|Expired date') - end - - def sort_title_relevant - s_('SortOptions|Relevant') - end - - # Values. - def sort_value_access_level_asc - 'access_level_asc' - end - - def sort_value_access_level_desc - 'access_level_desc' - end - - def sort_value_created_date - 'created_date' - end - - def sort_value_downvotes - 'downvotes_desc' - end - - def sort_value_due_date - 'due_date' - end - - def sort_value_due_date_later - 'due_date_desc' - end - - def sort_value_due_date_soon - 'due_date_asc' - end - - def sort_value_label_priority - 'label_priority' - end - - def sort_value_largest_group - 'storage_size_desc' - end - - def sort_value_largest_repo - 'storage_size_desc' - end - - def sort_value_last_joined - 'last_joined' - end - - def sort_value_latest_activity - 'latest_activity_desc' - end - - def sort_value_milestone - 'milestone' - end - - def sort_value_milestone_later - 'milestone_due_desc' - end - - def sort_value_milestone_soon - 'milestone_due_asc' - end - - def sort_value_name - 'name_asc' - end - - def sort_value_name_desc - 'name_desc' - end - - def sort_value_oldest_activity - 'latest_activity_asc' - end - - def sort_value_oldest_created - 'created_asc' - end - - def sort_value_oldest_signin - 'oldest_sign_in' - end - - def sort_value_oldest_joined - 'oldest_joined' - end - - def sort_value_oldest_updated - 'updated_asc' - end - - def sort_value_popularity - 'popularity' - end - - def sort_value_most_popular - 'popularity_desc' - end - - def sort_value_least_popular - 'popularity_asc' - end - - def sort_value_priority - 'priority' - end - - def sort_value_recently_created - 'created_desc' - end - - def sort_value_recently_signin - 'recent_sign_in' - end - - def sort_value_recently_updated - 'updated_desc' - end - - def sort_value_start_date_later - 'start_date_desc' - end - - def sort_value_start_date_soon - 'start_date_asc' - end - - def sort_value_upvotes - 'upvotes_desc' - end - - def sort_value_contacted_date - 'contacted_asc' - end - - def sort_value_stars_desc - 'stars_desc' - end - - def sort_value_stars_asc - 'stars_asc' - end - - def sort_value_oldest_last_activity - 'last_activity_on_asc' - end - - def sort_value_recently_last_activity - 'last_activity_on_desc' - end - - def sort_value_relative_position - 'relative_position' - end - - def sort_value_size - 'size_desc' - end - - def sort_value_expire_date - 'expired_asc' - end - - def sort_value_relevant - 'relevant' - end - def packages_sort_options_hash { sort_value_recently_created => sort_title_created_date, diff --git a/app/helpers/sorting_titles_values_helper.rb b/app/helpers/sorting_titles_values_helper.rb new file mode 100644 index 00000000000..27f3638dc73 --- /dev/null +++ b/app/helpers/sorting_titles_values_helper.rb @@ -0,0 +1,339 @@ +# frozen_string_literal: true + +module SortingTitlesValuesHelper + # Titles. + def sort_title_access_level_asc + s_('SortOptions|Access level, ascending') + end + + def sort_title_access_level_desc + s_('SortOptions|Access level, descending') + end + + def sort_title_created_date + s_('SortOptions|Created date') + end + + def sort_title_downvotes + s_('SortOptions|Least popular') + end + + def sort_title_due_date + s_('SortOptions|Due date') + end + + def sort_title_due_date_later + s_('SortOptions|Due later') + end + + def sort_title_due_date_soon + s_('SortOptions|Due soon') + end + + def sort_title_label_priority + s_('SortOptions|Label priority') + end + + def sort_title_largest_group + s_('SortOptions|Largest group') + end + + def sort_title_largest_repo + s_('SortOptions|Largest repository') + end + + def sort_title_last_joined + s_('SortOptions|Last joined') + end + + def sort_title_latest_activity + s_('SortOptions|Last updated') + end + + def sort_title_milestone + s_('SortOptions|Milestone due date') + end + + def sort_title_milestone_later + s_('SortOptions|Milestone due later') + end + + def sort_title_milestone_soon + s_('SortOptions|Milestone due soon') + end + + def sort_title_name + s_('SortOptions|Name') + end + + def sort_title_name_asc + s_('SortOptions|Name, ascending') + end + + def sort_title_name_desc + s_('SortOptions|Name, descending') + end + + def sort_title_oldest_activity + s_('SortOptions|Oldest updated') + end + + def sort_title_oldest_created + s_('SortOptions|Oldest created') + end + + def sort_title_oldest_joined + s_('SortOptions|Oldest joined') + end + + def sort_title_oldest_signin + s_('SortOptions|Oldest sign in') + end + + def sort_title_oldest_starred + s_('SortOptions|Oldest starred') + end + + def sort_title_oldest_updated + s_('SortOptions|Oldest updated') + end + + def sort_title_popularity + s_('SortOptions|Popularity') + end + + def sort_title_priority + s_('SortOptions|Priority') + end + + def sort_title_recently_created + s_('SortOptions|Last created') + end + + def sort_title_recently_signin + s_('SortOptions|Recent sign in') + end + + def sort_title_recently_starred + s_('SortOptions|Recently starred') + end + + def sort_title_recently_updated + s_('SortOptions|Last updated') + end + + def sort_title_start_date_later + s_('SortOptions|Start later') + end + + def sort_title_start_date_soon + s_('SortOptions|Start soon') + end + + def sort_title_upvotes + s_('SortOptions|Most popular') + end + + def sort_title_contacted_date + s_('SortOptions|Last Contact') + end + + def sort_title_most_stars + s_('SortOptions|Most stars') + end + + def sort_title_stars + s_('SortOptions|Stars') + end + + def sort_title_oldest_last_activity + s_('SortOptions|Oldest last activity') + end + + def sort_title_recently_last_activity + s_('SortOptions|Recent last activity') + end + + def sort_title_relative_position + s_('SortOptions|Manual') + end + + def sort_title_size + s_('SortOptions|Size') + end + + def sort_title_expire_date + s_('SortOptions|Expired date') + end + + def sort_title_relevant + s_('SortOptions|Relevant') + end + + # Values. + def sort_value_access_level_asc + 'access_level_asc' + end + + def sort_value_access_level_desc + 'access_level_desc' + end + + def sort_value_created_date + 'created_date' + end + + def sort_value_downvotes + 'downvotes_desc' + end + + def sort_value_due_date + 'due_date' + end + + def sort_value_due_date_later + 'due_date_desc' + end + + def sort_value_due_date_soon + 'due_date_asc' + end + + def sort_value_label_priority + 'label_priority' + end + + def sort_value_largest_group + 'storage_size_desc' + end + + def sort_value_largest_repo + 'storage_size_desc' + end + + def sort_value_last_joined + 'last_joined' + end + + def sort_value_latest_activity + 'latest_activity_desc' + end + + def sort_value_milestone + 'milestone' + end + + def sort_value_milestone_later + 'milestone_due_desc' + end + + def sort_value_milestone_soon + 'milestone_due_asc' + end + + def sort_value_name + 'name_asc' + end + + def sort_value_name_desc + 'name_desc' + end + + def sort_value_oldest_activity + 'latest_activity_asc' + end + + def sort_value_oldest_created + 'created_asc' + end + + def sort_value_oldest_signin + 'oldest_sign_in' + end + + def sort_value_oldest_joined + 'oldest_joined' + end + + def sort_value_oldest_updated + 'updated_asc' + end + + def sort_value_popularity + 'popularity' + end + + def sort_value_most_popular + 'popularity_desc' + end + + def sort_value_least_popular + 'popularity_asc' + end + + def sort_value_priority + 'priority' + end + + def sort_value_recently_created + 'created_desc' + end + + def sort_value_recently_signin + 'recent_sign_in' + end + + def sort_value_recently_updated + 'updated_desc' + end + + def sort_value_start_date_later + 'start_date_desc' + end + + def sort_value_start_date_soon + 'start_date_asc' + end + + def sort_value_upvotes + 'upvotes_desc' + end + + def sort_value_contacted_date + 'contacted_asc' + end + + def sort_value_stars_desc + 'stars_desc' + end + + def sort_value_stars_asc + 'stars_asc' + end + + def sort_value_oldest_last_activity + 'last_activity_on_asc' + end + + def sort_value_recently_last_activity + 'last_activity_on_desc' + end + + def sort_value_relative_position + 'relative_position' + end + + def sort_value_size + 'size_desc' + end + + def sort_value_expire_date + 'expired_asc' + end + + def sort_value_relevant + 'relevant' + end +end + +SortingHelper.include_if_ee('::EE::SortingTitlesValuesHelper') diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index 13bf9c92d52..d6a4d6ac57a 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -15,9 +15,11 @@ module StorageHelper counter_wikis: storage_counter(statistics.wiki_size), counter_build_artifacts: storage_counter(statistics.build_artifacts_size), counter_lfs_objects: storage_counter(statistics.lfs_objects_size), - counter_snippets: storage_counter(statistics.snippets_size) + counter_snippets: storage_counter(statistics.snippets_size), + counter_packages: storage_counter(statistics.packages_size), + counter_uploads: storage_counter(statistics.uploads_size) } - _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets}") % counters + _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters end end diff --git a/app/helpers/suggest_pipeline_helper.rb b/app/helpers/suggest_pipeline_helper.rb index 3151b792344..f0a12f0e268 100644 --- a/app/helpers/suggest_pipeline_helper.rb +++ b/app/helpers/suggest_pipeline_helper.rb @@ -2,8 +2,6 @@ module SuggestPipelineHelper def should_suggest_gitlab_ci_yml? - Feature.enabled?(:suggest_pipeline, default_enabled: true) && - current_user && - params[:suggest_gitlab_ci_yml] == 'true' + current_user && params[:suggest_gitlab_ci_yml] == 'true' end end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 79f4810e13a..85e644967ea 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -38,7 +38,8 @@ module SystemNoteHelper 'status' => 'status', 'alert_issue_added' => 'issues', 'new_alert_added' => 'warning', - 'severity' => 'information-o' + 'severity' => 'information-o', + 'cloned' => 'documents' }.freeze def system_note_icon_name(note) diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb new file mode 100644 index 00000000000..00f65b72c8e --- /dev/null +++ b/app/helpers/time_zone_helper.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module TimeZoneHelper + TIME_ZONE_FORMAT_ATTRS = { + short: %i[identifier name offset], + full: %i[identifier name abbr offset formatted_offset] + }.freeze + private_constant :TIME_ZONE_FORMAT_ATTRS + + # format: + # * :full - all available fields + # * :short (default) + # + # Example: + # timezone_data # :short by default + # timezone_data(format: :full) + # + def timezone_data(format: :short) + attrs = TIME_ZONE_FORMAT_ATTRS.fetch(format) do + valid_formats = TIME_ZONE_FORMAT_ATTRS.keys.map { |k| ":#{k}"}.join(", ") + raise ArgumentError.new("Invalid format :#{format}. Valid formats are #{valid_formats}.") + end + + ActiveSupport::TimeZone.all.map do |timezone| + { + identifier: timezone.tzinfo.identifier, + name: timezone.name, + abbr: timezone.tzinfo.strftime('%Z'), + offset: timezone.now.utc_offset, + formatted_offset: timezone.now.formatted_offset + }.slice(*attrs) + end + end +end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 692971f4627..f24aa5d3bcb 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -228,12 +228,12 @@ module TreeHelper gitpod_enabled: !current_user.nil? && current_user.gitpod_enabled, is_blob: !options[:blob].nil?, - show_edit_button: show_edit_button?, + show_edit_button: show_edit_button?(options), show_web_ide_button: show_web_ide_button?, show_gitpod_button: show_gitpod_button?, web_ide_url: web_ide_url, - edit_url: edit_url, + edit_url: edit_url(options), gitpod_url: gitpod_url } end diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index e93c1b82cd7..a06a31ddf32 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -57,7 +57,10 @@ module UserCalloutsHelper end def show_registration_enabled_user_callout? - current_user&.admin? && signup_enabled? && !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) + !Gitlab.com? && + current_user&.admin? && + signup_enabled? && + !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) end private diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 7d4ab192f2f..a58f8a6f792 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true module UsersHelper + def admin_users_data_attributes(users) + { + users: Admin::UserSerializer.new.represent(users).to_json, + paths: admin_users_paths.to_json + } + end + def user_link(user) link_to(user.name, user_path(user), title: user.email, @@ -60,6 +67,12 @@ module UsersHelper "access:#{max_project_member_access(project)}" end + def show_status_emoji?(status) + return false unless status + + status.message.present? || status.emoji != UserStatus::DEFAULT_EMOJI + end + def user_status(user) return unless user @@ -123,6 +136,19 @@ module UsersHelper } end + def user_unblock_data(user) + { + path: unblock_admin_user_path(user), + method: 'put', + modal_attributes: { + title: s_('AdminUsers|Unblock user %{username}?') % { username: sanitize_name(user.name) }, + message: s_('AdminUsers|You can always block their account again if needed.'), + okVariant: 'info', + okTitle: s_('AdminUsers|Unblock') + }.to_json + } + end + def user_block_effects header = tag.p s_('AdminUsers|Blocking user has the following effects:') @@ -136,8 +162,75 @@ module UsersHelper header + list end + def user_deactivation_data(user, message) + { + path: deactivate_admin_user_path(user), + method: 'put', + modal_attributes: { + title: s_('AdminUsers|Deactivate user %{username}?') % { username: sanitize_name(user.name) }, + messageHtml: message, + okVariant: 'warning', + okTitle: s_('AdminUsers|Deactivate') + }.to_json + } + end + + def user_activation_data(user) + { + path: activate_admin_user_path(user), + method: 'put', + modal_attributes: { + title: s_('AdminUsers|Activate user %{username}?') % { username: sanitize_name(user.name) }, + message: s_('AdminUsers|You can always deactivate their account again if needed.'), + okVariant: 'info', + okTitle: s_('AdminUsers|Activate') + }.to_json + } + end + + def user_deactivation_effects + header = tag.p s_('AdminUsers|Deactivating a user has the following effects:') + + list = tag.ul do + concat tag.li s_('AdminUsers|The user will be logged out') + concat tag.li s_('AdminUsers|The user will not be able to access git repositories') + concat tag.li s_('AdminUsers|The user will not be able to access the API') + concat tag.li s_('AdminUsers|The user will not receive any notifications') + concat tag.li s_('AdminUsers|The user will not be able to use slash commands') + concat tag.li s_('AdminUsers|When the user logs back in, their account will reactivate as a fully active account') + concat tag.li s_('AdminUsers|Personal projects, group and user history will be left intact') + end + + header + list + end + + def user_display_name(user) + return s_('UserProfile|Blocked user') if user.blocked? + + can_read_profile = can?(current_user, :read_user_profile, user) + return s_('UserProfile|Unconfirmed user') unless user.confirmed? || can_read_profile + + user.name + end + private + def admin_users_paths + { + edit: edit_admin_user_path(:id), + approve: approve_admin_user_path(:id), + reject: reject_admin_user_path(:id), + unblock: unblock_admin_user_path(:id), + block: block_admin_user_path(:id), + deactivate: deactivate_admin_user_path(:id), + activate: activate_admin_user_path(:id), + unlock: unlock_admin_user_path(:id), + delete: admin_user_path(:id), + delete_with_contributions: admin_user_path(:id), + admin_user: admin_user_path(:id) + } + end + def blocked_user_badge(user) pending_approval_badge = { text: s_('AdminUsers|Pending approval'), variant: 'info' } return pending_approval_badge if user.blocked_pending_approval? diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 896dcdd2caf..0a37257e124 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -157,6 +157,16 @@ module VisibilityLevelHelper end end + def visibility_level_options(form_model) + available_visibility_levels(form_model).map do |level| + { + level: level, + label: visibility_level_label(level), + description: visibility_level_description(level, form_model) + } + end + end + def snippets_selected_visibility_level(visibility_levels, selected) visibility_levels.find { |level| level == selected } || visibility_levels.min end diff --git a/app/helpers/web_ide_button_helper.rb b/app/helpers/web_ide_button_helper.rb index 0a4d47eed52..7aa0adc31bd 100644 --- a/app/helpers/web_ide_button_helper.rb +++ b/app/helpers/web_ide_button_helper.rb @@ -21,8 +21,8 @@ module WebIdeButtonHelper can_collaborate? || can_create_mr_from_fork? end - def show_edit_button? - readable_blob? && show_web_ide_button? + def show_edit_button?(options = {}) + readable_blob?(options) && show_web_ide_button? end def show_gitpod_button? @@ -37,8 +37,8 @@ module WebIdeButtonHelper !project_fork.nil? && !can_push_code? end - def readable_blob? - !readable_blob({}, @path, @project, @ref).nil? + def readable_blob?(options = {}) + !readable_blob(options, @path, @project, @ref).nil? end def needs_to_fork? @@ -49,8 +49,8 @@ module WebIdeButtonHelper ide_edit_path(project_to_use, @ref, @path || '') end - def edit_url - readable_blob? ? edit_blob_path(@project, @ref, @path || '') : '' + def edit_url(options = {}) + readable_blob?(options) ? edit_blob_path(@project, @ref, @path || '') : '' end def gitpod_url diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb index 283d443f51b..bbf5bde5904 100644 --- a/app/helpers/whats_new_helper.rb +++ b/app/helpers/whats_new_helper.rb @@ -1,25 +1,19 @@ # frozen_string_literal: true module WhatsNewHelper - include Gitlab::WhatsNew - def whats_new_most_recent_release_items_count - Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_items_count', expires_in: CACHE_DURATION) do - whats_new_release_items&.count - end + ReleaseHighlight.most_recent_item_count end def whats_new_storage_key - return unless whats_new_most_recent_version + most_recent_version = ReleaseHighlight.versions&.first - ['display-whats-new-notification', whats_new_most_recent_version].join('-') - end + return unless most_recent_version - private + ['display-whats-new-notification', most_recent_version].join('-') + end - def whats_new_most_recent_version - Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_version', expires_in: CACHE_DURATION) do - whats_new_release_items&.first&.[]('release') - end + def whats_new_versions + ReleaseHighlight.versions end end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 10a1da90e9e..b2c1351bd28 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -80,6 +80,16 @@ module Emails mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason)) end + def issue_cloned_email(recipient, issue, new_issue, updated_by_user, reason = nil) + setup_issue_mail(issue.id, recipient.id) + + @author = updated_by_user + @issue = issue + @new_issue = new_issue + @can_access_project = recipient.can?(:read_project, @new_issue.project) + mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason)) + end + def import_issues_csv_email(user_id, project_id, results) @user = User.find(user_id) @project = Project.find(project_id) diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 0b5a8dfdc24..759181bd3cb 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -63,15 +63,6 @@ module Emails subject: subject_line, layout: 'unknown_user_mailer' ) - - if Gitlab::Experimentation.enabled?(:invitation_reminders) - Gitlab::Tracking.event( - Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category, - 'sent', - property: Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group', - label: Digest::MD5.hexdigest(member.to_global_id.to_s) - ) - end end def member_invited_reminder_email(member_source_type, member_id, token, reminder_index) diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 6f44b63f8d0..e3c72a343e7 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -18,6 +18,14 @@ module Emails subject: subject(_("GitLab Account Request"))) end + def user_admin_rejection_email(name, email) + @name = name + + profile_email_with_layout( + to: email, + subject: subject(_("GitLab account request rejected"))) + end + # rubocop: disable CodeReuse/ActiveRecord def new_ssh_key_email(key_id) @key = Key.find_by(id: key_id) diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb index fa646487819..4dceff5b7ba 100644 --- a/app/mailers/emails/service_desk.rb +++ b/app/mailers/emails/service_desk.rb @@ -47,7 +47,7 @@ module Emails def service_desk_options(email_sender, email_type) { from: email_sender, - to: @issue.service_desk_reply_to + to: @issue.external_author }.tap do |options| next unless template_body = template_content(email_type) diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index 7ce7f40b6a8..7090d9f4ea1 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -69,6 +69,11 @@ module AlertManagement unknown: 5 } + enum domain: { + operations: 0, + threat_monitoring: 1 + } + state_machine :status, initial: :triggered do state :triggered, value: STATUSES[:triggered] @@ -122,6 +127,8 @@ module AlertManagement scope :open, -> { with_status(open_statuses) } scope :not_resolved, -> { without_status(:resolved) } scope :with_prometheus_alert, -> { includes(:prometheus_alert) } + scope :with_threat_monitoring_alerts, -> { where(domain: :threat_monitoring ) } + scope :with_operations_alerts, -> { where(domain: :operations) } scope :order_start_time, -> (sort_order) { order(started_at: sort_order) } scope :order_end_time, -> (sort_order) { order(ended_at: sort_order) } @@ -263,3 +270,5 @@ module AlertManagement end end end + +AlertManagement::Alert.prepend_if_ee('EE::AlertManagement::Alert') diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb index ae5170867c3..0c916c576cb 100644 --- a/app/models/alert_management/http_integration.rb +++ b/app/models/alert_management/http_integration.rb @@ -22,6 +22,7 @@ module AlertManagement validates :name, presence: true, length: { maximum: 255 } validates :endpoint_identifier, presence: true, length: { maximum: 255 }, format: { with: /\A[A-Za-z0-9]+\z/ } validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active? + validates :payload_attribute_mapping, json_schema: { filename: 'http_integration_payload_attribute_mapping' } before_validation :prevent_token_assignment before_validation :prevent_endpoint_identifier_assignment diff --git a/app/models/analytics/devops_adoption.rb b/app/models/analytics/devops_adoption.rb deleted file mode 100644 index ed5a5b16a6e..00000000000 --- a/app/models/analytics/devops_adoption.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -module Analytics::DevopsAdoption - def self.table_name_prefix - 'analytics_devops_adoption_' - end -end diff --git a/app/models/analytics/devops_adoption/segment.rb b/app/models/analytics/devops_adoption/segment.rb deleted file mode 100644 index 71d4a312627..00000000000 --- a/app/models/analytics/devops_adoption/segment.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -class Analytics::DevopsAdoption::Segment < ApplicationRecord - ALLOWED_SEGMENT_COUNT = 20 - - has_many :segment_selections - has_many :groups, through: :segment_selections - - validates :name, presence: true, uniqueness: true, length: { maximum: 255 } - validate :validate_segment_count - - accepts_nested_attributes_for :segment_selections, allow_destroy: true - - scope :ordered_by_name, -> { order(:name) } - scope :with_groups, -> { preload(:groups) } - - private - - def validate_segment_count - if self.class.count >= ALLOWED_SEGMENT_COUNT - errors.add(:name, s_('DevopsAdoptionSegment|The maximum number of segments has been reached')) - end - end -end diff --git a/app/models/analytics/devops_adoption/segment_selection.rb b/app/models/analytics/devops_adoption/segment_selection.rb deleted file mode 100644 index 6b70c13a773..00000000000 --- a/app/models/analytics/devops_adoption/segment_selection.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class Analytics::DevopsAdoption::SegmentSelection < ApplicationRecord - ALLOWED_SELECTIONS_PER_SEGMENT = 20 - - belongs_to :segment - belongs_to :project - belongs_to :group - - validates :segment, presence: true - validates :project, presence: { unless: :group } - validates :project_id, uniqueness: { scope: :segment_id, if: :project } - validates :group, presence: { unless: :project } - validates :group_id, uniqueness: { scope: :segment_id, if: :group } - - validate :exclusive_project_or_group - validate :validate_selection_count - - private - - def exclusive_project_or_group - if project.present? && group.present? - errors.add(:group, s_('DevopsAdoptionSegmentSelection|The selection cannot be configured for a project and for a group at the same time')) - end - end - - def validate_selection_count - return unless segment - - selection_count_for_segment = self.class.where(segment: segment).count - - if selection_count_for_segment >= ALLOWED_SELECTIONS_PER_SEGMENT - errors.add(:segment, s_('DevopsAdoptionSegmentSelection|The maximum number of selections has been reached')) - end - end -end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 7bfa5fb4cb8..9b9db7f93fd 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -5,14 +5,14 @@ class ApplicationSetting < ApplicationRecord include CacheMarkdownField include TokenAuthenticatable include ChronicDurationAttribute - include IgnorableColumns - - ignore_column :namespace_storage_size_limit, remove_with: '13.5', remove_after: '2020-09-22' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ 'Admin Area > Settings > Metrics and profiling > Metrics - Grafana' + KROKI_URL_ERROR_MESSAGE = 'Please check your Kroki URL setting in ' \ + 'Admin Area > Settings > General > Kroki' + add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required } add_authentication_token_field :health_check_access_token add_authentication_token_field :static_objects_external_storage_auth_token @@ -128,6 +128,11 @@ class ApplicationSetting < ApplicationRecord presence: true, if: :unique_ips_limit_enabled + validates :kroki_url, + presence: { if: :kroki_enabled } + + validate :validate_kroki_url, if: :kroki_enabled + validates :plantuml_url, presence: true, if: :plantuml_enabled @@ -244,6 +249,12 @@ class ApplicationSetting < ApplicationRecord validates :user_default_internal_regex, js_regex: true, allow_nil: true + validates :personal_access_token_prefix, + format: { with: /\A[a-zA-Z0-9_+=\/@:.-]+\z/, + message: _("can contain only letters of the Base64 alphabet (RFC4648) with the addition of '@', ':' and '.'") }, + length: { maximum: 20, message: _('is too long (maximum is %{count} characters)') }, + allow_blank: true + validates :commit_email_hostname, format: { with: /\A[^@]+\z/ } validates :archive_builds_in_seconds, @@ -362,11 +373,11 @@ class ApplicationSetting < ApplicationRecord validates :eks_access_key_id, length: { in: 16..128 }, - if: :eks_integration_enabled? + if: -> (setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? } validates :eks_secret_access_key, presence: true, - if: :eks_integration_enabled? + if: -> (setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? } validates_with X509CertificateCredentialsValidator, certificate: :external_auth_client_cert, @@ -418,6 +429,9 @@ class ApplicationSetting < ApplicationRecord attr_encrypted :secret_detection_token_revocation_token, encryption_options_base_truncated_aes_256_gcm attr_encrypted :cloud_license_auth_token, encryption_options_base_truncated_aes_256_gcm + validates :disable_feed_token, + inclusion: { in: [true, false], message: 'must be a boolean value' } + before_validation :ensure_uuid! before_save :ensure_runners_registration_token @@ -429,18 +443,21 @@ class ApplicationSetting < ApplicationRecord after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') } def validate_grafana_url - unless parsed_grafana_url - self.errors.add( - :grafana_url, - "must be a valid relative or absolute URL. #{GRAFANA_URL_ERROR_MESSAGE}" - ) - end + validate_url(parsed_grafana_url, :grafana_url, GRAFANA_URL_ERROR_MESSAGE) end def grafana_url_absolute? parsed_grafana_url&.absolute? end + def validate_kroki_url + validate_url(parsed_kroki_url, :kroki_url, KROKI_URL_ERROR_MESSAGE) + end + + def kroki_url_absolute? + parsed_kroki_url&.absolute? + end + def sourcegraph_url_is_com? !!(sourcegraph_url =~ /\Ahttps:\/\/(www\.)?sourcegraph\.com/) end @@ -503,6 +520,24 @@ class ApplicationSetting < ApplicationRecord def parsed_grafana_url @parsed_grafana_url ||= Gitlab::Utils.parse_url(grafana_url) end + + def parsed_kroki_url + @parsed_kroki_url ||= Gitlab::UrlBlocker.validate!(kroki_url, schemes: %w(http https), enforce_sanitization: true)[0] + rescue Gitlab::UrlBlocker::BlockedUrlError => error + self.errors.add( + :kroki_url, + "is not valid. #{error}" + ) + end + + def validate_url(parsed_url, name, error_message) + unless parsed_url + self.errors.add( + name, + "must be a valid relative or absolute URL. #{error_message}" + ) + end + end end ApplicationSetting.prepend_if_ee('EE::ApplicationSetting') diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 5c7abbccd63..105889a364a 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -58,6 +58,7 @@ module ApplicationSettingImplementation default_projects_limit: Settings.gitlab['default_projects_limit'], default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, + disable_feed_token: false, disabled_oauth_sign_in_sources: [], dns_rebinding_protection_enabled: true, domain_allowlist: Settings.gitlab['domain_allowlist'], @@ -103,6 +104,7 @@ module ApplicationSettingImplementation password_authentication_enabled_for_git: true, password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'], performance_bar_allowed_group_id: nil, + personal_access_token_prefix: nil, plantuml_enabled: false, plantuml_url: nil, polling_interval_multiplier: 1, @@ -168,7 +170,9 @@ module ApplicationSettingImplementation user_show_add_ssh_key_message: true, wiki_page_max_content_bytes: 50.megabytes, container_registry_delete_tags_service_timeout: 250, - container_registry_expiration_policies_worker_capacity: 0 + container_registry_expiration_policies_worker_capacity: 0, + kroki_enabled: false, + kroki_url: nil } end diff --git a/app/models/approval.rb b/app/models/approval.rb index bc123de0b20..899ea466315 100644 --- a/app/models/approval.rb +++ b/app/models/approval.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Approval < ApplicationRecord + include CreatedAtFilterable + belongs_to :user belongs_to :merge_request diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 55e8a5d4535..a4d991b040c 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -13,6 +13,8 @@ class AuditEvent < ApplicationRecord :target_id ].freeze + self.primary_key = :id + serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize belongs_to :user, foreign_key: :author_id diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 34030e079c7..a4d0b7485ba 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -30,6 +30,11 @@ class BulkImports::Entity < ApplicationRecord class_name: 'BulkImports::Tracker', foreign_key: :bulk_import_entity_id + has_many :failures, + class_name: 'BulkImports::Failure', + inverse_of: :entity, + foreign_key: :bulk_import_entity_id + validates :project, absence: true, if: :group validates :group, absence: true, if: :project validates :source_type, :source_full_path, :destination_name, @@ -52,6 +57,7 @@ class BulkImports::Entity < ApplicationRecord event :finish do transition started: :finished + transition failed: :failed end event :fail_op do @@ -59,6 +65,25 @@ class BulkImports::Entity < ApplicationRecord end end + def update_tracker_for(relation:, has_next_page:, next_page: nil) + attributes = { + relation: relation, + has_next_page: has_next_page, + next_page: next_page, + bulk_import_entity_id: id + } + + trackers.upsert(attributes, unique_by: %i[bulk_import_entity_id relation]) + end + + def has_next_page?(relation) + trackers.find_by(relation: relation)&.has_next_page + end + + def next_page_for(relation) + trackers.find_by(relation: relation)&.next_page + end + private def validate_parent_is_a_group diff --git a/app/models/bulk_imports/failure.rb b/app/models/bulk_imports/failure.rb new file mode 100644 index 00000000000..a6f7582c3b0 --- /dev/null +++ b/app/models/bulk_imports/failure.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class BulkImports::Failure < ApplicationRecord + self.table_name = 'bulk_import_failures' + + belongs_to :entity, + class_name: 'BulkImports::Entity', + foreign_key: :bulk_import_entity_id, + inverse_of: :failures, + optional: false + + validates :entity, presence: true +end diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 5b23cf46fdb..19a0d424e33 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -132,14 +132,10 @@ module Ci end def playable? - return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project) - action? && !archived? && manual? end def action? - return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project) - %w[manual].include?(self.when) end @@ -206,7 +202,7 @@ module Ci override :dependency_variables def dependency_variables - return [] unless ::Feature.enabled?(:ci_bridge_dependency_variables, project) + return [] unless ::Feature.enabled?(:ci_bridge_dependency_variables, project, default_enabled: true) super end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 84abd01786d..71939f070cb 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -190,6 +190,8 @@ module Ci scope :with_coverage, -> { where.not(coverage: nil) } + scope :for_project, -> (project_id) { where(project_id: project_id) } + acts_as_taggable add_authentication_token_field :token, encrypted: :optional @@ -379,8 +381,16 @@ module Ci Ci::BuildRunnerSession.where(build: build).delete_all end - after_transition any => [:skipped, :canceled] do |build| - build.deployment&.cancel + after_transition any => [:skipped, :canceled] do |build, transition| + if Feature.enabled?(:cd_skipped_deployment_status, build.project) + if transition.to_name == :skipped + build.deployment&.skip + else + build.deployment&.cancel + end + else + build.deployment&.cancel + end end end @@ -527,6 +537,7 @@ module Ci strong_memoize(:variables) do Gitlab::Ci::Variables::Collection.new .concat(persisted_variables) + .concat(dependency_proxy_variables) .concat(job_jwt_variables) .concat(scoped_variables) .concat(job_variables) @@ -575,6 +586,15 @@ module Ci end end + def dependency_proxy_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless Gitlab.config.dependency_proxy.enabled + + variables.append(key: 'CI_DEPENDENCY_PROXY_USER', value: ::Gitlab::Auth::CI_JOB_USER) + variables.append(key: 'CI_DEPENDENCY_PROXY_PASSWORD', value: token.to_s, public: false, masked: true) + end + end + def features { trace_sections: true } end @@ -908,13 +928,33 @@ module Ci end def collect_coverage_reports!(coverage_report) + project_path, worktree_paths = if Feature.enabled?(:smart_cobertura_parser, project) + # If the flag is disabled, we intentionally pass nil + # for both project_path and worktree_paths to fallback + # to the non-smart behavior of the parser + [project.full_path, pipeline.all_worktree_paths] + end + each_report(Ci::JobArtifact::COVERAGE_REPORT_FILE_TYPES) do |file_type, blob| - Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, coverage_report) + Gitlab::Ci::Parsers.fabricate!(file_type).parse!( + blob, + coverage_report, + project_path: project_path, + worktree_paths: worktree_paths + ) end coverage_report end + def collect_codequality_reports!(codequality_report) + each_report(Ci::JobArtifact::CODEQUALITY_REPORT_FILE_TYPES) do |file_type, blob| + Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, codequality_report) + end + + codequality_report + end + def collect_terraform_reports!(terraform_reports) each_report(::Ci::JobArtifact::TERRAFORM_REPORT_FILE_TYPES) do |file_type, blob, report_artifact| ::Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, terraform_reports, artifact: report_artifact) @@ -966,6 +1006,15 @@ module Ci ::Gitlab.com? ? 500_000 : 0 end + def debug_mode? + return false unless Feature.enabled?(:restrict_access_to_build_debug_mode, default_enabled: true) + + # TODO: Have `debug_mode?` check against data on sent back from runner + # to capture all the ways that variables can be set. + # See (https://gitlab.com/gitlab-org/gitlab/-/issues/290955) + variables.any? { |variable| variable[:key] == 'CI_DEBUG_TRACE' && variable[:value].casecmp('true') == 0 } + end + protected def run_status_commit_hooks! diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb index 2fcd1708cf4..a6abeb517c1 100644 --- a/app/models/ci/build_dependencies.rb +++ b/app/models/ci/build_dependencies.rb @@ -2,6 +2,8 @@ module Ci class BuildDependencies + include ::Gitlab::Utils::StrongMemoize + attr_reader :processable def initialize(processable) @@ -9,7 +11,7 @@ module Ci end def all - (local + cross_pipeline).uniq + (local + cross_pipeline + cross_project).uniq end # Dependencies local to the given pipeline @@ -23,8 +25,16 @@ module Ci deps end - # Dependencies that are defined in other pipelines + # Dependencies from the same parent-pipeline hierarchy excluding + # the current job's pipeline def cross_pipeline + strong_memoize(:cross_pipeline) do + fetch_dependencies_in_hierarchy + end + end + + # Dependencies that are defined by project and ref + def cross_project [] end @@ -33,7 +43,7 @@ module Ci end def valid? - valid_local? && valid_cross_pipeline? + valid_local? && valid_cross_pipeline? && valid_cross_project? end private @@ -44,13 +54,61 @@ module Ci ::Ci::Build end + def fetch_dependencies_in_hierarchy + deps_specifications = specified_cross_pipeline_dependencies + return [] if deps_specifications.empty? + + deps_specifications = expand_variables_and_validate(deps_specifications) + jobs_in_pipeline_hierarchy(deps_specifications) + end + + def jobs_in_pipeline_hierarchy(deps_specifications) + all_pipeline_ids = [] + all_job_names = [] + + deps_specifications.each do |spec| + all_pipeline_ids << spec[:pipeline] + all_job_names << spec[:job] + end + + model_class.latest.success + .in_pipelines(processable.pipeline.same_family_pipeline_ids) + .in_pipelines(all_pipeline_ids.uniq) + .by_name(all_job_names.uniq) + .select do |dependency| + # the query may not return exact matches pipeline-job, so we filter + # them separately. + deps_specifications.find do |spec| + spec[:pipeline] == dependency.pipeline_id && + spec[:job] == dependency.name + end + end + end + + def expand_variables_and_validate(specifications) + specifications.map do |spec| + pipeline = ExpandVariables.expand(spec[:pipeline].to_s, processable_variables).to_i + # current pipeline is not allowed because local dependencies + # should be used instead. + next if pipeline == processable.pipeline_id + + job = ExpandVariables.expand(spec[:job], processable_variables) + + { job: job, pipeline: pipeline } + end.compact + end + + def valid_cross_pipeline? + cross_pipeline.size == specified_cross_pipeline_dependencies.size + end + def valid_local? return true if Feature.enabled?(:ci_disable_validates_dependencies) local.all?(&:valid_dependency?) end - def valid_cross_pipeline? + def valid_cross_project? true end @@ -78,6 +136,22 @@ module Ci scope.where(name: processable.options[:dependencies]) end + + def processable_variables + -> { processable.simple_variables_without_dependencies } + end + + def specified_cross_pipeline_dependencies + strong_memoize(:specified_cross_pipeline_dependencies) do + next [] unless Feature.enabled?(:ci_cross_pipeline_artifacts_download, processable.project, default_enabled: true) + + specified_cross_dependencies.select { |dep| dep[:pipeline] && dep[:artifacts] } + end + end + + def specified_cross_dependencies + Array(processable.options[:cross_dependencies]) + end end end diff --git a/app/models/ci/build_trace_chunks/fog.rb b/app/models/ci/build_trace_chunks/fog.rb index d3051e3dadc..27b579bf428 100644 --- a/app/models/ci/build_trace_chunks/fog.rb +++ b/app/models/ci/build_trace_chunks/fog.rb @@ -14,11 +14,15 @@ module Ci end def set_data(model, new_data) - # TODO: Support AWS S3 server side encryption - files.create({ - key: key(model), - body: new_data - }) + if Feature.enabled?(:ci_live_trace_use_fog_attributes, default_enabled: true) + files.create(create_attributes(model, new_data)) + else + # TODO: Support AWS S3 server side encryption + files.create({ + key: key(model), + body: new_data + }) + end end def append_data(model, new_data, offset) @@ -57,6 +61,13 @@ module Ci key_raw(model.build_id, model.chunk_index) end + def create_attributes(model, new_data) + { + key: key(model), + body: new_data + }.merge(object_store_config.fog_attributes) + end + def key_raw(build_id, chunk_index) "tmp/builds/#{build_id.to_i}/chunks/#{chunk_index.to_i}.log" end @@ -84,6 +95,14 @@ module Ci def object_store Gitlab.config.artifacts.object_store end + + def object_store_raw_config + object_store + end + + def object_store_config + @object_store_config ||= ::ObjectStorage::Config.new(object_store_raw_config) + end end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 7cedd13b407..c80d50ea131 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -7,15 +7,13 @@ module Ci include UpdateProjectStatistics include UsageStatistics include Sortable - include IgnorableColumns include Artifactable include FileStoreMounter extend Gitlab::Ci::Model - ignore_columns :locked, remove_after: '2020-07-22', remove_with: '13.4' - TEST_REPORT_FILE_TYPES = %w[junit].freeze COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze + CODEQUALITY_REPORT_FILE_TYPES = %w[codequality].freeze ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze NON_ERASABLE_FILE_TYPES = %w[trace].freeze TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze @@ -157,6 +155,10 @@ module Ci with_file_types(COVERAGE_REPORT_FILE_TYPES) end + scope :codequality_reports, -> do + with_file_types(CODEQUALITY_REPORT_FILE_TYPES) + end + scope :terraform_reports, -> do with_file_types(TERRAFORM_REPORT_FILE_TYPES) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 8707d635e03..5e5f51d776f 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -7,6 +7,7 @@ module Ci include Importable include AfterCommitQueue include Presentable + include Gitlab::Allowable include Gitlab::OptimisticLocking include Gitlab::Utils::StrongMemoize include AtomicInternalId @@ -16,6 +17,8 @@ module Ci include FromUnion include UpdatedAtFilterable + MAX_OPEN_MERGE_REQUESTS_REFS = 4 + PROJECT_ROUTE_AND_NAMESPACE_ROUTE = { project: [:project_feature, :route, { namespace: :route }] }.freeze @@ -104,7 +107,6 @@ module Ci accepts_nested_attributes_for :variables, reject_if: :persisted? - delegate :id, to: :project, prefix: true delegate :full_path, to: :project, prefix: true validates :sha, presence: { unless: :importing? } @@ -259,6 +261,22 @@ module Ci end end + after_transition any => any do |pipeline| + next unless Feature.enabled?(:jira_sync_builds, pipeline.project) + + pipeline.run_after_commit do + # Passing the seq-id ensures this is idempotent + seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id + ::JiraConnect::SyncBuildsWorker.perform_async(pipeline.id, seq_id) + end + end + + after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline| + pipeline.run_after_commit do + ::Ci::TestFailureHistoryService.new(pipeline).async.perform_if_needed # rubocop: disable CodeReuse/ServiceClass + end + end + after_transition any => [:success, :failed] do |pipeline| ref_status = pipeline.ci_ref&.update_status_by!(pipeline) @@ -277,15 +295,17 @@ module Ci scope :internal, -> { where(source: internal_sources) } scope :no_child, -> { where.not(source: :parent_pipeline) } scope :ci_sources, -> { where(source: Enums::Ci::Pipeline.ci_sources.values) } + scope :ci_branch_sources, -> { where(source: Enums::Ci::Pipeline.ci_branch_sources.values) } scope :ci_and_parent_sources, -> { where(source: Enums::Ci::Pipeline.ci_and_parent_sources.values) } scope :for_user, -> (user) { where(user: user) } scope :for_sha, -> (sha) { where(sha: sha) } scope :for_source_sha, -> (source_sha) { where(source_sha: source_sha) } scope :for_sha_or_source_sha, -> (sha) { for_sha(sha).or(for_source_sha(sha)) } scope :for_ref, -> (ref) { where(ref: ref) } + scope :for_branch, -> (branch) { for_ref(branch).where(tag: false) } scope :for_id, -> (id) { where(id: id) } scope :for_iid, -> (iid) { where(iid: iid) } - scope :for_project, -> (project) { where(project: project) } + scope :for_project, -> (project_id) { where(project_id: project_id) } scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) } scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) } scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) } @@ -310,9 +330,9 @@ module Ci # In general, please use `Ci::PipelinesForMergeRequestFinder` instead, # for checking permission of the actor. scope :triggered_by_merge_request, -> (merge_request) do - ci_sources.where(source: :merge_request_event, - merge_request: merge_request, - project: [merge_request.source_project, merge_request.target_project]) + where(source: :merge_request_event, + merge_request: merge_request, + project: [merge_request.source_project, merge_request.target_project]) end # Returns the pipelines in descending order (= newest first), optionally @@ -774,9 +794,20 @@ module Ci variables.append(key: 'CI_MERGE_REQUEST_EVENT_TYPE', value: merge_request_event_type.to_s) variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s) variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s) + + diff = self.merge_request_diff + if diff.present? + variables.append(key: 'CI_MERGE_REQUEST_DIFF_ID', value: diff.id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_DIFF_BASE_SHA', value: diff.base_commit_sha) + end + variables.concat(merge_request.predefined_variables) end + if Gitlab::Ci::Features.pipeline_open_merge_requests?(project) && open_merge_requests_refs.any? + variables.append(key: 'CI_OPEN_MERGE_REQUESTS', value: open_merge_requests_refs.join(',')) + end + variables.append(key: 'CI_KUBERNETES_ACTIVE', value: 'true') if has_kubernetes_active? variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true') if freeze_period? @@ -824,9 +855,8 @@ module Ci end def execute_hooks - data = pipeline_data - project.execute_hooks(data, :pipeline_hooks) - project.execute_services(data, :pipeline_hooks) + project.execute_hooks(pipeline_data, :pipeline_hooks) if project.has_active_hooks?(:pipeline_hooks) + project.execute_services(pipeline_data, :pipeline_hooks) if project.has_active_services?(:pipeline_hooks) end # All the merge requests for which the current pipeline runs/ran against @@ -844,9 +874,39 @@ module Ci all_merge_requests.order(id: :desc) end + # This returns a list of MRs that point + # to the same source project/branch + def related_merge_requests + if merge_request? + # We look for all other MRs that this branch might be pointing to + MergeRequest.where( + source_project_id: merge_request.source_project_id, + source_branch: merge_request.source_branch) + else + MergeRequest.where( + source_project_id: project_id, + source_branch: ref) + end + end + + # We cannot use `all_merge_requests`, due to race condition + # This returns a list of at most 4 open MRs + def open_merge_requests_refs + strong_memoize(:open_merge_requests_refs) do + # We ensure that triggering user can actually read the pipeline + related_merge_requests + .opened + .limit(MAX_OPEN_MERGE_REQUESTS_REFS) + .order(id: :desc) + .preload(:target_project) + .select { |mr| can?(user, :read_merge_request, mr) } + .map { |mr| mr.to_reference(project, full: true) } + end + end + def same_family_pipeline_ids ::Gitlab::Ci::PipelineObjectHierarchy.new( - base_and_ancestors(same_project: true), options: { same_project: true } + self.class.where(id: root_ancestor), options: { same_project: true } ).base_and_descendants.select(:id) end @@ -869,6 +929,15 @@ module Ci .base_and_descendants end + def root_ancestor + return self unless child? + + Gitlab::Ci::PipelineObjectHierarchy + .new(self.class.unscoped.where(id: id), options: { same_project: true }) + .base_and_ancestors(hierarchy_order: :desc) + .first + end + def bridge_triggered? source_bridge.present? end @@ -878,7 +947,8 @@ module Ci end def child? - parent_pipeline.present? + parent_pipeline? && # child pipelines have `parent_pipeline` source + parent_pipeline.present? end def parent? @@ -910,10 +980,18 @@ module Ci builds.latest.with_reports(reports_scope) end + def latest_test_report_builds + latest_report_builds(Ci::JobArtifact.test_reports).preload(:project) + end + def builds_with_coverage builds.latest.with_coverage end + def builds_with_failed_tests(limit: nil) + latest_test_report_builds.failed.limit(limit) + end + def has_reports?(reports_scope) complete? && latest_report_builds(reports_scope).exists? end @@ -934,7 +1012,7 @@ module Ci def test_reports Gitlab::Ci::Reports::TestReports.new.tap do |test_reports| - latest_report_builds(Ci::JobArtifact.test_reports).preload(:project).find_each do |build| + latest_test_report_builds.find_each do |build| build.collect_test_reports!(test_reports) end end @@ -950,12 +1028,20 @@ module Ci def coverage_reports Gitlab::Ci::Reports::CoverageReports.new.tap do |coverage_reports| - latest_report_builds(Ci::JobArtifact.coverage_reports).each do |build| + latest_report_builds(Ci::JobArtifact.coverage_reports).includes(:project).find_each do |build| build.collect_coverage_reports!(coverage_reports) end end end + def codequality_reports + Gitlab::Ci::Reports::CodequalityReports.new.tap do |codequality_reports| + latest_report_builds(Ci::JobArtifact.codequality_reports).each do |build| + build.collect_codequality_reports!(codequality_reports) + end + end + end + def terraform_reports ::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports| latest_report_builds(::Ci::JobArtifact.terraform_reports).each do |build| @@ -1128,7 +1214,25 @@ module Ci end def pipeline_data - Gitlab::DataBuilder::Pipeline.build(self) + strong_memoize(:pipeline_data) do + Gitlab::DataBuilder::Pipeline.build(self) + end + end + + def merge_request_diff_sha + return unless merge_request? + + if merge_request_pipeline? + source_sha + else + sha + end + end + + def merge_request_diff + return unless merge_request? + + merge_request.merge_request_diff_for(merge_request_diff_sha) end def push_details diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index 5feb3b0a1e6..c58a3bab1a9 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -19,5 +19,9 @@ module Clusters with: Gitlab::Regex.cluster_agent_name_regex, message: Gitlab::Regex.cluster_agent_name_regex_message } + + def has_access_to?(requested_project) + requested_project == project + end end end diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb index d1d6defb713..6f4b273a2c8 100644 --- a/app/models/clusters/applications/helm.rb +++ b/app/models/clusters/applications/helm.rb @@ -4,8 +4,8 @@ require 'openssl' module Clusters module Applications - # DEPRECATED: This model represents the Helm 2 Tiller server, and is no longer being actively used. - # It is being kept around for a potential cleanup of the unused Tiller server. + # DEPRECATED: This model represents the Helm 2 Tiller server. + # It is being kept around to enable the cleanup of the unused Tiller server. class Helm < ApplicationRecord self.table_name = 'clusters_applications_helm' @@ -27,29 +27,11 @@ module Clusters end def set_initial_status - return unless not_installable? - - self.status = status_states[:installable] if cluster&.platform_kubernetes_active? - end - - # It can only be uninstalled if there are no other applications installed - # or with intermitent installation statuses in the database. - def allowed_to_uninstall? - strong_memoize(:allowed_to_uninstall) do - applications = nil - - Clusters::Cluster::APPLICATIONS.each do |application_name, klass| - next if application_name == 'helm' - - extra_apps = Clusters::Applications::Helm.where('EXISTS (?)', klass.select(1).where(cluster_id: cluster_id)) - - applications = applications ? applications.or(extra_apps) : extra_apps - end - - !applications.exists? - end + # The legacy Tiller server is not installable, which is the initial status of every app end + # DEPRECATED: This command is only for development and testing purposes, to simulate + # a Helm 2 cluster with an existing Tiller server. def install_command Gitlab::Kubernetes::Helm::V2::InitCommand.new( name: name, @@ -70,13 +52,6 @@ module Clusters ca_key.present? && ca_cert.present? end - def post_uninstall - cluster.kubeclient.delete_namespace(Gitlab::Kubernetes::Helm::NAMESPACE) - rescue Kubeclient::ResourceNotFoundError - # we actually don't care if the namespace is not present - # since we want to delete it anyway. - end - private def files diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 03f4caccccd..1e41b6f4f31 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.22.0' + VERSION = '0.23.0' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 3cf5542ae76..a34d8a6b98d 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -149,8 +149,8 @@ module Clusters scope :for_project_namespace, -> (namespace_id) { joins(:projects).where(projects: { namespace_id: namespace_id }) } scope :with_application_prometheus, -> { includes(:application_prometheus).joins(:application_prometheus) } - scope :with_project_alert_service_data, -> (project_ids) do - conditions = { projects: { alerts_service: [:data] } } + scope :with_project_http_integrations, -> (project_ids) do + conditions = { projects: :alert_management_http_integrations } includes(conditions).joins(conditions).where(projects: { id: project_ids }) end diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index b82b1887308..ad6699daa78 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -62,6 +62,14 @@ module Clusters end end + def uninstall_command + helm_command_module::DeleteCommand.new( + name: name, + rbac: cluster.platform_kubernetes_rbac?, + files: files + ) + end + def prepare_uninstall # Override if your application needs any action before # being uninstalled by Helm diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb index 00aeb7669ad..a022f174faf 100644 --- a/app/models/clusters/concerns/application_data.rb +++ b/app/models/clusters/concerns/application_data.rb @@ -3,14 +3,6 @@ module Clusters module Concerns module ApplicationData - def uninstall_command - helm_command_module::DeleteCommand.new( - name: name, - rbac: cluster.platform_kubernetes_rbac?, - files: files - ) - end - def repository nil end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index b85a902d58b..84de5828491 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -94,9 +94,20 @@ module Clusters return unless enabled? pods = read_pods(environment.deployment_namespace) + deployments = read_deployments(environment.deployment_namespace) - # extract_relevant_pod_data avoids uploading all the pod info into ReactiveCaching - { pods: extract_relevant_pod_data(pods) } + ingresses = if ::Feature.enabled?(:canary_ingress_weight_control, environment.project, default_enabled: true) + read_ingresses(environment.deployment_namespace) + else + [] + end + + # extract only the data required for display to avoid unnecessary caching + { + pods: extract_relevant_pod_data(pods), + deployments: extract_relevant_deployment_data(deployments), + ingresses: extract_relevant_ingress_data(ingresses) + } end def terminals(environment, data) @@ -109,6 +120,25 @@ module Clusters @kubeclient ||= build_kube_client! end + def rollout_status(environment, data) + project = environment.project + + deployments = filter_by_project_environment(data[:deployments], project.full_path_slug, environment.slug) + pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug) + ingresses = data[:ingresses].presence || [] + + ::Gitlab::Kubernetes::RolloutStatus.from_deployments(*deployments, pods_attrs: pods, ingresses: ingresses) + end + + def ingresses(namespace) + ingresses = read_ingresses(namespace) + ingresses.map { |ingress| ::Gitlab::Kubernetes::Ingress.new(ingress) } + end + + def patch_ingress(namespace, ingress, data) + kubeclient.patch_ingress(ingress.name, data, namespace) + end + private def default_namespace(project, environment_name:) @@ -140,6 +170,18 @@ module Clusters [] end + def read_deployments(namespace) + kubeclient.get_deployments(namespace: namespace).as_json + rescue Kubeclient::ResourceNotFoundError + [] + end + + def read_ingresses(namespace) + kubeclient.get_ingresses(namespace: namespace).as_json + rescue Kubeclient::ResourceNotFoundError + [] + end + def build_kube_client! raise "Incomplete settings" unless api_url @@ -231,8 +273,24 @@ module Clusters } end end + + def extract_relevant_deployment_data(deployments) + deployments.map do |deployment| + { + 'metadata' => deployment.fetch('metadata', {}).slice('name', 'generation', 'labels', 'annotations'), + 'spec' => deployment.fetch('spec', {}).slice('replicas'), + 'status' => deployment.fetch('status', {}).slice('observedGeneration') + } + end + end + + def extract_relevant_ingress_data(ingresses) + ingresses.map do |ingress| + { + 'metadata' => ingress.fetch('metadata', {}).slice('name', 'labels', 'annotations') + } + end + end end end end - -Clusters::Platforms::Kubernetes.prepend_if_ee('EE::Clusters::Platforms::Kubernetes') diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index 07c49ed48e6..a3ee8e4f364 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -86,9 +86,9 @@ class CommitCollection # Batch load full Commits from the repository # and map to a Hash of id => Commit - replacements = Hash[unenriched.map do |c| - [c.id, Commit.lazy(container, c.id)] - end.compact] + replacements = unenriched.each_with_object({}) do |c, result| + result[c.id] = Commit.lazy(container, c.id) + end.compact # Replace the commits, keeping the same order @commits = @commits.map do |original_commit| diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 49fc780f372..45944401c2d 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -70,8 +70,12 @@ module CacheMarkdownField def refresh_markdown_cache! updates = refresh_markdown_cache - - save_markdown(updates) + if updates.present? && save_markdown(updates) + # save_markdown updates DB columns directly, so compute and save mentions + # by calling store_mentions! or we end-up with missing mentions although those + # would appear in the notes, descriptions, etc in the UI + store_mentions! if mentionable_attributes_changed?(updates) + end end def cached_html_up_to_date?(markdown_field) @@ -106,7 +110,19 @@ module CacheMarkdownField def updated_cached_html_for(markdown_field) return unless cached_markdown_fields.markdown_fields.include?(markdown_field) - refresh_markdown_cache! if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field)) + if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field)) + # Invalidated due to Markdown content change + # We should not persist the updated HTML here since this will depend on whether the + # Markdown content change will be persisted. Both will be persisted together when the model is saved. + if changed_attributes.key?(markdown_field) + refresh_markdown_cache + else + # Invalidated due to stale HTML cache + # This could happen when the Markdown cache version is bumped or when a model is imported and the HTML is empty. + # We persist the updated HTML here so that subsequent calls to this method do not have to regenerate the HTML again. + refresh_markdown_cache! + end + end cached_html_for(markdown_field) end @@ -140,6 +156,46 @@ module CacheMarkdownField nil end + def store_mentions! + refs = all_references(self.author) + + references = {} + references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence + references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence + references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence + + # One retry is enough as next time `model_user_mention` should return the existing mention record, + # that threw the `ActiveRecord::RecordNotUnique` exception in first place. + self.class.safe_ensure_unique(retries: 1) do + user_mention = model_user_mention + + # this may happen due to notes polymorphism, so noteable_id may point to a record + # that no longer exists as we cannot have FK on noteable_id + break if user_mention.blank? + + user_mention.mentioned_users_ids = references[:mentioned_users_ids] + user_mention.mentioned_groups_ids = references[:mentioned_groups_ids] + user_mention.mentioned_projects_ids = references[:mentioned_projects_ids] + + if user_mention.has_mentions? + user_mention.save! + else + user_mention.destroy! + end + end + + true + end + + def mentionable_attributes_changed?(changes = saved_changes) + return false unless is_a?(Mentionable) + + self.class.mentionable_attrs.any? do |attr| + changes.key?(cached_markdown_fields.html_field(attr.first)) && + changes.fetch(cached_markdown_fields.html_field(attr.first)).last.present? + end + end + included do cattr_reader :cached_markdown_fields do Gitlab::MarkdownCache::FieldData.new diff --git a/app/models/concerns/can_move_repository_storage.rb b/app/models/concerns/can_move_repository_storage.rb new file mode 100644 index 00000000000..52c3a4106e3 --- /dev/null +++ b/app/models/concerns/can_move_repository_storage.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module CanMoveRepositoryStorage + extend ActiveSupport::Concern + + RepositoryReadOnlyError = Class.new(StandardError) + + # Tries to set repository as read_only, checking for existing Git transfers in + # progress beforehand. Setting a repository read-only will fail if it is + # already in that state. + # + # @return nil. Failures will raise an exception + def set_repository_read_only!(skip_git_transfer_check: false) + with_lock do + raise RepositoryReadOnlyError, _('Git transfer in progress') if + !skip_git_transfer_check && git_transfer_in_progress? + + raise RepositoryReadOnlyError, _('Repository already read-only') if + self.class.where(id: id).pick(:repository_read_only) + + raise ActiveRecord::RecordNotSaved, _('Database update failed') unless + update_column(:repository_read_only, true) + + nil + end + end + + # Set repository as writable again. Unlike setting it read-only, this will + # succeed if the repository is already writable. + def set_repository_writable! + with_lock do + raise ActiveRecord::RecordNotSaved, _('Database update failed') unless + update_column(:repository_read_only, false) + + nil + end + end + + def git_transfer_in_progress? + reference_counter(type: repository.repo_type).value > 0 + end + + def reference_counter(type:) + Gitlab::ReferenceCounter.new(type.identifier_for_container(self)) + end +end diff --git a/app/models/concerns/case_sensitivity.rb b/app/models/concerns/case_sensitivity.rb index abddbf1c7e3..31b5afd604d 100644 --- a/app/models/concerns/case_sensitivity.rb +++ b/app/models/concerns/case_sensitivity.rb @@ -11,12 +11,14 @@ module CaseSensitivity def iwhere(params) criteria = self - params.each do |key, value| + params.each do |column, value| + column = arel_table[column] unless column.is_a?(Arel::Attribute) + criteria = case value when Array - criteria.where(value_in(key, value)) + criteria.where(value_in(column, value)) else - criteria.where(value_equal(key, value)) + criteria.where(value_equal(column, value)) end end @@ -28,7 +30,7 @@ module CaseSensitivity def value_equal(column, value) lower_value = lower_value(value) - lower_column(arel_table[column]).eq(lower_value).to_sql + lower_column(column).eq(lower_value).to_sql end def value_in(column, values) @@ -36,7 +38,7 @@ module CaseSensitivity lower_value(value) end - lower_column(arel_table[column]).in(lower_values).to_sql + lower_column(column).in(lower_values).to_sql end def lower_value(value) diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb index bb8df37f649..e1f07fa162c 100644 --- a/app/models/concerns/enums/ci/pipeline.rb +++ b/app/models/concerns/enums/ci/pipeline.rb @@ -9,7 +9,8 @@ module Enums { unknown_failure: 0, config_error: 1, - external_validation_failure: 2 + external_validation_failure: 2, + deployments_limit_exceeded: 23 } end @@ -24,8 +25,6 @@ module Enums schedule: 4, api: 5, external: 6, - # TODO: Rename `pipeline` to `cross_project_pipeline` in 13.0 - # https://gitlab.com/gitlab-org/gitlab/issues/195991 pipeline: 7, chat: 8, webide: 9, @@ -53,6 +52,10 @@ module Enums sources.except(*dangling_sources.keys) end + def self.ci_branch_sources + ci_sources.except(:merge_request_event) + end + def self.ci_and_parent_sources ci_sources.merge(sources.slice(:parent_pipeline)) end diff --git a/app/models/concerns/enums/data_visualization_palette.rb b/app/models/concerns/enums/data_visualization_palette.rb new file mode 100644 index 00000000000..25002e64ba6 --- /dev/null +++ b/app/models/concerns/enums/data_visualization_palette.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Enums + # These color palettes are part of the Pajamas Design System. + # See https://design.gitlab.com/data-visualization/color/#categorical-data + module DataVisualizationPalette + def self.colors + { + blue: 0, + orange: 1, + aqua: 2, + green: 3, + magenta: 4 + } + end + + def self.weights + { + '50' => 0, + '100' => 1, + '200' => 2, + '300' => 3, + '400' => 4, + '500' => 5, + '600' => 6, + '700' => 7, + '800' => 8, + '900' => 9, + '950' => 10 + } + end + end +end diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb index f01bd60ef16..b08c05b1934 100644 --- a/app/models/concerns/enums/internal_id.rb +++ b/app/models/concerns/enums/internal_id.rb @@ -15,7 +15,8 @@ module Enums operations_user_lists: 7, alert_management_alerts: 8, sprints: 9, # iterations - design_management_designs: 10 + design_management_designs: 10, + incident_management_oncall_schedules: 11 } end end diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index 3dea4a9f5fb..9692941d8b2 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -88,7 +88,7 @@ module HasRepository group_branch_default_name = group&.default_branch_name if respond_to?(:group) - group_branch_default_name || Gitlab::CurrentSettings.default_branch_name + (group_branch_default_name || Gitlab::CurrentSettings.default_branch_name).presence end def reload_default_branch diff --git a/app/models/concerns/has_wiki_page_meta_attributes.rb b/app/models/concerns/has_wiki_page_meta_attributes.rb new file mode 100644 index 00000000000..136f2d00ce3 --- /dev/null +++ b/app/models/concerns/has_wiki_page_meta_attributes.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module HasWikiPageMetaAttributes + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + CanonicalSlugConflictError = Class.new(ActiveRecord::RecordInvalid) + WikiPageInvalid = Class.new(ArgumentError) + + included do + has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + + validates :title, length: { maximum: 255 }, allow_nil: false + validate :no_two_metarecords_in_same_container_can_have_same_canonical_slug + + scope :with_canonical_slug, ->(slug) do + slug_table_name = klass.reflect_on_association(:slugs).table_name + + joins(:slugs).where(slug_table_name => { canonical: true, slug: slug }) + end + end + + class_methods do + # Return the (updated) WikiPage::Meta record for a given wiki page + # + # If none is found, then a new record is created, and its fields are set + # to reflect the wiki_page passed. + # + # @param [String] last_known_slug + # @param [WikiPage] wiki_page + # + # This method raises errors on validation issues. + def find_or_create(last_known_slug, wiki_page) + raise WikiPageInvalid unless wiki_page.valid? + + container = wiki_page.wiki.container + known_slugs = [last_known_slug, wiki_page.slug].compact.uniq + raise 'No slugs found! This should not be possible.' if known_slugs.empty? + + transaction do + updates = wiki_page_updates(wiki_page) + found = find_by_canonical_slug(known_slugs, container) + meta = found || create!(updates.merge(container_attrs(container))) + + meta.update_state(found.nil?, known_slugs, wiki_page, updates) + + # We don't need to run validations here, since find_by_canonical_slug + # guarantees that there is no conflict in canonical_slug, and DB + # constraints on title and project_id/group_id enforce our other invariants + # This saves us a query. + meta + end + end + + def find_by_canonical_slug(canonical_slug, container) + meta, conflict = with_canonical_slug(canonical_slug) + .where(container_attrs(container)) + .limit(2) + + if conflict.present? + meta.errors.add(:canonical_slug, 'Duplicate value found') + raise CanonicalSlugConflictError.new(meta) + end + + meta + end + + private + + def wiki_page_updates(wiki_page) + last_commit_date = wiki_page.version_commit_timestamp || Time.now.utc + + { + title: wiki_page.title, + created_at: last_commit_date, + updated_at: last_commit_date + } + end + + def container_key + raise NotImplementedError + end + + def container_attrs(container) + { container_key => container.id } + end + end + + def canonical_slug + strong_memoize(:canonical_slug) { slugs.canonical.take&.slug } + end + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def canonical_slug=(slug) + return if @canonical_slug == slug + + if persisted? + transaction do + slugs.canonical.update_all(canonical: false) + page_slug = slugs.create_with(canonical: true).find_or_create_by(slug: slug) + page_slug.update_columns(canonical: true) unless page_slug.canonical? + end + else + slugs.new(slug: slug, canonical: true) + end + + @canonical_slug = slug + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + + def update_state(created, known_slugs, wiki_page, updates) + update_wiki_page_attributes(updates) + insert_slugs(known_slugs, created, wiki_page.slug) + self.canonical_slug = wiki_page.slug + end + + private + + def update_wiki_page_attributes(updates) + # Remove all unnecessary updates: + updates.delete(:updated_at) if updated_at == updates[:updated_at] + updates.delete(:created_at) if created_at <= updates[:created_at] + updates.delete(:title) if title == updates[:title] + + update_columns(updates) unless updates.empty? + end + + def insert_slugs(strings, is_new, canonical_slug) + creation = Time.current.utc + + slug_attrs = strings.map do |slug| + slug_attributes(slug, canonical_slug, is_new, creation) + end + slugs.insert_all(slug_attrs) unless !is_new && slug_attrs.size == 1 + + @canonical_slug = canonical_slug if is_new || strings.size == 1 # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + def slug_attributes(slug, canonical_slug, is_new, creation) + { + slug: slug, + canonical: (is_new && slug == canonical_slug), + created_at: creation, + updated_at: creation + }.merge(slug_meta_attributes) + end + + def slug_meta_attributes + { self.association(:slugs).reflection.foreign_key => id } + end + + def no_two_metarecords_in_same_container_can_have_same_canonical_slug + container_id = attributes[self.class.container_key.to_s] + + return unless container_id.present? && canonical_slug.present? + + offending = self.class.with_canonical_slug(canonical_slug).where(self.class.container_key => container_id) + offending = offending.where.not(id: id) if persisted? + + if offending.exists? + errors.add(:canonical_slug, 'each page in a wiki must have a distinct canonical slug') + end + end +end diff --git a/app/models/concerns/has_wiki_page_slug_attributes.rb b/app/models/concerns/has_wiki_page_slug_attributes.rb new file mode 100644 index 00000000000..3335eccbaf6 --- /dev/null +++ b/app/models/concerns/has_wiki_page_slug_attributes.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module HasWikiPageSlugAttributes + extend ActiveSupport::Concern + + included do + validates :slug, uniqueness: { scope: meta_foreign_key } + validates :slug, length: { maximum: 2048 }, allow_nil: false + validates :canonical, uniqueness: { + scope: meta_foreign_key, + if: :canonical?, + message: 'Only one slug can be canonical per wiki metadata record' + } + + scope :canonical, -> { where(canonical: true) } + + def update_columns(attrs = {}) + super(attrs.reverse_merge(updated_at: Time.current.utc)) + end + end + + def self.update_all(attrs = {}) + super(attrs.reverse_merge(updated_at: Time.current.utc)) + end +end diff --git a/app/models/concerns/ignorable_columns.rb b/app/models/concerns/ignorable_columns.rb index 744a1f0b5f3..4cbcb25406d 100644 --- a/app/models/concerns/ignorable_columns.rb +++ b/app/models/concerns/ignorable_columns.rb @@ -31,15 +31,13 @@ module IgnorableColumns alias_method :ignore_column, :ignore_columns def ignored_columns_details - unless defined?(@ignored_columns_details) - IGNORE_COLUMN_MUTEX.synchronize do - @ignored_columns_details ||= superclass.try(:ignored_columns_details)&.dup || {} - end - end + return @ignored_columns_details if defined?(@ignored_columns_details) - @ignored_columns_details + IGNORE_COLUMN_MONITOR.synchronize do + @ignored_columns_details ||= superclass.try(:ignored_columns_details)&.dup || {} + end end - IGNORE_COLUMN_MUTEX = Mutex.new + IGNORE_COLUMN_MONITOR = Monitor.new end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 7624a1a4e80..c3a394c1ca5 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -84,7 +84,6 @@ module Issuable validate :description_max_length_for_new_records_is_valid, on: :update before_validation :truncate_description_on_import! - after_save :store_mentions!, if: :any_mentionable_attributes_changed? scope :authored, ->(user) { where(author_id: user) } scope :recent, -> { reorder(id: :desc) } @@ -198,7 +197,7 @@ module Issuable end def severity - return IssuableSeverity::DEFAULT unless incident? + return IssuableSeverity::DEFAULT unless supports_severity? issuable_severity&.severity || IssuableSeverity::DEFAULT end @@ -305,14 +304,12 @@ module Issuable end def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [], with_cte: false) - params = { + highest_priority = highest_label_priority( target_type: name, target_column: "#{table_name}.id", project_column: "#{table_name}.#{project_foreign_key}", excluded_labels: excluded_labels - } - - highest_priority = highest_label_priority(params).to_sql + ).to_sql # When using CTE make sure to select the same columns that are on the group_by clause. # This prevents errors when ignored columns are present in the database. diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index b10e8547e86..5db077c178d 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -80,37 +80,6 @@ module Mentionable all_references(current_user).users end - def store_mentions! - refs = all_references(self.author) - - references = {} - references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence - references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence - references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence - - # One retry should be enough as next time `model_user_mention` should return the existing mention record, that - # threw the `ActiveRecord::RecordNotUnique` exception in first place. - self.class.safe_ensure_unique(retries: 1) do - user_mention = model_user_mention - - # this may happen due to notes polymorphism, so noteable_id may point to a record that no longer exists - # as we cannot have FK on noteable_id - break if user_mention.blank? - - user_mention.mentioned_users_ids = references[:mentioned_users_ids] - user_mention.mentioned_groups_ids = references[:mentioned_groups_ids] - user_mention.mentioned_projects_ids = references[:mentioned_projects_ids] - - if user_mention.has_mentions? - user_mention.save! - else - user_mention.destroy! - end - end - - true - end - def referenced_users User.where(id: user_mentions.select("unnest(mentioned_users_ids)")) end @@ -216,12 +185,6 @@ module Mentionable source.select { |key, val| mentionable.include?(key) } end - def any_mentionable_attributes_changed? - self.class.mentionable_attrs.any? do |attr| - saved_changes.key?(attr.first) - end - end - # Determine whether or not a cross-reference Note has already been created between this Mentionable and # the specified target. def cross_reference_exists?(target) @@ -237,12 +200,12 @@ module Mentionable end # User mention that is parsed from model description rather then its related notes. - # Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. + # Models that have a description attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. # Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have # a description attribute. # # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception - # in a multithreaded environment. Make sure to use it within a *safe_ensure_unique* block. + # in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block. def model_user_mention user_mentions.where(note_id: nil).first_or_initialize end diff --git a/app/models/concerns/optimized_issuable_label_filter.rb b/app/models/concerns/optimized_issuable_label_filter.rb index 7be4a26d4fa..82055822cfb 100644 --- a/app/models/concerns/optimized_issuable_label_filter.rb +++ b/app/models/concerns/optimized_issuable_label_filter.rb @@ -1,6 +1,15 @@ # frozen_string_literal: true module OptimizedIssuableLabelFilter + extend ActiveSupport::Concern + + prepended do + extend Gitlab::Cache::RequestCache + + # Avoid repeating label queries times when the finder is instantiated multiple times during the request. + request_cache(:find_label_ids) { [root_namespace.id, params.label_names] } + end + def by_label(items) return items unless params.labels? @@ -41,7 +50,7 @@ module OptimizedIssuableLabelFilter def issuables_with_selected_labels(items, target_model) if root_namespace - all_label_ids = find_label_ids(root_namespace) + all_label_ids = find_label_ids # Found less labels in the DB than we were searching for. Return nothing. return items.none if all_label_ids.size != params.label_names.size @@ -57,18 +66,20 @@ module OptimizedIssuableLabelFilter items end - def find_label_ids(root_namespace) - finder_params = { - include_subgroups: true, - include_ancestor_groups: true, - include_descendant_groups: true, - group: root_namespace, - title: params.label_names - } - - LabelsFinder - .new(nil, finder_params) - .execute(skip_authorization: true) + def find_label_ids + group_labels = Label + .where(project_id: nil) + .where(title: params.label_names) + .where(group_id: root_namespace.self_and_descendants.select(:id)) + + project_labels = Label + .where(group_id: nil) + .where(title: params.label_names) + .where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendants.select(:id))) + + Label + .from_union([group_labels, project_labels], remove_duplicates: false) + .reorder(nil) .pluck(:title, :id) .group_by(&:first) .values diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index b69fb2931c3..07bec07e556 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -70,6 +70,14 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:metrics_dashboard_access_level, value) end + def analytics_access_level=(value) + write_feature_attribute_string(:analytics_access_level, value) + end + + def operations_access_level=(value) + write_feature_attribute_string(:operations_access_level, value) + end + private def write_feature_attribute_boolean(field, value) diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index cddca72f91f..65195a8d5aa 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -12,6 +12,10 @@ module ProtectedRef delegate :matching, :matches?, :wildcard?, to: :ref_matcher scope :for_project, ->(project) { where(project: project) } + + def allow_multiple?(type) + false + end end def commit @@ -29,7 +33,7 @@ module ProtectedRef # to fail. has_many :"#{type}_access_levels", inverse_of: self.model_name.singular - validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." } + validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }, unless: -> { allow_multiple?(type) } accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true end diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index 28dc3366e51..5e38ce7cad8 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -45,6 +45,7 @@ module ProtectedRefAccess end def check_access(user) + return false unless user return true if user.admin? user.can?(:push_code, project) && diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 3470bdab5fb..dbc70ac2218 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true # The usage of the ReactiveCaching module is documented here: -# https://docs.gitlab.com/ee/development/reactive_caching.md +# https://docs.gitlab.com/ee/development/reactive_caching.html +# module ReactiveCaching extend ActiveSupport::Concern diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb new file mode 100644 index 00000000000..a45b4626628 --- /dev/null +++ b/app/models/concerns/repository_storage_movable.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module RepositoryStorageMovable + extend ActiveSupport::Concern + include AfterCommitQueue + + included do + scope :order_created_at_desc, -> { order(created_at: :desc) } + + validates :container, presence: true + validates :state, presence: true + validates :source_storage_name, + on: :create, + presence: true, + inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } + validates :destination_storage_name, + on: :create, + presence: true, + inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } + validate :container_repository_writable, on: :create + + default_value_for(:destination_storage_name, allows_nil: false) do + pick_repository_storage + end + + state_machine initial: :initial do + event :schedule do + transition initial: :scheduled + end + + event :start do + transition scheduled: :started + end + + event :finish_replication do + transition started: :replicated + end + + event :finish_cleanup do + transition replicated: :finished + end + + event :do_fail do + transition [:initial, :scheduled, :started] => :failed + transition replicated: :cleanup_failed + end + + around_transition initial: :scheduled do |storage_move, block| + block.call + + begin + storage_move.container.set_repository_read_only!(skip_git_transfer_check: true) + rescue => err + storage_move.add_error(err.message) + next false + end + + storage_move.run_after_commit do + storage_move.schedule_repository_storage_update_worker + end + + true + end + + before_transition started: :replicated do |storage_move| + storage_move.container.set_repository_writable! + + storage_move.update_repository_storage(storage_move.destination_storage_name) + end + + before_transition started: :failed do |storage_move| + storage_move.container.set_repository_writable! + end + + state :initial, value: 1 + state :scheduled, value: 2 + state :started, value: 3 + state :finished, value: 4 + state :failed, value: 5 + state :replicated, value: 6 + state :cleanup_failed, value: 7 + end + end + + class_methods do + private + + def pick_repository_storage + container_klass = reflect_on_association(:container).class_name.constantize + + container_klass.pick_repository_storage + end + end + + # Projects, snippets, and group wikis has different db structure. In projects, + # we need to update some columns in this step, but we don't with the other resources. + # + # Therefore, we create this No-op method for snippets and wikis and let project + # overwrite it in their implementation. + def update_repository_storage(new_storage) + # No-op + end + + def schedule_repository_storage_update_worker + raise NotImplementedError + end + + def add_error(message) + errors.add(error_key, message) + end + + private + + def container_repository_writable + add_error(_('is read only')) if container&.repository_read_only? + end + + def error_key + raise NotImplementedError + end +end diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index c70ce9bebcc..71d8e06de76 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -4,6 +4,36 @@ # Object must have name and path db fields and respond to parent and parent_changed? methods. module Routable extend ActiveSupport::Concern + include CaseSensitivity + + # Finds a Routable object by its full path, without knowing the class. + # + # Usage: + # + # Routable.find_by_full_path('groupname') # -> Group + # Routable.find_by_full_path('groupname/projectname') # -> Project + # + # Returns a single object, or nil. + def self.find_by_full_path(path, follow_redirects: false, route_scope: Route, redirect_route_scope: RedirectRoute) + return unless path.present? + + # Case sensitive match first (it's cheaper and the usual case) + # If we didn't have an exact match, we perform a case insensitive search + # + # We need to qualify the columns with the table name, to support both direct lookups on + # Route/RedirectRoute, and scoped lookups through the Routable classes. + route = + route_scope.find_by(routes: { path: path }) || + route_scope.iwhere(Route.arel_table[:path] => path).take + + if follow_redirects + route ||= redirect_route_scope.iwhere(RedirectRoute.arel_table[:path] => path).take + end + + return unless route + + route.is_a?(Routable) ? route : route.source + end included do # Remove `inverse_of: source` when upgraded to rails 5.2 @@ -30,15 +60,14 @@ module Routable # # Returns a single object, or nil. def find_by_full_path(path, follow_redirects: false) - # Case sensitive match first (it's cheaper and the usual case) - # If we didn't have an exact match, we perform a case insensitive search - found = includes(:route).find_by(routes: { path: path }) || where_full_path_in([path]).take - - return found if found - - if follow_redirects - joins(:redirect_routes).find_by("LOWER(redirect_routes.path) = LOWER(?)", path) - end + # TODO: Optimize these queries by avoiding joins + # https://gitlab.com/gitlab-org/gitlab/-/issues/292252 + Routable.find_by_full_path( + path, + follow_redirects: follow_redirects, + route_scope: includes(:route).references(:routes), + redirect_route_scope: joins(:redirect_routes) + ) end # Builds a relation to find multiple objects by their full paths. diff --git a/app/models/concerns/shardable.rb b/app/models/concerns/shardable.rb index c0883c08289..4bebb99d195 100644 --- a/app/models/concerns/shardable.rb +++ b/app/models/concerns/shardable.rb @@ -8,6 +8,7 @@ module Shardable scope :for_repository_storage, -> (repository_storage) { joins(:shard).where(shards: { name: repository_storage }) } scope :excluding_repository_storage, -> (repository_storage) { joins(:shard).where.not(shards: { name: repository_storage }) } + scope :for_shard, -> (shard) { where(shard_id: shard) } validates :shard, presence: true end diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 23fd73f2904..8273059b30c 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -12,10 +12,16 @@ module Timebox include FromUnion TimeboxStruct = Struct.new(:title, :name, :id) do + include GlobalID::Identification + # Ensure these models match the interface required for exporting def serializable_hash(_opts = {}) { title: title, name: name, id: id } end + + def self.declarative_policy_class + "TimeboxPolicy" + end end # Represents a "No Timebox" state used for filtering Issues and Merge diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index a1f83884f02..535cf25eb9d 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -57,6 +57,13 @@ module TokenAuthenticatable token = read_attribute(token_field) token.present? && ActiveSupport::SecurityUtils.secure_compare(other_token, token) end + + # Base strategy delegates to this method for formatting a token before + # calling set_token. Can be overridden in models to e.g. add a prefix + # to the tokens + mod.define_method("format_#{token_field}") do |token| + token + end end def token_authenticatable_module diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index aafd0b538a3..f72a41f06b1 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -18,10 +18,15 @@ module TokenAuthenticatableStrategies raise NotImplementedError end - def set_token(instance) + def set_token(instance, token) raise NotImplementedError end + # Default implementation returns the token as-is + def format_token(instance, token) + instance.send("format_#{@token_field}", token) # rubocop:disable GitlabSecurity/PublicSend + end + def ensure_token(instance) write_new_token(instance) unless token_set?(instance) get_token(instance) @@ -57,7 +62,8 @@ module TokenAuthenticatableStrategies def write_new_token(instance) new_token = generate_available_token - set_token(instance, new_token) + formatted_token = format_token(instance, new_token) + set_token(instance, formatted_token) end def unique diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index 325a5531926..473b430bb04 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -15,14 +15,13 @@ module TriggerableHooks wiki_page_hooks: :wiki_page_events, deployment_hooks: :deployment_events, feature_flag_hooks: :feature_flag_events, - release_hooks: :releases_events + release_hooks: :releases_events, + member_hooks: :member_events }.freeze extend ActiveSupport::Concern class_methods do - attr_reader :triggerable_hooks - attr_reader :triggers def hooks_for(trigger) diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 4adbd37608f..0d7ce966537 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -25,8 +25,7 @@ class ContainerRepository < ApplicationRecord .with_container_registry .select(:id) - ContainerRepository - .joins("INNER JOIN (#{project_scope.to_sql}) projects on projects.id=container_repositories.project_id") + joins("INNER JOIN (#{project_scope.to_sql}) projects on projects.id=container_repositories.project_id") end scope :for_project_id, ->(project_id) { where(project_id: project_id) } scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index ed22d4ba231..4f8f86965d7 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -17,7 +17,7 @@ class CustomEmoji < ApplicationRecord uniqueness: { scope: [:namespace_id, :name] }, presence: true, length: { maximum: 36 }, - format: { with: /\A([a-z0-9]+[-_]?)+[a-z0-9]+\z/ } + format: { with: /\A[a-z0-9][a-z0-9\-_]*[a-z0-9]\z/ } private diff --git a/app/models/cycle_analytics/level_base.rb b/app/models/cycle_analytics/level_base.rb index 967de9a22b4..901636a7263 100644 --- a/app/models/cycle_analytics/level_base.rb +++ b/app/models/cycle_analytics/level_base.rb @@ -4,9 +4,52 @@ module CycleAnalytics module LevelBase STAGES = %i[issue plan code test review staging].freeze + # This is a temporary adapter class which makes the new value stream (cycle analytics) + # backend compatible with the old implementation. + class StageAdapter + def initialize(stage, options) + @stage = stage + @options = options + end + + # rubocop: disable CodeReuse/Presenter + def as_json(serializer: AnalyticsStageSerializer) + presenter = Analytics::CycleAnalytics::StagePresenter.new(stage) + + serializer.new.represent(OpenStruct.new( + title: presenter.title, + description: presenter.description, + legend: presenter.legend, + name: stage.name, + project_median: median, + group_median: median + )) + end + # rubocop: enable CodeReuse/Presenter + + def events + data_collector.records_fetcher.serialized_records + end + + def median + data_collector.median.seconds + end + + alias_method :project_median, :median + alias_method :group_median, :median + + private + + attr_reader :stage, :options + + def data_collector + @data_collector ||= Gitlab::Analytics::CycleAnalytics::DataCollector.new(stage: stage, params: options) + end + end + def all_medians_by_stage STAGES.each_with_object({}) do |stage_name, medians_per_stage| - medians_per_stage[stage_name] = self[stage_name].project_median + medians_per_stage[stage_name] = self[stage_name].median end end @@ -16,12 +59,16 @@ module CycleAnalytics end end - def no_stats? - stats.all? { |hash| hash[:value].nil? } + def [](stage_name) + if Feature.enabled?(:new_project_level_vsa_backend, resource_parent, default_enabled: true) + StageAdapter.new(build_stage(stage_name), options) + else + Gitlab::CycleAnalytics::Stage[stage_name].new(options: options) + end end - def [](stage_name) - Gitlab::CycleAnalytics::Stage[stage_name].new(options: options) + def stage_params_by_name(name) + Gitlab::Analytics::CycleAnalytics::DefaultStages.find_by_name!(name) end end end diff --git a/app/models/cycle_analytics/project_level.rb b/app/models/cycle_analytics/project_level.rb index 591435baf34..26cdcc0db4b 100644 --- a/app/models/cycle_analytics/project_level.rb +++ b/app/models/cycle_analytics/project_level.rb @@ -20,5 +20,14 @@ module CycleAnalytics def permissions(user:) Gitlab::CycleAnalytics::Permissions.get(user: user, project: project) end + + def build_stage(stage_name) + stage_params = stage_params_by_name(stage_name).merge(project: project) + Analytics::CycleAnalytics::ProjectStage.new(stage_params) + end + + def resource_parent + project + end end end diff --git a/app/models/dependency_proxy.rb b/app/models/dependency_proxy.rb index 510a304ff17..9cbaf7e9884 100644 --- a/app/models/dependency_proxy.rb +++ b/app/models/dependency_proxy.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true module DependencyProxy + URL_SUFFIX = '/dependency_proxy/containers' + def self.table_name_prefix 'dependency_proxy_' end diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb new file mode 100644 index 00000000000..f3c7f34e0d7 --- /dev/null +++ b/app/models/dependency_proxy/manifest.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class DependencyProxy::Manifest < ApplicationRecord + include FileStoreMounter + + belongs_to :group + + validates :group, presence: true + validates :file, presence: true + validates :file_name, presence: true + validates :digest, presence: true + + mount_file_store_uploader DependencyProxy::FileUploader + + scope :find_or_initialize_by_file_name, ->(file_name) { find_or_initialize_by(file_name: file_name) } +end diff --git a/app/models/dependency_proxy/registry.rb b/app/models/dependency_proxy/registry.rb index 471d5be2600..6492acf325a 100644 --- a/app/models/dependency_proxy/registry.rb +++ b/app/models/dependency_proxy/registry.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true class DependencyProxy::Registry - AUTH_URL = 'https://auth.docker.io'.freeze - LIBRARY_URL = 'https://registry-1.docker.io/v2'.freeze + AUTH_URL = 'https://auth.docker.io' + LIBRARY_URL = 'https://registry-1.docker.io/v2' + PROXY_AUTH_URL = Gitlab::Utils.append_path(Gitlab.config.gitlab.url, "jwt/auth") class << self def auth_url(image) @@ -17,6 +18,10 @@ class DependencyProxy::Registry "#{LIBRARY_URL}/#{image_path(image)}/blobs/#{blob_sha}" end + def authenticate_header + "Bearer realm=\"#{PROXY_AUTH_URL}\",service=\"#{::Auth::DependencyProxyAuthenticationService::AUDIENCE}\"" + end + private def image_path(image) diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 36ac1bdb236..b93b714ec8b 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -37,12 +37,19 @@ class Deployment < ApplicationRecord end scope :for_status, -> (status) { where(status: status) } + scope :for_project, -> (project_id) { where(project_id: project_id) } scope :visible, -> { where(status: %i[running success failed canceled]) } scope :stoppable, -> { where.not(on_stop: nil).where.not(deployable_id: nil).success } scope :active, -> { where(status: %i[created running]) } - scope :older_than, -> (deployment) { where('id < ?', deployment.id) } - scope :with_deployable, -> { includes(:deployable).where('deployable_id IS NOT NULL') } + scope :older_than, -> (deployment) { where('deployments.id < ?', deployment.id) } + scope :with_deployable, -> { joins('INNER JOIN ci_builds ON ci_builds.id = deployments.deployable_id').preload(:deployable) } + + scope :finished_between, -> (start_date, end_date = nil) do + selected = where('deployments.finished_at >= ?', start_date) + selected = selected.where('deployments.finished_at < ?', end_date) if end_date + selected + end FINISHED_STATUSES = %i[success failed canceled].freeze @@ -63,6 +70,10 @@ class Deployment < ApplicationRecord transition any - [:canceled] => :canceled end + event :skip do + transition any - [:skipped] => :skipped + end + before_transition any => FINISHED_STATUSES do |deployment| deployment.finished_at = Time.current end @@ -105,7 +116,8 @@ class Deployment < ApplicationRecord running: 1, success: 2, failed: 3, - canceled: 4 + canceled: 4, + skipped: 5 } def self.last_for_environment(environment) @@ -144,6 +156,10 @@ class Deployment < ApplicationRecord project.repository.delete_refs(*ref_paths.flatten) end end + + def latest_for_sha(sha) + where(sha: sha).order(id: :desc).take + end end def commit @@ -297,6 +313,8 @@ class Deployment < ApplicationRecord drop when 'canceled' cancel + when 'skipped' + skip else raise ArgumentError, "The status #{status.inspect} is invalid" end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 4b2e62bf761..944a64f5419 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -19,7 +19,7 @@ class DiffNote < Note # EE might have added a type when the module was prepended validates :noteable_type, inclusion: { in: -> (_note) { noteable_types } } validate :positions_complete - validate :verify_supported + validate :verify_supported, unless: :importing? before_validation :set_line_code, if: :on_text?, unless: :importing? after_save :keep_around_commits, unless: :importing? @@ -149,7 +149,7 @@ class DiffNote < Note end def supported? - for_commit? || for_design? || self.noteable.has_complete_diff_refs? + for_commit? || for_design? || self.noteable&.has_complete_diff_refs? end def set_line_code diff --git a/app/models/environment.rb b/app/models/environment.rb index deded3eeae0..31a95bb1b5d 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -32,6 +32,7 @@ class Environment < ApplicationRecord has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment' has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus' has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline' + has_one :upcoming_deployment, -> { running.order('deployments.id DESC') }, class_name: 'Deployment' has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment before_validation :nullify_external_url @@ -60,6 +61,7 @@ class Environment < ApplicationRecord addressable_url: true delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true + delegate :auto_rollback_enabled?, to: :project scope :available, -> { with_state(:available) } scope :stopped, -> { with_state(:stopped) } @@ -240,10 +242,6 @@ class Environment < ApplicationRecord def cancel_deployment_jobs! jobs = active_deployments.with_deployable jobs.each do |deployment| - # guard against data integrity issues, - # for example https://gitlab.com/gitlab-org/gitlab/-/issues/218659#note_348823660 - next unless deployment.deployable - Gitlab::OptimisticLocking.retry_lock(deployment.deployable) do |deployable| deployable.cancel! if deployable&.cancelable? end @@ -387,8 +385,38 @@ class Environment < ApplicationRecord !!deployment_platform&.cluster&.application_elastic_stack_available? end + def rollout_status + return unless rollout_status_available? + + result = rollout_status_with_reactive_cache + + result || ::Gitlab::Kubernetes::RolloutStatus.loading + end + + def ingresses + return unless rollout_status_available? + + deployment_platform.ingresses(deployment_namespace) + end + + def patch_ingress(ingress, data) + return unless rollout_status_available? + + deployment_platform.patch_ingress(deployment_namespace, ingress, data) + end + private + def rollout_status_available? + has_terminals? + end + + def rollout_status_with_reactive_cache + with_reactive_cache do |data| + deployment_platform.rollout_status(self, data) + end + end + def has_metrics_and_can_query? has_metrics? && prometheus_adapter.can_query? end @@ -396,11 +424,6 @@ class Environment < ApplicationRecord def generate_slug self.slug = Gitlab::Slug::Environment.new(name).generate end - - # Overrides ReactiveCaching default to activate limit checking behind a FF - def reactive_cache_limit_enabled? - Feature.enabled?(:reactive_caching_limit_environment, project, default_enabled: true) - end end Environment.prepend_if_ee('EE::Environment') diff --git a/app/models/experiment.rb b/app/models/experiment.rb index f179a1fc6ce..a4cacab25ee 100644 --- a/app/models/experiment.rb +++ b/app/models/experiment.rb @@ -2,17 +2,24 @@ class Experiment < ApplicationRecord has_many :experiment_users + has_many :experiment_subjects, inverse_of: :experiment validates :name, presence: true, uniqueness: true, length: { maximum: 255 } - def self.add_user(name, group_type, user) - return unless experiment = find_or_create_by(name: name) + def self.add_user(name, group_type, user, context = {}) + find_or_create_by!(name: name).record_user_and_group(user, group_type, context) + end - experiment.record_user_and_group(user, group_type) + def self.record_conversion_event(name, user) + find_or_create_by!(name: name).record_conversion_event_for_user(user) end # Create or update the recorded experiment_user row for the user in this experiment. - def record_user_and_group(user, group_type) - experiment_users.find_or_initialize_by(user: user).update!(group_type: group_type) + def record_user_and_group(user, group_type, context = {}) + experiment_users.find_or_initialize_by(user: user).update!(group_type: group_type, context: context) + end + + def record_conversion_event_for_user(user) + experiment_users.find_by(user: user, converted_at: nil)&.touch(:converted_at) end end diff --git a/app/models/experiment_subject.rb b/app/models/experiment_subject.rb new file mode 100644 index 00000000000..51ffc0b304e --- /dev/null +++ b/app/models/experiment_subject.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ExperimentSubject < ApplicationRecord + include ::Gitlab::Experimentation::GroupTypes + + belongs_to :experiment, inverse_of: :experiment_subjects + belongs_to :user + belongs_to :group + belongs_to :project + + validates :experiment, presence: true + validates :variant, presence: true + validate :must_have_one_subject_present + + enum variant: { GROUP_CONTROL => 0, GROUP_EXPERIMENTAL => 1 } + + private + + def must_have_one_subject_present + if non_nil_subjects.length != 1 + errors.add(:base, s_("ExperimentSubject|Must have exactly one of User, Group, or Project.")) + end + end + + def non_nil_subjects + @non_nil_subjects ||= [user, group, project].reject(&:blank?) + end +end diff --git a/app/models/exported_protected_branch.rb b/app/models/exported_protected_branch.rb new file mode 100644 index 00000000000..6e8abbc2389 --- /dev/null +++ b/app/models/exported_protected_branch.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ExportedProtectedBranch < ProtectedBranch + has_many :push_access_levels, -> { where(deploy_key_id: nil) }, class_name: "ProtectedBranch::PushAccessLevel", foreign_key: :protected_branch_id +end diff --git a/app/models/group.rb b/app/models/group.rb index 3509299a579..739135e82dd 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -73,6 +73,7 @@ class Group < Namespace has_one :dependency_proxy_setting, class_name: 'DependencyProxy::GroupSetting' has_many :dependency_proxy_blobs, class_name: 'DependencyProxy::Blob' + has_many :dependency_proxy_manifests, class_name: 'DependencyProxy::Manifest' accepts_nested_attributes_for :variables, allow_destroy: true @@ -402,6 +403,13 @@ class Group < Namespace .where(source_id: self_and_hierarchy.reorder(nil).select(:id)) end + def direct_and_indirect_members_with_inactive + GroupMember + .non_request + .non_invite + .where(source_id: self_and_hierarchy.reorder(nil).select(:id)) + end + def users_with_parents User .where(id: members_with_parents.select(:user_id)) @@ -428,6 +436,20 @@ class Group < Namespace ]) end + # Returns all users (also inactive) that are members of the group because: + # 1. They belong to the group + # 2. They belong to a project that belongs to the group + # 3. They belong to a sub-group or project in such sub-group + # 4. They belong to an ancestor group + def direct_and_indirect_users_with_inactive + User.from_union([ + User + .where(id: direct_and_indirect_members_with_inactive.select(:user_id)) + .reorder(nil), + project_users_with_descendants + ]) + end + def users_count members.count end diff --git a/app/models/group_import_state.rb b/app/models/group_import_state.rb index 89602e40357..c47ae3a80ba 100644 --- a/app/models/group_import_state.rb +++ b/app/models/group_import_state.rb @@ -3,6 +3,8 @@ class GroupImportState < ApplicationRecord self.primary_key = :group_id + MAX_ERROR_LENGTH = 255 + belongs_to :group, inverse_of: :import_state belongs_to :user, optional: false @@ -30,7 +32,7 @@ class GroupImportState < ApplicationRecord after_transition any => :failed do |state, transition| last_error = transition.args.first - state.update_column(:last_error, last_error) if last_error + state.update_column(:last_error, last_error.truncate(MAX_ERROR_LENGTH)) if last_error end end diff --git a/app/models/identity.rb b/app/models/identity.rb index 40d9f856abf..fc97c68b756 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -18,6 +18,9 @@ class Identity < ApplicationRecord scope :with_extern_uid, ->(provider, extern_uid) do iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider) end + scope :with_any_extern_uid, ->(provider) do + where.not(extern_uid: nil).with_provider(provider) + end def ldap? Gitlab::Auth::OAuth::Provider.ldap_provider?(provider) diff --git a/app/models/issue.rb b/app/models/issue.rb index 7dc18cacd7c..253f4465cd9 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -22,6 +22,7 @@ class Issue < ApplicationRecord include Presentable include IssueAvailableFeatures include Todoable + include FromUnion DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -90,6 +91,8 @@ class Issue < ApplicationRecord alias_attribute :parent_ids, :project_id alias_method :issuing_parent, :project + alias_attribute :external_author, :service_desk_reply_to + scope :in_projects, ->(project_ids) { where(project_id: project_ids) } scope :not_in_projects, ->(project_ids) { where.not(project_id: project_ids) } @@ -306,6 +309,7 @@ class Issue < ApplicationRecord !moved? && persisted? && user.can?(:admin_issue, self.project) end + alias_method :can_clone?, :can_move? def to_branch_name if self.confidential? @@ -328,7 +332,9 @@ class Issue < ApplicationRecord related_issues = ::Issue .select(['issues.*', 'issue_links.id AS issue_link_id', 'issue_links.link_type as issue_link_type_value', - 'issue_links.target_id as issue_link_source_id']) + 'issue_links.target_id as issue_link_source_id', + 'issue_links.created_at as issue_link_created_at', + 'issue_links.updated_at as issue_link_updated_at']) .joins("INNER JOIN issue_links ON (issue_links.source_id = issues.id AND issue_links.target_id = #{id}) OR diff --git a/app/models/iteration.rb b/app/models/iteration.rb index ba7cd973e9d..7a35bb1cd1f 100644 --- a/app/models/iteration.rb +++ b/app/models/iteration.rb @@ -32,9 +32,9 @@ class Iteration < ApplicationRecord scope :closed, -> { with_state(:closed) } scope :within_timeframe, -> (start_date, end_date) do - where('start_date is not NULL or due_date is not NULL') - .where('start_date is NULL or start_date <= ?', end_date) - .where('due_date is NULL or due_date >= ?', start_date) + where('start_date IS NOT NULL OR due_date IS NOT NULL') + .where('start_date IS NULL OR start_date <= ?', end_date) + .where('due_date IS NULL OR due_date >= ?', start_date) end scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date >= ?', Date.current) } diff --git a/app/models/label.rb b/app/models/label.rb index 3c70eef9bd5..54129c7c7f3 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -257,7 +257,7 @@ class Label < ApplicationRecord end def present(attributes) - super(attributes.merge(presenter_class: ::LabelPresenter)) + super(**attributes.merge(presenter_class: ::LabelPresenter)) end private diff --git a/app/models/label_priority.rb b/app/models/label_priority.rb index 8f8f36efbfe..11854404a71 100644 --- a/app/models/label_priority.rb +++ b/app/models/label_priority.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true class LabelPriority < ApplicationRecord + include Importable + belongs_to :project belongs_to :label - validates :project, :label, :priority, presence: true + validates :label, presence: true, unless: :importing? + validates :project, :priority, presence: true validates :label_id, uniqueness: { scope: :project_id } validates :priority, numericality: { only_integer: true, greater_than_or_equal_to: 0 } end diff --git a/app/models/list.rb b/app/models/list.rb index ec211dfd497..1df565c83e6 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -7,7 +7,7 @@ class List < ApplicationRecord belongs_to :label has_many :list_user_preferences - enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4 } + enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4, iteration: 5 } validates :board, :list_type, presence: true, unless: :importing? validates :label, :position, presence: true, if: :label? diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index d379f85bc15..043f07cf9f3 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -233,13 +233,13 @@ class MergeRequest < ApplicationRecord cannot_be_merged_rechecking? ? 'checking' : merge_status end - validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_without_fork?] + validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?] validates :source_branch, presence: true validates :target_project, presence: true validates :target_branch, presence: true validates :merge_user, presence: true, if: :auto_merge_enabled?, unless: :importing? - validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?] - validate :validate_fork, unless: :closed_without_fork? + validate :validate_branches, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?] + validate :validate_fork, unless: :closed_or_merged_without_fork? validate :validate_target_project, on: :create scope :by_source_or_target_branch, ->(branch_name) do @@ -274,7 +274,7 @@ class MergeRequest < ApplicationRecord scope :with_api_entity_associations, -> { preload_routables .preload(:assignees, :author, :unresolved_notes, :labels, :milestone, - :timelogs, :latest_merge_request_diff, + :timelogs, :latest_merge_request_diff, :reviewers, target_project: :project_feature, metrics: [:latest_closed_by, :merged_by]) } @@ -314,6 +314,38 @@ class MergeRequest < ApplicationRecord scope :with_jira_issue_keys, -> { where('title ~ :regex OR merge_requests.description ~ :regex', regex: Gitlab::Regex.jira_issue_key_regex.source) } + scope :review_requested, -> do + where(reviewers_subquery.exists) + end + + scope :no_review_requested, -> do + where(reviewers_subquery.exists.not) + end + + scope :review_requested_to, ->(user) do + where( + reviewers_subquery + .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user)) + .exists + ) + end + + scope :no_review_requested_to, ->(user) do + where( + reviewers_subquery + .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user)) + .exists + .not + ) + end + + def self.total_time_to_merge + join_metrics + .merge(MergeRequest::Metrics.with_valid_time_to_merge) + .pluck(MergeRequest::Metrics.time_to_merge_expression) + .first + end + after_save :keep_around_commit, unless: :importing? alias_attribute :project, :target_project @@ -361,6 +393,12 @@ class MergeRequest < ApplicationRecord end end + def self.reviewers_subquery + MergeRequestReviewer.arel_table + .project('true') + .where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) + end + def rebase_in_progress? rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid) end @@ -845,8 +883,8 @@ class MergeRequest < ApplicationRecord !!merge_jid && !merged? && Gitlab::SidekiqStatus.running?(merge_jid) end - def closed_without_fork? - closed? && source_project_missing? + def closed_or_merged_without_fork? + (closed? || merged?) && source_project_missing? end def source_project_missing? @@ -941,7 +979,7 @@ class MergeRequest < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def diffable_merge_ref? - merge_ref_head.present? && (Feature.enabled?(:display_merge_conflicts_in_diff, project) || can_be_merged?) + open? && merge_ref_head.present? && (Feature.enabled?(:display_merge_conflicts_in_diff, project) || can_be_merged?) end # Returns boolean indicating the merge_status should be rechecked in order to @@ -1423,6 +1461,20 @@ class MergeRequest < ApplicationRecord compare_reports(Ci::GenerateCoverageReportsService) end + def has_codequality_reports? + return false unless Feature.enabled?(:codequality_mr_diff, project) + + actual_head_pipeline&.has_reports?(Ci::JobArtifact.codequality_reports) + end + + def compare_codequality_reports + unless has_codequality_reports? + return { status: :error, status_reason: _('This merge request does not have codequality reports') } + end + + compare_reports(Ci::CompareCodequalityReportsService) + end + def find_terraform_reports unless has_terraform_reports? return { status: :error, status_reason: 'This merge request does not have terraform reports' } @@ -1703,7 +1755,7 @@ class MergeRequest < ApplicationRecord end def allows_reviewers? - Feature.enabled?(:merge_request_reviewers, project) + Feature.enabled?(:merge_request_reviewers, project, default_enabled: true) end def allows_multiple_reviewers? diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 66bff3f5982..d3fe256fb1b 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -10,6 +10,11 @@ class MergeRequest::Metrics < ApplicationRecord scope :merged_after, ->(date) { where(arel_table[:merged_at].gteq(date)) } scope :merged_before, ->(date) { where(arel_table[:merged_at].lteq(date)) } + scope :with_valid_time_to_merge, -> { where(arel_table[:merged_at].gt(arel_table[:created_at])) } + + def self.time_to_merge_expression + Arel.sql('EXTRACT(epoch FROM SUM(AGE(merge_request_metrics.merged_at, merge_request_metrics.created_at)))') + end private diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 24809141570..d23e66b9697 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -358,6 +358,7 @@ class MergeRequestDiff < ApplicationRecord if comparison comparison.diffs_in_batch(batch_page, batch_size, diff_options: diff_options) else + reorder_diff_files! diffs_in_batch_collection(batch_page, batch_size, diff_options: diff_options) end end @@ -371,6 +372,7 @@ class MergeRequestDiff < ApplicationRecord if comparison comparison.diffs(diff_options) else + reorder_diff_files! diffs_collection(diff_options) end end @@ -565,7 +567,7 @@ class MergeRequestDiff < ApplicationRecord end def build_merge_request_diff_files(diffs) - diffs.map.with_index do |diff, index| + sort_diffs(diffs).map.with_index do |diff, index| diff_hash = diff.to_hash.merge( binary: false, merge_request_diff_id: self.id, @@ -678,6 +680,7 @@ class MergeRequestDiff < ApplicationRecord rows = build_merge_request_diff_files(diff_collection) create_merge_request_diff_files(rows) + new_attributes[:sorted] = true self.class.uncached { merge_request_diff_files.reset } end @@ -719,6 +722,35 @@ class MergeRequestDiff < ApplicationRecord repo.keep_around(start_commit_sha, head_commit_sha, base_commit_sha) end end + + def reorder_diff_files! + return unless sort_diffs? + return if sorted? || merge_request_diff_files.empty? + + diff_files = sort_diffs(merge_request_diff_files) + + diff_files.each_with_index do |diff_file, index| + diff_file.relative_order = index + end + + transaction do + # The `merge_request_diff_files` table doesn't have an `id` column so + # we cannot use `Gitlab::Database::BulkUpdate`. + MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all + MergeRequestDiffFile.bulk_insert!(diff_files) + update_column(:sorted, true) + end + end + + def sort_diffs(diffs) + return diffs unless sort_diffs? + + Gitlab::Diff::FileCollectionSorter.new(diffs).sort + end + + def sort_diffs? + Feature.enabled?(:sort_diffs, project, default_enabled: false) + end end MergeRequestDiff.prepend_if_ee('EE::MergeRequestDiff') diff --git a/app/models/merge_request_reviewer.rb b/app/models/merge_request_reviewer.rb index 1cb49c0cd76..c4e5274f832 100644 --- a/app/models/merge_request_reviewer.rb +++ b/app/models/merge_request_reviewer.rb @@ -2,5 +2,5 @@ class MergeRequestReviewer < ApplicationRecord belongs_to :merge_request - belongs_to :reviewer, class_name: "User", foreign_key: :user_id, inverse_of: :merge_request_assignees + belongs_to :reviewer, class_name: 'User', foreign_key: :user_id, inverse_of: :merge_request_reviewers end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index c8776be5e4a..c244150e7a3 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -9,6 +9,10 @@ class Milestone < ApplicationRecord prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule + class Predefined + ALL = [::Timebox::None, ::Timebox::Any, ::Timebox::Started, ::Timebox::Upcoming].freeze + end + has_many :milestone_releases has_many :releases, through: :milestone_releases diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 232d0a6b05d..238e8f70778 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -28,6 +28,7 @@ class Namespace < ApplicationRecord has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace' has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner' + has_many :namespace_onboarding_actions # This should _not_ be `inverse_of: :namespace`, because that would also set # `user.namespace` when this user creates a group with themselves as `owner`. diff --git a/app/models/namespace_onboarding_action.rb b/app/models/namespace_onboarding_action.rb new file mode 100644 index 00000000000..43dd872673c --- /dev/null +++ b/app/models/namespace_onboarding_action.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class NamespaceOnboardingAction < ApplicationRecord + belongs_to :namespace, optional: false + + validates :action, presence: true + + ACTIONS = { + subscription_created: 1, + git_write: 2, + merge_request_created: 3, + git_read: 4, + user_added: 6 + }.freeze + + enum action: ACTIONS + + class << self + def completed?(namespace, action) + where(namespace: namespace, action: action).exists? + end + + def create_action(namespace, action) + NamespaceOnboardingAction.safe_find_or_create_by(namespace: namespace, action: action) + end + end +end diff --git a/app/models/note.rb b/app/models/note.rb index cfdac6c432f..77f7726079c 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -145,7 +145,6 @@ class Note < ApplicationRecord after_save :expire_etag_cache, unless: :importing? after_save :touch_noteable, unless: :importing? after_destroy :expire_etag_cache - after_save :store_mentions!, if: :any_mentionable_attributes_changed? after_commit :notify_after_create, on: :create after_commit :notify_after_destroy, on: :destroy @@ -548,8 +547,8 @@ class Note < ApplicationRecord private - # Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception - # in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block. + # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception + # in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block. def model_user_mention return if user_mentions.is_a?(ActiveRecord::NullRelation) diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 6066046a722..82e39e4f207 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -30,6 +30,7 @@ class NotificationSetting < ApplicationRecord scope :preload_source_route, -> { preload(source: [:route]) } + # NOTE: Applicable unfound_translations.rb also needs to be updated when below events are changed. EMAIL_EVENTS = [ :new_release, :new_note, @@ -51,7 +52,6 @@ class NotificationSetting < ApplicationRecord :moved_project ].freeze - # Update unfound_translations.rb when events are changed def self.email_events(source = nil) EMAIL_EVENTS end diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb index 959c94931ec..13da82d16d3 100644 --- a/app/models/packages/event.rb +++ b/app/models/packages/event.rb @@ -25,7 +25,7 @@ class Packages::Event < ApplicationRecord enum originator_type: { user: 0, deploy_token: 1, guest: 2 } def self.allowed_event_name(event_scope, event_type, originator) - return unless event_allowed?(event_scope, event_type, originator) + return unless event_allowed?(event_type) # remove `package` from the event name to avoid issues with HLLRedisCounter class parsing "i_package_#{event_scope}_#{originator}_#{event_type.gsub(/_packages?/, "")}" @@ -33,8 +33,7 @@ class Packages::Event < ApplicationRecord # Remove some of the events, for now, so we don't hammer Redis too hard. # See: https://gitlab.com/gitlab-org/gitlab/-/issues/280770 - def self.event_allowed?(event_scope, event_type, originator) - return false if originator.to_sym == :guest + def self.event_allowed?(event_type) return true if UNIQUE_EVENTS_ALLOWED.include?(event_type.to_sym) false diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 60aab0a7222..10c98f03804 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -28,7 +28,7 @@ class Packages::Package < ApplicationRecord validates :project, presence: true validates :name, presence: true - validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: -> { conan? || generic? } + validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: -> { conan? || generic? || debian? } validates :name, uniqueness: { scope: %i[project_id version package_type] }, unless: :conan? @@ -40,6 +40,8 @@ class Packages::Package < ApplicationRecord validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic? validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget? + validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package? + validates :name, inclusion: { in: %w[incoming] }, if: :debian_incoming? validates :version, format: { with: Gitlab::Regex.nuget_version_regex }, if: :nuget? validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? } @@ -51,6 +53,11 @@ class Packages::Package < ApplicationRecord presence: true, format: { with: Gitlab::Regex.generic_package_version_regex }, if: :generic? + validates :version, + presence: true, + format: { with: Gitlab::Regex.debian_version_regex }, + if: :debian_package? + validate :forbidden_debian_changes, if: :debian? enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7, golang: 8, debian: 9 } @@ -184,6 +191,14 @@ class Packages::Package < ApplicationRecord tags.pluck(:name) end + def debian_incoming? + debian? && version.nil? + end + + def debian_package? + debian? && !version.nil? + end + private def composer_tag_version? @@ -228,4 +243,13 @@ class Packages::Package < ApplicationRecord errors.add(:base, _('Package already exists')) end end + + def forbidden_debian_changes + return unless persisted? + + # Debian incoming + if version_was.nil? || version.nil? + errors.add(:version, _('cannot be changed')) if version_changed? + end + end end diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index d68f75140ac..e8d1dd1e8c4 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Packages::PackageFile < ApplicationRecord include UpdateProjectStatistics + include FileStoreMounter delegate :project, :project_id, to: :package delegate :conan_file_type, to: :conan_file_metadatum @@ -35,20 +36,12 @@ class Packages::PackageFile < ApplicationRecord .where(packages_conan_file_metadata: { conan_package_reference: conan_package_reference }) end - mount_uploader :file, Packages::PackageFileUploader - - after_save :update_file_metadata, if: :saved_change_to_file? + mount_file_store_uploader Packages::PackageFileUploader update_project_statistics project_statistics_name: :packages_size before_save :update_size_from_file - def update_file_metadata - # The file.object_store is set during `uploader.store!` - # which happens after object is inserted/updated - self.update_column(:file_store, file.object_store) - end - def download_path Gitlab::Routing.url_helpers.download_project_package_file_path(project, self) end diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 9855731778f..84928468ad1 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -2,6 +2,8 @@ module Pages class LookupPath + include Gitlab::Utils::StrongMemoize + def initialize(project, trim_prefix: nil, domain: nil) @project = project @domain = domain @@ -37,37 +39,28 @@ module Pages attr_reader :project, :trim_prefix, :domain - def artifacts_archive - return unless Feature.enabled?(:pages_serve_from_artifacts_archive, project) - - project.pages_metadatum.artifacts_archive - end - def deployment - return unless Feature.enabled?(:pages_serve_from_deployments, project) + strong_memoize(:deployment) do + next unless Feature.enabled?(:pages_serve_from_deployments, project, default_enabled: true) - project.pages_metadatum.pages_deployment + project.pages_metadatum.pages_deployment + end end def zip_source - source = deployment || artifacts_archive - - return unless source&.file - - return if source.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project) + return unless deployment&.file - # artifacts archive doesn't support this - file_count = source.file_count if source.respond_to?(:file_count) + return if deployment.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project) - global_id = ::Gitlab::GlobalId.build(source, id: source.id).to_s + global_id = ::Gitlab::GlobalId.build(deployment, id: deployment.id).to_s { type: 'zip', - path: source.file.url_or_file_path(expire_at: 1.day.from_now), + path: deployment.file.url_or_file_path(expire_at: 1.day.from_now), global_id: global_id, - sha256: source.file_sha256, - file_size: source.size, - file_count: file_count + sha256: deployment.file_sha256, + file_size: deployment.size, + file_count: deployment.file_count } end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 8192310ddfb..4004ea9a662 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -34,10 +34,10 @@ class PagesDomain < ApplicationRecord validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? } validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? } - default_value_for(:auto_ssl_enabled, allow_nil: false) { ::Gitlab::LetsEncrypt.enabled? } - default_value_for :scope, allow_nil: false, value: :project - default_value_for :wildcard, allow_nil: false, value: false - default_value_for :usage, allow_nil: false, value: :pages + default_value_for(:auto_ssl_enabled, allows_nil: false) { ::Gitlab::LetsEncrypt.enabled? } + default_value_for :scope, allows_nil: false, value: :project + default_value_for :wildcard, allows_nil: false, value: false + default_value_for :usage, allows_nil: false, value: :pages attr_encrypted :key, mode: :per_attribute_iv_and_salt, diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 5aa5f2c842b..3b07551fe05 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -9,7 +9,9 @@ class PersonalAccessToken < ApplicationRecord add_authentication_token_field :token, digest: true REDIS_EXPIRY_TIME = 3.minutes - TOKEN_LENGTH = 20 + + # PATs are 20 characters + optional configurable settings prefix (0..20) + TOKEN_LENGTH_RANGE = (20..40).freeze serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize @@ -77,6 +79,15 @@ class PersonalAccessToken < ApplicationRecord ) end + def self.token_prefix + Gitlab::CurrentSettings.current_application_settings.personal_access_token_prefix + end + + override :format_token + def format_token(token) + "#{self.class.token_prefix}#{token}" + end + protected def validate_scopes diff --git a/app/models/project.rb b/app/models/project.rb index ebd8e56246d..daa5605c2e0 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -19,6 +19,7 @@ class Project < ApplicationRecord include Presentable include HasRepository include HasWiki + include CanMoveRepositoryStorage include Routable include GroupDescendant include Gitlab::SQL::Pattern @@ -64,6 +65,8 @@ class Project < ApplicationRecord SORTING_PREFERENCE_FIELD = :projects_sort MAX_BUILD_TIMEOUT = 1.month + GL_REPOSITORY_TYPES = [Gitlab::GlRepository::PROJECT, Gitlab::GlRepository::WIKI, Gitlab::GlRepository::DESIGN].freeze + cache_markdown_field :description, pipeline: :description default_value_for :packages_enabled, true @@ -145,6 +148,7 @@ class Project < ApplicationRecord # Project services has_one :alerts_service has_one :campfire_service + has_one :datadog_service has_one :discord_service has_one :drone_ci_service has_one :emails_on_push_service @@ -164,6 +168,7 @@ class Project < ApplicationRecord has_one :bamboo_service has_one :teamcity_service has_one :pushover_service + has_one :jenkins_service has_one :jira_service has_one :redmine_service has_one :youtrack_service @@ -222,6 +227,7 @@ class Project < ApplicationRecord has_many :snippets, class_name: 'ProjectSnippet' has_many :hooks, class_name: 'ProjectHook' has_many :protected_branches + has_many :exported_protected_branches has_many :protected_tags has_many :repository_languages, -> { order "share DESC" } has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design' @@ -336,7 +342,7 @@ class Project < ApplicationRecord has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult' - has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove' + has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove', inverse_of: :container has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project has_many :reviews, inverse_of: :project @@ -379,11 +385,11 @@ class Project < ApplicationRecord delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, :forking_enabled?, :issues_enabled?, - :pages_enabled?, :snippets_enabled?, :public_pages?, :private_pages?, + :pages_enabled?, :analytics_enabled?, :snippets_enabled?, :public_pages?, :private_pages?, :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, - :repository_access_level, :pages_access_level, :metrics_dashboard_access_level, - to: :project_feature, allow_nil: true + :repository_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level, + :operations_enabled?, :operations_access_level, to: :project_feature, allow_nil: true delegate :show_default_award_emojis, :show_default_award_emojis=, :show_default_award_emojis?, to: :project_setting, allow_nil: true @@ -404,7 +410,7 @@ class Project < ApplicationRecord delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings, prefix: :ci delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, - :allow_merge_on_skipped_pipeline=, :has_confluence?, + :allow_merge_on_skipped_pipeline=, :has_confluence?, :allow_editing_commit_messages?, to: :project_setting delegate :active?, to: :prometheus_service, allow_nil: true, prefix: true @@ -1349,6 +1355,8 @@ class Project < ApplicationRecord end def disabled_services + return ['datadog'] unless Feature.enabled?(:datadog_ci_integration, self) + [] end @@ -1836,6 +1844,7 @@ class Project < ApplicationRecord wiki.repository.expire_content_cache DetectRepositoryLanguagesWorker.perform_async(id) + ProjectCacheWorker.perform_async(self.id, [], [:repository_size]) # The import assigns iid values on its own, e.g. by re-using GitHub ids. # Flush existing InternalId records for this project for consistency reasons. @@ -1952,6 +1961,7 @@ class Project < ApplicationRecord .concat(predefined_project_variables) .concat(pages_variables) .concat(container_registry_variables) + .concat(dependency_proxy_variables) .concat(auto_devops_variables) .concat(api_variables) end @@ -2003,6 +2013,18 @@ class Project < ApplicationRecord end end + def dependency_proxy_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless Gitlab.config.dependency_proxy.enabled + + variables.append(key: 'CI_DEPENDENCY_PROXY_SERVER', value: "#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}") + variables.append( + key: 'CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX', + value: "#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/#{namespace.root_ancestor.path}#{DependencyProxy::URL_SUFFIX}" + ) + end + end + def container_registry_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables unless Gitlab.config.registry.enabled @@ -2091,39 +2113,6 @@ class Project < ApplicationRecord (auto_devops || build_auto_devops)&.predefined_variables end - RepositoryReadOnlyError = Class.new(StandardError) - - # Tries to set repository as read_only, checking for existing Git transfers in - # progress beforehand. Setting a repository read-only will fail if it is - # already in that state. - # - # @return nil. Failures will raise an exception - def set_repository_read_only! - with_lock do - raise RepositoryReadOnlyError, _('Git transfer in progress') if - git_transfer_in_progress? - - raise RepositoryReadOnlyError, _('Repository already read-only') if - self.class.where(id: id).pick(:repository_read_only) - - raise ActiveRecord::RecordNotSaved, _('Database update failed') unless - update_column(:repository_read_only, true) - - nil - end - end - - # Set repository as writable again. Unlike setting it read-only, this will - # succeed if the repository is already writable. - def set_repository_writable! - with_lock do - raise ActiveRecord::RecordNotSaved, _('Database update failed') unless - update_column(:repository_read_only, false) - - nil - end - end - def pushes_since_gc Gitlab::Redis::SharedState.with { |redis| redis.get(pushes_since_gc_redis_shared_state_key).to_i } end @@ -2273,8 +2262,11 @@ class Project < ApplicationRecord end end + override :git_transfer_in_progress? def git_transfer_in_progress? - repo_reference_count > 0 || wiki_reference_count > 0 + GL_REPOSITORY_TYPES.any? do |type| + reference_counter(type: type).value > 0 + end end def storage_version=(value) @@ -2283,10 +2275,6 @@ class Project < ApplicationRecord @storage = nil if storage_version_changed? end - def reference_counter(type: Gitlab::GlRepository::PROJECT) - Gitlab::ReferenceCounter.new(type.identifier_for_container(self)) - end - def badges return project_badges unless group @@ -2498,8 +2486,7 @@ class Project < ApplicationRecord end def service_desk_custom_address - return unless ::Gitlab::ServiceDeskEmail.enabled? - return unless ::Feature.enabled?(:service_desk_custom_address, self) + return unless service_desk_custom_address_enabled? key = service_desk_setting&.project_key return unless key.present? @@ -2507,6 +2494,10 @@ class Project < ApplicationRecord ::Gitlab::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}") end + def service_desk_custom_address_enabled? + ::Gitlab::ServiceDeskEmail.enabled? && ::Feature.enabled?(:service_desk_custom_address, self, default_enabled: true) + end + def root_namespace if namespace.has_parent? namespace.root_ancestor @@ -2607,14 +2598,6 @@ class Project < ApplicationRecord end end - def repo_reference_count - reference_counter.value - end - - def wiki_reference_count - reference_counter(type: Gitlab::GlRepository::WIKI).value - end - def check_repository_absence! return if skip_disk_validation diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index b3ebcbd4b17..7b204cfb1c0 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -3,7 +3,7 @@ class ProjectFeature < ApplicationRecord include Featurable - FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze + FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard analytics operations).freeze set_available_features(FEATURES) @@ -44,7 +44,9 @@ class ProjectFeature < ApplicationRecord default_value_for :snippets_access_level, value: ENABLED, allows_nil: false default_value_for :wiki_access_level, value: ENABLED, allows_nil: false default_value_for :repository_access_level, value: ENABLED, allows_nil: false + default_value_for :analytics_access_level, value: ENABLED, allows_nil: false default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false + default_value_for :operations_access_level, value: ENABLED, allows_nil: false default_value_for(:pages_access_level, allows_nil: false) do |feature| if ::Gitlab::Pages.access_control_is_forced? diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb index 092efabd73f..a9cef16f3ac 100644 --- a/app/models/project_repository.rb +++ b/app/models/project_repository.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class ProjectRepository < ApplicationRecord + include EachBatch include Shardable belongs_to :project, inverse_of: :project_repository diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb index 3429dbe3a85..1e3782a1fb5 100644 --- a/app/models/project_repository_storage_move.rb +++ b/app/models/project_repository_storage_move.rb @@ -4,100 +4,31 @@ # project. For example, moving a project to another gitaly node to help # balance storage capacity. class ProjectRepositoryStorageMove < ApplicationRecord - include AfterCommitQueue + extend ::Gitlab::Utils::Override + include RepositoryStorageMovable - belongs_to :project, inverse_of: :repository_storage_moves + belongs_to :container, class_name: 'Project', inverse_of: :repository_storage_moves, foreign_key: :project_id + alias_attribute :project, :container + scope :with_projects, -> { includes(container: :route) } - validates :project, presence: true - validates :state, presence: true - validates :source_storage_name, - on: :create, - presence: true, - inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } - validates :destination_storage_name, - on: :create, - presence: true, - inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } - validate :project_repository_writable, on: :create - - default_value_for(:destination_storage_name, allows_nil: false) do - pick_repository_storage - end - - state_machine initial: :initial do - event :schedule do - transition initial: :scheduled - end - - event :start do - transition scheduled: :started - end - - event :finish_replication do - transition started: :replicated - end - - event :finish_cleanup do - transition replicated: :finished - end - - event :do_fail do - transition [:initial, :scheduled, :started] => :failed - transition replicated: :cleanup_failed - end - - around_transition initial: :scheduled do |storage_move, block| - block.call - - begin - storage_move.project.set_repository_read_only! - rescue => err - errors.add(:project, err.message) - next false - end - - storage_move.run_after_commit do - ProjectUpdateRepositoryStorageWorker.perform_async( - storage_move.project_id, - storage_move.destination_storage_name, - storage_move.id - ) - end - - true - end - - before_transition started: :replicated do |storage_move| - storage_move.project.set_repository_writable! - - storage_move.project.update_column(:repository_storage, storage_move.destination_storage_name) - end - - before_transition started: :failed do |storage_move| - storage_move.project.set_repository_writable! - end - - state :initial, value: 1 - state :scheduled, value: 2 - state :started, value: 3 - state :finished, value: 4 - state :failed, value: 5 - state :replicated, value: 6 - state :cleanup_failed, value: 7 + override :update_repository_storage + def update_repository_storage(new_storage) + container.update_column(:repository_storage, new_storage) end - scope :order_created_at_desc, -> { order(created_at: :desc) } - scope :with_projects, -> { includes(project: :route) } - - class << self - def pick_repository_storage - Project.pick_repository_storage - end + override :schedule_repository_storage_update_worker + def schedule_repository_storage_update_worker + ProjectUpdateRepositoryStorageWorker.perform_async( + project_id, + destination_storage_name, + id + ) end private - def project_repository_writable - errors.add(:project, _('is read only')) if project&.repository_read_only? + override :error_key + def error_key + :project end end diff --git a/app/models/project_services/datadog_service.rb b/app/models/project_services/datadog_service.rb new file mode 100644 index 00000000000..543843ab1b0 --- /dev/null +++ b/app/models/project_services/datadog_service.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +class DatadogService < Service + DEFAULT_SITE = 'datadoghq.com'.freeze + URL_TEMPLATE = 'https://webhooks-http-intake.logs.%{datadog_site}/v1/input/'.freeze + URL_TEMPLATE_API_KEYS = 'https://app.%{datadog_site}/account/settings#api'.freeze + URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_SITE}/account_management/api-app-keys/".freeze + + SUPPORTED_EVENTS = %w[ + pipeline job + ].freeze + + prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env + + with_options presence: true, if: :activated? do + validates :api_key, format: { with: /\A\w+\z/ } + validates :datadog_site, format: { with: /\A[\w\.]+\z/ }, unless: :api_url + validates :api_url, public_url: true, unless: :datadog_site + end + + after_save :compose_service_hook, if: :activated? + + def self.supported_events + SUPPORTED_EVENTS + end + + def self.default_test_event + 'pipeline' + end + + def configurable_events + [] # do not allow to opt out of required hooks + end + + def title + 'Datadog' + end + + def description + 'Trace your GitLab pipelines with Datadog' + end + + def help + nil + # Maybe adding something in the future + # We could link to static help pages as well + # [More information](#{Gitlab::Routing.url_helpers.help_page_url('integration/datadog')})" + end + + def self.to_param + 'datadog' + end + + def fields + [ + { + type: 'text', name: 'datadog_site', + placeholder: DEFAULT_SITE, default: DEFAULT_SITE, + help: 'Choose the Datadog site to send data to. Set to "datadoghq.eu" to send data to the EU site', + required: false + }, + { + type: 'text', name: 'api_url', title: 'Custom URL', + help: '(Advanced) Define the full URL for your Datadog site directly', + required: false + }, + { + type: 'password', name: 'api_key', title: 'API key', + help: "<a href=\"#{api_keys_url}\" target=\"_blank\">API key</a> used for authentication with Datadog", + required: true + }, + { + type: 'text', name: 'datadog_service', title: 'Service', placeholder: 'gitlab-ci', + help: 'Name of this GitLab instance that all data will be tagged with' + }, + { + type: 'text', name: 'datadog_env', title: 'Env', + help: 'The environment tag that traces will be tagged with' + } + ] + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def hook_url + url = api_url.presence || sprintf(URL_TEMPLATE, datadog_site: datadog_site) + url = URI.parse(url) + url.path = File.join(url.path || '/', api_key) + query = { service: datadog_service, env: datadog_env }.compact + url.query = query.to_query unless query.empty? + url.to_s + end + + def api_keys_url + return URL_API_KEYS_DOCS unless datadog_site.presence + + sprintf(URL_TEMPLATE_API_KEYS, datadog_site: datadog_site) + end + + def execute(data) + return if project.disabled_services.include?(to_param) + + object_kind = data[:object_kind] + object_kind = 'job' if object_kind == 'build' + return unless supported_events.include?(object_kind) + + service_hook.execute(data, "#{object_kind} hook") + end + + def test(data) + begin + result = execute(data) + return { success: false, result: result[:message] } if result[:http_status] != 200 + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result[:message] } + end +end diff --git a/app/models/project_services/jenkins_service.rb b/app/models/project_services/jenkins_service.rb new file mode 100644 index 00000000000..63ecfc66877 --- /dev/null +++ b/app/models/project_services/jenkins_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class JenkinsService < CiService + prop_accessor :jenkins_url, :project_name, :username, :password + + before_update :reset_password + + validates :jenkins_url, presence: true, addressable_url: true, if: :activated? + validates :project_name, presence: true, if: :activated? + validates :username, presence: true, if: ->(service) { service.activated? && service.password_touched? && service.password.present? } + + default_value_for :push_events, true + default_value_for :merge_requests_events, false + default_value_for :tag_push_events, false + + after_save :compose_service_hook, if: :activated? + + def reset_password + # don't reset the password if a new one is provided + if (jenkins_url_changed? || username.blank?) && !password_touched? + self.password = nil + end + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def execute(data) + return if project.disabled_services.include?(to_param) + return unless supported_events.include?(data[:object_kind]) + + service_hook.execute(data, "#{data[:object_kind]}_hook") + end + + def test(data) + begin + result = execute(data) + return { success: false, result: result[:message] } if result[:http_status] != 200 + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result[:message] } + end + + def hook_url + url = URI.parse(jenkins_url) + url.path = File.join(url.path || '/', "project/#{project_name}") + url.user = ERB::Util.url_encode(username) unless username.blank? + url.password = ERB::Util.url_encode(password) unless password.blank? + url.to_s + end + + def self.supported_events + %w(push merge_request tag_push) + end + + def title + 'Jenkins CI' + end + + def description + 'An extendable open source continuous integration server' + end + + def help + "You must have installed the Git Plugin and GitLab Plugin in Jenkins. [More information](#{Gitlab::Routing.url_helpers.help_page_url('integration/jenkins')})" + end + + def self.to_param + 'jenkins' + end + + def fields + [ + { + type: 'text', name: 'jenkins_url', + placeholder: 'Jenkins URL like http://jenkins.example.com' + }, + { + type: 'text', name: 'project_name', placeholder: 'Project Name', + help: 'The URL-friendly project name. Example: my_project_name' + }, + { type: 'text', name: 'username' }, + { type: 'password', name: 'password' } + ] + end +end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 7814bdb7106..1f4abfc1aca 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -122,12 +122,15 @@ class JiraService < IssueTrackerService end def fields + transition_id_help_path = help_page_path('user/project/integrations/jira', anchor: 'obtaining-a-transition-id') + transition_id_help_link_start = '<a href="%{transition_id_help_path}" target="_blank" rel="noopener noreferrer">'.html_safe % { transition_id_help_path: transition_id_help_path } + [ { type: 'text', name: 'url', title: s_('JiraService|Web URL'), placeholder: 'https://jira.example.com', required: true }, { type: 'text', name: 'api_url', title: s_('JiraService|Jira API URL'), placeholder: s_('JiraService|If different from Web URL') }, { type: 'text', name: 'username', title: s_('JiraService|Username or Email'), placeholder: s_('JiraService|Use a username for server version and an email for cloud version'), required: true }, { type: 'password', name: 'password', title: s_('JiraService|Password or API token'), placeholder: s_('JiraService|Use a password for server version and an API token for cloud version'), required: true }, - { type: 'text', name: 'jira_issue_transition_id', title: s_('JiraService|Transition ID(s)'), placeholder: s_('JiraService|Use , or ; to separate multiple transition IDs') } + { type: 'text', name: 'jira_issue_transition_id', title: s_('JiraService|Jira workflow transition IDs'), placeholder: s_('JiraService|For example, 12, 24'), help: s_('JiraService|Set transition IDs for Jira workflow transitions. %{link_start}Learn more%{link_end}'.html_safe % { link_start: transition_id_help_link_start, link_end: '</a>'.html_safe }) } ] end diff --git a/app/models/project_services/mock_deployment_service.rb b/app/models/project_services/mock_deployment_service.rb index f80819de9fb..e55335d9aae 100644 --- a/app/models/project_services/mock_deployment_service.rb +++ b/app/models/project_services/mock_deployment_service.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +# Deprecated, to be deleted in 13.8 (https://gitlab.com/gitlab-org/gitlab/-/issues/293914) +# +# This was a class used only in development environment but became unusable +# since DeploymentService was deleted class MockDeploymentService < Service default_value_for :category, 'deployment' @@ -32,5 +36,3 @@ class MockDeploymentService < Service false end end - -MockDeploymentService.prepend_if_ee('EE::MockDeploymentService') diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index c11b2f7cc65..8af4cd952c9 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -40,6 +40,10 @@ class PipelinesEmailService < Service %w[pipeline] end + def self.default_test_event + 'pipeline' + end + def execute(data, force: false) return unless supported_events.include?(data[:object_kind]) return unless force || should_pipeline_be_notified?(data) diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index c11a7fea1c6..7605ef54d5b 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -73,8 +73,6 @@ class ProjectStatistics < ApplicationRecord end def update_uploads_size - return uploads_size unless Feature.enabled?(:count_uploads_size_in_storage_stats, project) - self.uploads_size = project.uploads.sum(:size) end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 599c174ddd7..ad418a47476 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -48,6 +48,10 @@ class ProtectedBranch < ApplicationRecord where(fuzzy_arel_match(:name, query.downcase)) end + + def allow_multiple?(type) + type == :push + end end ProtectedBranch.prepend_if_ee('EE::ProtectedBranch') diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index 63d577a4866..f28440f2444 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -18,6 +18,14 @@ class ProtectedBranch::PushAccessLevel < ApplicationRecord end end + def check_access(user) + if Feature.enabled?(:deploy_keys_on_protected_branches, project) && user && deploy_key.present? + return true if user.can?(:read_project, project) && enabled_deploy_key_for_user?(deploy_key, user) + end + + super + end + private def validate_deploy_key_membership @@ -27,4 +35,8 @@ class ProtectedBranch::PushAccessLevel < ApplicationRecord self.errors.add(:deploy_key, 'is not enabled for this project') end end + + def enabled_deploy_key_for_user?(deploy_key, user) + deploy_key.user_id == user.id && DeployKey.with_write_access_for_project(protected_branch.project, deploy_key: deploy_key).any? + end end diff --git a/app/models/raw_usage_data.rb b/app/models/raw_usage_data.rb index 18cee55d06e..06cd4ad3f6c 100644 --- a/app/models/raw_usage_data.rb +++ b/app/models/raw_usage_data.rb @@ -5,6 +5,6 @@ class RawUsageData < ApplicationRecord validates :recorded_at, presence: true, uniqueness: true def update_sent_at! - self.update_column(:sent_at, Time.current) if Feature.enabled?(:save_raw_usage_data) + self.update_column(:sent_at, Time.current) end end diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb index 22f60802257..749f4a87818 100644 --- a/app/models/redirect_route.rb +++ b/app/models/redirect_route.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class RedirectRoute < ApplicationRecord + include CaseSensitivity + belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations validates :source, presence: true diff --git a/app/models/release.rb b/app/models/release.rb index c56df0a6aa3..bebf91fb247 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -29,6 +29,8 @@ class Release < ApplicationRecord scope :preloaded, -> { includes(:evidences, :milestones, project: [:project_feature, :route, { namespace: :route }]) } scope :with_project_and_namespace, -> { includes(project: :namespace) } scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } + scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) } + scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) } # Sorting scope :order_created, -> { reorder('created_at ASC') } diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb new file mode 100644 index 00000000000..1efba6380e9 --- /dev/null +++ b/app/models/release_highlight.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +class ReleaseHighlight + CACHE_DURATION = 1.hour + FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml') + RELEASE_VERSIONS_IN_A_YEAR = 12 + + def self.for_version(version:) + index = self.versions.index(version) + + return if index.nil? + + page = index + 1 + + self.paginated(page: page) + end + + def self.paginated(page: 1) + key = self.cache_key("items:page-#{page}") + + Rails.cache.fetch(key, expires_in: CACHE_DURATION) do + items = self.load_items(page: page) + + next if items.nil? + + QueryResult.new(items: items, next_page: next_page(current_page: page)) + end + end + + def self.load_items(page:) + index = page - 1 + file_path = file_paths[index] + + return if file_path.nil? + + file = File.read(file_path) + items = YAML.safe_load(file, permitted_classes: [Date]) + + platform = Gitlab.com? ? 'gitlab-com' : 'self-managed' + + items&.map! do |item| + next unless item[platform] + + begin + item.tap {|i| i['body'] = Kramdown::Document.new(i['body']).to_html } + rescue => e + Gitlab::ErrorTracking.track_exception(e, file_path: file_path) + + next + end + end + + items&.compact + rescue Psych::Exception => e + Gitlab::ErrorTracking.track_exception(e, file_path: file_path) + + nil + end + + def self.file_paths + @file_paths ||= Rails.cache.fetch(self.cache_key('file_paths'), expires_in: CACHE_DURATION) do + Dir.glob(FILES_PATH).sort.reverse + end + end + + def self.cache_key(key) + ['release_highlight', key, Gitlab.revision].join(':') + end + + def self.next_page(current_page: 1) + next_page = current_page + 1 + next_index = next_page - 1 + + next_page if self.file_paths[next_index] + end + + def self.most_recent_item_count + key = self.cache_key('recent_item_count') + + Gitlab::ProcessMemoryCache.cache_backend.fetch(key, expires_in: CACHE_DURATION) do + self.paginated&.items&.count + end + end + + def self.versions + key = self.cache_key('versions') + + Gitlab::ProcessMemoryCache.cache_backend.fetch(key, expires_in: CACHE_DURATION) do + versions = self.file_paths.first(RELEASE_VERSIONS_IN_A_YEAR).map do |path| + /\d*\_(\d*\_\d*)\.yml$/.match(path).captures[0].gsub(/0(?=\d)/, "").tr("_", ".") + end + + versions.uniq + end + end + + QueryResult = Struct.new(:items, :next_page, keyword_init: true) do + include Enumerable + + delegate :each, to: :items + end +end diff --git a/app/models/repository.rb b/app/models/repository.rb index d4fd202b966..93f22dbe122 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -513,6 +513,9 @@ class Repository # Don't attempt to return a special result if there is no blob at all return unless blob + # Don't attempt to return a special result if this can't be a README + return blob unless Gitlab::FileDetector.type_of(blob.name) == :readme + # Don't attempt to return a special result unless we're looking at HEAD return blob unless head_commit&.sha == sha @@ -615,7 +618,7 @@ class Repository end def readme_path - readme&.path + head_tree&.readme_path end cache_method :readme_path diff --git a/app/models/resource_event.rb b/app/models/resource_event.rb index 26dcda2630a..54fa4137f73 100644 --- a/app/models/resource_event.rb +++ b/app/models/resource_event.rb @@ -30,14 +30,6 @@ class ResourceEvent < ApplicationRecord return true if issuable_count == 1 - # if none of issuable IDs is set, check explicitly if nested issuable - # object is set, this is used during project import - if issuable_count == 0 && importing? - issuable_count = self.class.issuable_attrs.count { |attr| self.public_send(attr) } # rubocop:disable GitlabSecurity/PublicSend - - return true if issuable_count == 1 - end - errors.add( :base, _("Exactly one of %{attributes} is required") % { attributes: self.class.issuable_attrs.join(', ') } diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index 18e2944a9ca..57a3b568c53 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -12,10 +12,9 @@ class ResourceLabelEvent < ResourceEvent scope :inc_relations, -> { includes(:label, :user) } validates :label, presence: { unless: :importing? }, on: :create - validate :exactly_one_issuable + validate :exactly_one_issuable, unless: :importing? after_save :expire_etag_cache - after_save :usage_metrics after_destroy :expire_etag_cache enum action: { @@ -114,16 +113,6 @@ class ResourceLabelEvent < ResourceEvent def discussion_id_key [self.class.name, created_at, user_id] end - - def for_issue? - issue_id.present? - end - - def usage_metrics - return unless for_issue? - - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user) - end end ResourceLabelEvent.prepend_if_ee('EE::ResourceLabelEvent') diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb index 6475633868a..73eb4987143 100644 --- a/app/models/resource_state_event.rb +++ b/app/models/resource_state_event.rb @@ -11,7 +11,7 @@ class ResourceStateEvent < ResourceEvent # state is used for issue and merge request states. enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5) - after_save :usage_metrics + after_create :issue_usage_metrics def self.issuable_attrs %i(issue merge_request).freeze @@ -27,7 +27,7 @@ class ResourceStateEvent < ResourceEvent private - def usage_metrics + def issue_usage_metrics return unless for_issue? case state diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb index ac164783945..71077758b69 100644 --- a/app/models/resource_timebox_event.rb +++ b/app/models/resource_timebox_event.rb @@ -13,7 +13,7 @@ class ResourceTimeboxEvent < ResourceEvent remove: 2 } - after_save :usage_metrics + after_create :issue_usage_metrics def self.issuable_attrs %i(issue merge_request).freeze @@ -25,7 +25,13 @@ class ResourceTimeboxEvent < ResourceEvent private - def usage_metrics + def for_issue? + issue_id.present? + end + + def issue_usage_metrics + return unless for_issue? + case self when ResourceMilestoneEvent Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user) diff --git a/app/models/route.rb b/app/models/route.rb index fe4846b3be5..fcc8459d6e5 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -4,7 +4,7 @@ class Route < ApplicationRecord include CaseSensitivity include Gitlab::SQL::Pattern - belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations + belongs_to :source, polymorphic: true, inverse_of: :route # rubocop:disable Cop/PolymorphicAssociations validates :source, presence: true validates :path, diff --git a/app/models/sentry_issue.rb b/app/models/sentry_issue.rb index 30f4026e633..fec1a55f17d 100644 --- a/app/models/sentry_issue.rb +++ b/app/models/sentry_issue.rb @@ -1,9 +1,12 @@ # frozen_string_literal: true class SentryIssue < ApplicationRecord + include Importable + belongs_to :issue - validates :issue, uniqueness: true, presence: true + validates :issue, uniqueness: true + validates :issue, presence: true, unless: :importing? validates :sentry_issue_identifier, presence: true validate :ensure_sentry_issue_identifier_is_unique_per_project diff --git a/app/models/service.rb b/app/models/service.rb index 2b6971954e3..57c099d6f04 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -11,15 +11,20 @@ class Service < ApplicationRecord include EachBatch SERVICE_NAMES = %w[ - alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord + asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat hipchat irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack ].freeze + PROJECT_SPECIFIC_SERVICE_NAMES = %w[ + jenkins + alerts + ].freeze + # Fake services to help with local development. DEV_SERVICE_NAMES = %w[ - mock_ci mock_deployment mock_monitoring + mock_ci mock_monitoring ].freeze serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize @@ -66,6 +71,7 @@ class Service < ApplicationRecord scope :by_type, -> (type) { where(type: type) } scope :by_active_flag, -> (flag) { where(active: flag) } scope :inherit_from_id, -> (id) { where(inherit_from_id: id) } + scope :inherit, -> { where.not(inherit_from_id: nil) } scope :for_group, -> (group) { where(group_id: group, type: available_services_types(include_project_specific: false)) } scope :for_template, -> { where(template: true, type: available_services_types(include_project_specific: false)) } scope :for_instance, -> { where(instance: true, type: available_services_types(include_project_specific: false)) } @@ -147,6 +153,10 @@ class Service < ApplicationRecord %w[commit push tag_push issue confidential_issue merge_request wiki_page] end + def self.default_test_event + 'push' + end + def self.event_description(event) ServicesHelper.service_event_description(event) end @@ -212,7 +222,7 @@ class Service < ApplicationRecord end def self.project_specific_services_names - [] + PROJECT_SPECIFIC_SERVICE_NAMES end def self.available_services_types(include_project_specific: true, include_dev: true) @@ -270,7 +280,7 @@ class Service < ApplicationRecord active.where(instance: true), active.where(group_id: group_ids, inherit_from_id: nil) ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], services.group_id), instance DESC")).group_by(&:type).each do |type, records| - build_from_integration(records.first, association => scope.id).save! + build_from_integration(records.first, association => scope.id).save end end @@ -386,6 +396,10 @@ class Service < ApplicationRecord self.class.supported_events end + def default_test_event + self.class.default_test_event + end + def execute(data) # implement inside child end @@ -402,6 +416,10 @@ class Service < ApplicationRecord !instance? && !group_id end + def parent + project || group + end + # Returns a hash of the properties that have been assigned a new value since last save, # indicating their original values (attr => original value). # ActiveRecord does not provide a mechanism to track changes in serialized keys, diff --git a/app/models/snippet.rb b/app/models/snippet.rb index dc370b46bda..817f9d014eb 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -15,6 +15,7 @@ class Snippet < ApplicationRecord include FromUnion include IgnorableColumns include HasRepository + include CanMoveRepositoryStorage include AfterCommitQueue extend ::Gitlab::Utils::Override @@ -43,6 +44,7 @@ class Snippet < ApplicationRecord has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_one :snippet_repository, inverse_of: :snippet + has_many :repository_storage_moves, class_name: 'SnippetRepositoryStorageMove', inverse_of: :container # We need to add the `dependent` in order to call the after_destroy callback has_one :statistics, class_name: 'SnippetStatistics', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -69,7 +71,6 @@ class Snippet < ApplicationRecord validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values } - after_save :store_mentions!, if: :any_mentionable_attributes_changed? after_create :create_statistics # Scopes @@ -213,7 +214,8 @@ class Snippet < ApplicationRecord def blobs return [] unless repository_exists? - repository.ls_files(default_branch).map { |file| Blob.lazy(repository, default_branch, file) } + branch = default_branch + list_files(branch).map { |file| Blob.lazy(repository, branch, file) } end def hook_attrs diff --git a/app/models/snippet_blob.rb b/app/models/snippet_blob.rb index cf1ab089829..bad24cc45f6 100644 --- a/app/models/snippet_blob.rb +++ b/app/models/snippet_blob.rb @@ -21,6 +21,10 @@ class SnippetBlob data.bytesize end + def commit_id + nil + end + def data snippet.content end diff --git a/app/models/snippet_repository_storage_move.rb b/app/models/snippet_repository_storage_move.rb new file mode 100644 index 00000000000..a365569bfa8 --- /dev/null +++ b/app/models/snippet_repository_storage_move.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# SnippetRepositoryStorageMove are details of repository storage moves for a +# snippet. For example, moving a snippet to another gitaly node to help +# balance storage capacity. +class SnippetRepositoryStorageMove < ApplicationRecord + extend ::Gitlab::Utils::Override + include RepositoryStorageMovable + + belongs_to :container, class_name: 'Snippet', inverse_of: :repository_storage_moves, foreign_key: :snippet_id + alias_attribute :snippet, :container + + override :schedule_repository_storage_update_worker + def schedule_repository_storage_update_worker + # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/218991 + end + + private + + override :error_key + def error_key + :snippet + end +end diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index 8c72bd5ae7e..ff564d87449 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true class Suggestion < ApplicationRecord + include Importable include Suggestible belongs_to :note, inverse_of: :suggestions - validates :note, presence: true + validates :note, presence: true, unless: :importing? validates :commit_id, presence: true, if: :applied? delegate :position, :noteable, to: :note diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 0ddf2c5fbcd..20107147b4f 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SystemNoteMetadata < ApplicationRecord + include Importable + # These notes's action text might contain a reference that is external. # We should always force a deep validation upon references that are found # in this note type. @@ -12,18 +14,19 @@ class SystemNoteMetadata < ApplicationRecord moved merge label milestone relate unrelate + cloned ].freeze ICON_TYPES = %w[ commit description merge confidential visible label assignee cross_reference designs_added designs_modified designs_removed designs_discussion_added - title time_tracking branch milestone discussion task moved + title time_tracking branch milestone discussion task moved cloned opened closed merged duplicate locked unlocked outdated reviewer tag due_date pinned_embed cherry_pick health_status approved unapproved status alert_issue_added relate unrelate new_alert_added severity ].freeze - validates :note, presence: true + validates :note, presence: true, unless: :importing? validates :action, inclusion: { in: :icon_types }, allow_nil: true belongs_to :note diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index d329b429c9d..1b99f310e1a 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -3,13 +3,6 @@ module Terraform class State < ApplicationRecord include UsageStatistics - include FileStoreMounter - include IgnorableColumns - # These columns are being removed since geo replication falls to the versioned state - # Tracking in https://gitlab.com/gitlab-org/gitlab/-/issues/258262 - ignore_columns %i[verification_failure verification_retry_at verified_at verification_retry_count verification_checksum], - remove_with: '13.7', - remove_after: '2020-12-22' HEX_REGEXP = %r{\A\h+\z}.freeze UUID_LENGTH = 32 @@ -35,20 +28,9 @@ module Terraform format: { with: HEX_REGEXP, message: 'only allows hex characters' } default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) } - default_value_for(:versioning_enabled, true) - - mount_file_store_uploader StateUploader - - def file_store - super || StateUploader.default_store - end def latest_file - if versioning_enabled? - latest_version&.file - else - latest_version&.file || file - end + latest_version&.file end def locked? @@ -56,13 +38,14 @@ module Terraform end def update_file!(data, version:, build:) + # This check is required to maintain backwards compatibility with + # states that were created prior to versioning being supported. + # This can be removed in 14.0 when support for these states is dropped. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/258960 if versioning_enabled? create_new_version!(data: data, version: version, build: build) - elsif latest_version.present? - migrate_legacy_version!(data: data, version: version, build: build) else - self.file = data - save! + migrate_legacy_version!(data: data, version: version, build: build) end end diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb index cc5d94b8e09..19d708616fc 100644 --- a/app/models/terraform/state_version.rb +++ b/app/models/terraform/state_version.rb @@ -10,9 +10,9 @@ module Terraform scope :ordered_by_version_desc, -> { order(version: :desc) } - default_value_for(:file_store) { VersionedStateUploader.default_store } + default_value_for(:file_store) { StateUploader.default_store } - mount_file_store_uploader VersionedStateUploader + mount_file_store_uploader StateUploader delegate :project_id, :uuid, to: :terraform_state, allow_nil: true diff --git a/app/models/timelog.rb b/app/models/timelog.rb index 60aaaaef831..f4debedb656 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true class Timelog < ApplicationRecord + include Importable + validates :time_spent, :user, presence: true - validate :issuable_id_is_present + validate :issuable_id_is_present, unless: :importing? belongs_to :issue, touch: true belongs_to :merge_request, touch: true diff --git a/app/models/todo.rb b/app/models/todo.rb index 0d893b25253..12dc9ce0fe6 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -139,13 +139,11 @@ class Todo < ApplicationRecord # Todos with highest priority first then oldest todos # Need to order by created_at last because of differences on Mysql and Postgres when joining by type "Merge_request/Issue" def order_by_labels_priority - params = { + highest_priority = highest_label_priority( target_type_column: "todos.target_type", target_column: "todos.target_id", project_column: "todos.project_id" - } - - highest_priority = highest_label_priority(params).to_sql + ).to_sql select("#{table_name}.*, (#{highest_priority}) AS highest_priority") .order(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) diff --git a/app/models/user.rb b/app/models/user.rb index be64e057d59..c735f20b92c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,11 +25,14 @@ class User < ApplicationRecord include IgnorableColumns include UpdateHighestRole include HasUserType + include Gitlab::Auth::Otp::Fortinet DEFAULT_NOTIFICATION_LEVEL = :participating INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 + BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'.freeze + add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) } add_authentication_token_field :feed_token add_authentication_token_field :static_object_token @@ -166,6 +169,7 @@ class User < ApplicationRecord has_many :issue_assignees, inverse_of: :assignee has_many :merge_request_assignees, inverse_of: :assignee + has_many :merge_request_reviewers, inverse_of: :reviewer has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request @@ -286,6 +290,7 @@ class User < ApplicationRecord delegate :path, to: :namespace, allow_nil: true, prefix: true delegate :job_title, :job_title=, to: :user_detail, allow_nil: true + delegate :other_role, :other_role=, to: :user_detail, allow_nil: true delegate :bio, :bio=, :bio_html, to: :user_detail, allow_nil: true delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true @@ -587,11 +592,7 @@ class User < ApplicationRecord sanitized_order_sql = Arel.sql(sanitize_sql_array([order, query: query])) - where( - fuzzy_arel_match(:name, query, lower_exact_match: true) - .or(fuzzy_arel_match(:username, query, lower_exact_match: true)) - .or(arel_table[:email].eq(query)) - ).reorder(sanitized_order_sql, :name) + search_with_secondary_emails(query).reorder(sanitized_order_sql, :name) end # Limits the result set to users _not_ in the given query/list of IDs. @@ -606,6 +607,18 @@ class User < ApplicationRecord reorder(:name) end + def search_without_secondary_emails(query) + return none if query.blank? + + query = query.downcase + + where( + fuzzy_arel_match(:name, query, lower_exact_match: true) + .or(fuzzy_arel_match(:username, query, lower_exact_match: true)) + .or(arel_table[:email].eq(query)) + ) + end + # searches user by given pattern # it compares name, email, username fields and user's secondary emails with given pattern # This method uses ILIKE on PostgreSQL. @@ -616,15 +629,16 @@ class User < ApplicationRecord query = query.downcase email_table = Email.arel_table - matched_by_emails_user_ids = email_table + matched_by_email_user_id = email_table .project(email_table[:user_id]) .where(email_table[:email].eq(query)) + .take(1) # at most 1 record as there is a unique constraint where( fuzzy_arel_match(:name, query) .or(fuzzy_arel_match(:username, query)) .or(arel_table[:email].eq(query)) - .or(arel_table[:id].in(matched_by_emails_user_ids)) + .or(arel_table[:id].eq(matched_by_email_user_id)) ) end @@ -708,6 +722,7 @@ class User < ApplicationRecord u.name = 'GitLab Security Bot' u.website_url = Gitlab::Routing.url_helpers.help_page_url('user/application_security/security_bot/index.md') u.avatar = bot_avatar(image: 'security-bot.png') + u.confirmed_at = Time.zone.now end end @@ -797,7 +812,9 @@ class User < ApplicationRecord end def two_factor_otp_enabled? - otp_required_for_login? || Feature.enabled?(:forti_authenticator, self) + otp_required_for_login? || + forti_authenticator_enabled?(self) || + forti_token_cloud_enabled?(self) end def two_factor_u2f_enabled? @@ -1032,7 +1049,7 @@ class User < ApplicationRecord end def require_personal_access_token_creation_for_git_auth? - return false if allow_password_authentication_for_git? || ldap_user? + return false if allow_password_authentication_for_git? || password_based_omniauth_user? PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none? end @@ -1050,7 +1067,7 @@ class User < ApplicationRecord end def allow_password_authentication_for_git? - Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !ldap_user? + Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !password_based_omniauth_user? end def can_change_username? @@ -1130,6 +1147,18 @@ class User < ApplicationRecord namespace.find_fork_of(project) end + def password_based_omniauth_user? + ldap_user? || crowd_user? + end + + def crowd_user? + if identities.loaded? + identities.find { |identity| identity.provider == 'crowd' && identity.extern_uid.present? } + else + identities.with_any_extern_uid('crowd').exists? + end + end + def ldap_user? if identities.loaded? identities.find { |identity| Gitlab::Auth::OAuth::Provider.ldap_provider?(identity.provider) && !identity.extern_uid.nil? } @@ -1229,7 +1258,7 @@ class User < ApplicationRecord end def solo_owned_groups - @solo_owned_groups ||= owned_groups.select do |group| + @solo_owned_groups ||= owned_groups.includes(:owners).select do |group| group.owners == [self] end end @@ -1464,6 +1493,10 @@ class User < ApplicationRecord !solo_owned_groups.present? end + def can_remove_self? + true + end + def ci_owned_runners @ci_owned_runners ||= begin project_runners = Ci::RunnerProject @@ -1636,11 +1669,11 @@ class User < ApplicationRecord save end - # each existing user needs to have an `feed_token`. + # each existing user needs to have a `feed_token`. # we do this on read since migrating all existing users is not a feasible # solution. def feed_token - ensure_feed_token! + Gitlab::CurrentSettings.disable_feed_token ? nil : ensure_feed_token! end # Each existing user needs to have a `static_object_token`. diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index cfad58fc0db..ad5651f9439 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -26,7 +26,8 @@ class UserCallout < ApplicationRecord suggest_pipeline: 22, customize_homepage: 23, feature_flags_new_version: 24, - registration_enabled_callout: 25 + registration_enabled_callout: 25, + new_user_signups_cap_reached: 26 # EE-only } validates :user, presence: true diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 9674f9a41da..ef799b01452 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -31,3 +31,5 @@ class UserDetail < ApplicationRecord self.bio = '' if bio_changed? && bio.nil? end end + +UserDetail.prepend_if_ee('EE::UserDetail') diff --git a/app/models/wiki_page/meta.rb b/app/models/wiki_page/meta.rb index 215d84dc463..70b5547ffad 100644 --- a/app/models/wiki_page/meta.rb +++ b/app/models/wiki_page/meta.rb @@ -2,149 +2,20 @@ class WikiPage class Meta < ApplicationRecord - include Gitlab::Utils::StrongMemoize - - CanonicalSlugConflictError = Class.new(ActiveRecord::RecordInvalid) - WikiPageInvalid = Class.new(ArgumentError) + include HasWikiPageMetaAttributes self.table_name = 'wiki_page_meta' belongs_to :project has_many :slugs, class_name: 'WikiPage::Slug', foreign_key: 'wiki_page_meta_id', inverse_of: :wiki_page_meta - has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent - validates :title, presence: true validates :project_id, presence: true - validate :no_two_metarecords_in_same_project_can_have_same_canonical_slug - - scope :with_canonical_slug, ->(slug) do - joins(:slugs).where(wiki_page_slugs: { canonical: true, slug: slug }) - end alias_method :resource_parent, :project - class << self - # Return the (updated) WikiPage::Meta record for a given wiki page - # - # If none is found, then a new record is created, and its fields are set - # to reflect the wiki_page passed. - # - # @param [String] last_known_slug - # @param [WikiPage] wiki_page - # - # This method raises errors on validation issues. - def find_or_create(last_known_slug, wiki_page) - raise WikiPageInvalid unless wiki_page.valid? - - project = wiki_page.wiki.project - known_slugs = [last_known_slug, wiki_page.slug].compact.uniq - raise 'No slugs found! This should not be possible.' if known_slugs.empty? - - transaction do - updates = wiki_page_updates(wiki_page) - found = find_by_canonical_slug(known_slugs, project) - meta = found || create!(updates.merge(project_id: project.id)) - - meta.update_state(found.nil?, known_slugs, wiki_page, updates) - - # We don't need to run validations here, since find_by_canonical_slug - # guarantees that there is no conflict in canonical_slug, and DB - # constraints on title and project_id enforce our other invariants - # This saves us a query. - meta - end - end - - def find_by_canonical_slug(canonical_slug, project) - meta, conflict = with_canonical_slug(canonical_slug) - .where(project_id: project.id) - .limit(2) - - if conflict.present? - meta.errors.add(:canonical_slug, 'Duplicate value found') - raise CanonicalSlugConflictError.new(meta) - end - - meta - end - - private - - def wiki_page_updates(wiki_page) - last_commit_date = wiki_page.version_commit_timestamp || Time.now.utc - - { - title: wiki_page.title, - created_at: last_commit_date, - updated_at: last_commit_date - } - end - end - - def canonical_slug - strong_memoize(:canonical_slug) { slugs.canonical.first&.slug } - end - - def canonical_slug=(slug) - return if @canonical_slug == slug - - if persisted? - transaction do - slugs.canonical.update_all(canonical: false) - page_slug = slugs.create_with(canonical: true).find_or_create_by(slug: slug) - page_slug.update_columns(canonical: true) unless page_slug.canonical? - end - else - slugs.new(slug: slug, canonical: true) - end - - @canonical_slug = slug - end - - def update_state(created, known_slugs, wiki_page, updates) - update_wiki_page_attributes(updates) - insert_slugs(known_slugs, created, wiki_page.slug) - self.canonical_slug = wiki_page.slug - end - - private - - def update_wiki_page_attributes(updates) - # Remove all unnecessary updates: - updates.delete(:updated_at) if updated_at == updates[:updated_at] - updates.delete(:created_at) if created_at <= updates[:created_at] - updates.delete(:title) if title == updates[:title] - - update_columns(updates) unless updates.empty? - end - - def insert_slugs(strings, is_new, canonical_slug) - creation = Time.current.utc - - slug_attrs = strings.map do |slug| - { - wiki_page_meta_id: id, - slug: slug, - canonical: (is_new && slug == canonical_slug), - created_at: creation, - updated_at: creation - } - end - slugs.insert_all(slug_attrs) unless !is_new && slug_attrs.size == 1 - - @canonical_slug = canonical_slug if is_new || strings.size == 1 - end - - def no_two_metarecords_in_same_project_can_have_same_canonical_slug - return unless project_id.present? && canonical_slug.present? - - offending = self.class.with_canonical_slug(canonical_slug).where(project_id: project_id) - offending = offending.where.not(id: id) if persisted? - - if offending.exists? - errors.add(:canonical_slug, 'each page in a wiki must have a distinct canonical slug') - end + def self.container_key + :project_id end end end diff --git a/app/models/wiki_page/slug.rb b/app/models/wiki_page/slug.rb index c1725d34921..b82386c0e3c 100644 --- a/app/models/wiki_page/slug.rb +++ b/app/models/wiki_page/slug.rb @@ -2,25 +2,14 @@ class WikiPage class Slug < ApplicationRecord - self.table_name = 'wiki_page_slugs' - - belongs_to :wiki_page_meta, class_name: 'WikiPage::Meta', inverse_of: :slugs - - validates :slug, presence: true, uniqueness: { scope: :wiki_page_meta_id } - validates :canonical, uniqueness: { - scope: :wiki_page_meta_id, - if: :canonical?, - message: 'Only one slug can be canonical per wiki metadata record' - } + def self.meta_foreign_key + :wiki_page_meta_id + end - scope :canonical, -> { where(canonical: true) } + include HasWikiPageSlugAttributes - def update_columns(attrs = {}) - super(attrs.reverse_merge(updated_at: Time.current.utc)) - end + self.table_name = 'wiki_page_slugs' - def self.update_all(attrs = {}) - super(attrs.reverse_merge(updated_at: Time.current.utc)) - end + belongs_to :wiki_page_meta, class_name: 'WikiPage::Meta', inverse_of: :slugs end end diff --git a/app/models/zoom_meeting.rb b/app/models/zoom_meeting.rb index f83aa93b69a..c8b510c4779 100644 --- a/app/models/zoom_meeting.rb +++ b/app/models/zoom_meeting.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true class ZoomMeeting < ApplicationRecord + include Importable include UsageStatistics - belongs_to :project, optional: false - belongs_to :issue, optional: false + belongs_to :project + belongs_to :issue + + validates :project, presence: true, unless: :importing? + validates :issue, presence: true, unless: :importing? validates :url, presence: true, length: { maximum: 255 }, zoom_url: true - validates :issue, same_project_association: true + validates :issue, same_project_association: true, unless: :importing? enum issue_status: { added: 1, diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 580a348b408..51694ec7c50 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -25,6 +25,10 @@ class BasePolicy < DeclarativePolicy::Base with_options scope: :user, score: 0 condition(:support_bot) { @user&.support_bot? } + desc "User is security bot" + with_options scope: :user, score: 0 + condition(:security_bot) { @user&.security_bot? } + desc "User email is unconfirmed or user account is locked" with_options scope: :user, score: 0 condition(:inactive) { @user&.confirmation_required_on_sign_in? || @user&.access_locked? } diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 3efc07421e4..7e69e1fdd88 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -45,6 +45,21 @@ module Ci @subject.pipeline.webide? end + condition(:debug_mode, scope: :subject, score: 32) do + @subject.debug_mode? + end + + condition(:project_read_build, scope: :subject) do + can?(:read_build, @subject.project) + end + + condition(:project_update_build, scope: :subject) do + can?(:update_build, @subject.project) + end + + rule { project_read_build }.enable :read_build_trace + rule { debug_mode & ~project_update_build }.prevent :read_build_trace + rule { ~protected_environment_access & (protected_ref | archived) }.policy do prevent :update_build prevent :update_commit_status diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb index 7eca6f4c6c8..75849fb10c8 100644 --- a/app/policies/concerns/policy_actor.rb +++ b/app/policies/concerns/policy_actor.rb @@ -49,6 +49,10 @@ module PolicyActor false end + def security_bot? + false + end + def deactivated? false end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index c1ea4dddb51..b5c1ec0181e 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -48,7 +48,7 @@ class GlobalPolicy < BasePolicy prevent :use_slash_commands end - rule { blocked | (internal & ~migration_bot) }.policy do + rule { blocked | (internal & ~migration_bot & ~security_bot) }.policy do prevent :access_git end @@ -99,6 +99,7 @@ class GlobalPolicy < BasePolicy enable :read_custom_attribute enable :update_custom_attribute enable :approve_user + enable :reject_user end # We can't use `read_statistics` because the user may have different permissions for different projects diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 231843c5f23..7d0db222eaf 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -185,7 +185,10 @@ class GroupPolicy < BasePolicy rule { developer & developer_maintainer_access }.enable :create_projects rule { create_projects_disabled }.prevent :create_projects - rule { owner | admin }.enable :read_statistics + rule { owner | admin }.policy do + enable :owner_access + enable :read_statistics + end rule { maintainer & can?(:create_projects) }.enable :transfer_projects diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index 5cfbcfec5c0..f49a6ee8498 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -27,3 +27,5 @@ class IssuablePolicy < BasePolicy prevent :award_emoji end end + +IssuablePolicy.prepend_if_ee('EE::IssuablePolicy') diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index aa87442cadd..b1d680b4264 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -8,6 +8,7 @@ class NamespacePolicy < BasePolicy condition(:owner) { @subject.owner == @user } rule { owner | admin }.policy do + enable :owner_access enable :create_projects enable :admin_namespace enable :read_namespace diff --git a/app/policies/project_ci_cd_setting_policy.rb b/app/policies/project_ci_cd_setting_policy.rb new file mode 100644 index 00000000000..a22b790415b --- /dev/null +++ b/app/policies/project_ci_cd_setting_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ProjectCiCdSettingPolicy < BasePolicy + delegate { @subject.project } +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 13073ed68a1..403fb34803e 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -135,6 +135,10 @@ class ProjectPolicy < BasePolicy ::Feature.enabled?(:build_service_proxy, @subject) end + condition(:project_bot_is_member) do + user.project_bot? & team_member? + end + with_scope :subject condition(:packages_disabled) { !@subject.packages_enabled } @@ -147,6 +151,8 @@ class ProjectPolicy < BasePolicy builds pages metrics_dashboard + analytics + operations ] features.each do |f| @@ -211,6 +217,7 @@ class ProjectPolicy < BasePolicy enable :award_emoji enable :read_pages_content enable :read_release + enable :read_analytics end # These abilities are not allowed to admins that are not members of the project, @@ -272,6 +279,19 @@ class ProjectPolicy < BasePolicy prevent(:metrics_dashboard) end + rule { operations_disabled }.policy do + prevent(*create_read_update_admin_destroy(:feature_flag)) + prevent(*create_read_update_admin_destroy(:environment)) + prevent(*create_read_update_admin_destroy(:sentry_issue)) + prevent(*create_read_update_admin_destroy(:alert_management_alert)) + prevent(*create_read_update_admin_destroy(:cluster)) + prevent(*create_read_update_admin_destroy(:terraform_state)) + prevent(*create_read_update_admin_destroy(:deployment)) + prevent(:metrics_dashboard) + prevent(:read_pod_logs) + prevent(:read_prometheus) + end + rule { can?(:metrics_dashboard) }.policy do enable :read_prometheus enable :read_deployment @@ -424,6 +444,10 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:snippet)) end + rule { analytics_disabled }.policy do + prevent(:read_analytics) + end + rule { wiki_disabled }.policy do prevent(*create_read_update_admin_destroy(:wiki)) prevent(:download_wiki_code) @@ -494,6 +518,7 @@ class ProjectPolicy < BasePolicy enable :download_wiki_code enable :read_cycle_analytics enable :read_pages_content + enable :read_analytics # NOTE: may be overridden by IssuePolicy enable :read_issue @@ -594,6 +619,8 @@ class ProjectPolicy < BasePolicy enable :admin_resource_access_tokens end + rule { project_bot_is_member & ~blocked }.enable :bot_log_in + private def user_is_user? diff --git a/app/policies/timebox_policy.rb b/app/policies/timebox_policy.rb new file mode 100644 index 00000000000..03a1acb9358 --- /dev/null +++ b/app/policies/timebox_policy.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class TimeboxPolicy < BasePolicy + # stub permissions policy on None, Any, Upcoming, Started and Current timeboxes + + rule { default }.policy do + enable :read_iteration + enable :read_milestone + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 70e8fb32064..48c2bd3f0bd 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -13,6 +13,9 @@ class UserPolicy < BasePolicy desc "The user is blocked" condition(:blocked_user, scope: :subject, score: 0) { @subject.blocked? } + desc "The user is unconfirmed" + condition(:unconfirmed_user, scope: :subject, score: 0) { !@subject.confirmed? } + rule { ~restricted_public_level }.enable :read_user rule { ~anonymous }.enable :read_user @@ -25,7 +28,7 @@ class UserPolicy < BasePolicy end rule { default }.enable :read_user_profile - rule { (private_profile | blocked_user) & ~(user_is_self | admin) }.prevent :read_user_profile + rule { (private_profile | blocked_user | unconfirmed_user) & ~(user_is_self | admin) }.prevent :read_user_profile rule { user_is_self | admin }.enable :disable_two_factor rule { (user_is_self | admin) & ~blocked }.enable :create_user_personal_access_token end diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb index 4bfa3dc9a13..1cebf5c561a 100644 --- a/app/presenters/alert_management/alert_presenter.rb +++ b/app/presenters/alert_management/alert_presenter.rb @@ -8,7 +8,6 @@ module AlertManagement MARKDOWN_LINE_BREAK = " \n" HORIZONTAL_LINE = "\n\n---\n\n" - INCIDENT_LABEL_NAME = ::IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title] delegate :metrics_dashboard_url, :runbook, to: :parsed_payload @@ -48,7 +47,7 @@ module AlertManagement end def incident_issues_link - project_issues_url(project, label_name: INCIDENT_LABEL_NAME) + project_incidents_url(project) end def performance_dashboard_link diff --git a/app/presenters/analytics/cycle_analytics/stage_presenter.rb b/app/presenters/analytics/cycle_analytics/stage_presenter.rb new file mode 100644 index 00000000000..7b295b814bc --- /dev/null +++ b/app/presenters/analytics/cycle_analytics/stage_presenter.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class StagePresenter < Gitlab::View::Presenter::Delegated + def title + extract_default_stage_attribute(:title) || name + end + + def description + extract_default_stage_attribute(:description) || '' + end + + def legend + '' + end + + private + + def extract_default_stage_attribute(attribute) + default_stage_attributes.dig(name.to_sym, attribute.to_sym) + end + + def default_stage_attributes + @default_stage_attributes ||= { + issue: { + title: s_('CycleAnalyticsStage|Issue'), + description: _('Time before an issue gets scheduled') + }, + plan: { + title: s_('CycleAnalyticsStage|Plan'), + description: _('Time before an issue starts implementation') + }, + code: { + title: s_('CycleAnalyticsStage|Code'), + description: _('Time until first merge request') + }, + test: { + title: s_('CycleAnalyticsStage|Test'), + description: _('Total test time for all commits/merges') + }, + review: { + title: s_('CycleAnalyticsStage|Review'), + description: _('Time between merge request creation and merge/close') + }, + staging: { + title: s_('CycleAnalyticsStage|Staging'), + description: _('From merge request merge until deploy to production') + }, + production: { + title: s_('CycleAnalyticsStage|Total'), + description: _('From issue creation until deploy to production') + } + }.freeze + end + end + end +end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index da610f13899..f3bb63b31c3 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -10,7 +10,8 @@ module Ci def self.failure_reasons { unknown_failure: 'Unknown pipeline failure!', config_error: 'CI/CD YAML configuration error!', - external_validation_failure: 'External pipeline validation failed!' } + external_validation_failure: 'External pipeline validation failed!', + deployments_limit_exceeded: 'Pipeline deployments limit exceeded!' } end presents :pipeline diff --git a/app/presenters/gitlab/whats_new/item_presenter.rb b/app/presenters/gitlab/whats_new/item_presenter.rb new file mode 100644 index 00000000000..9f66e19ade0 --- /dev/null +++ b/app/presenters/gitlab/whats_new/item_presenter.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Gitlab + module WhatsNew + class ItemPresenter + DICTIONARY = { + core: 'Free', + starter: 'Bronze', + premium: 'Silver', + ultimate: 'Gold' + }.freeze + + def self.present(item) + if Gitlab.com? + item['packages'] = item['packages'].map { |p| DICTIONARY[p.downcase.to_sym] } + end + + item + end + end + end +end diff --git a/app/presenters/packages/composer/packages_presenter.rb b/app/presenters/packages/composer/packages_presenter.rb index 84f266989e9..cce006cbb1a 100644 --- a/app/presenters/packages/composer/packages_presenter.rb +++ b/app/presenters/packages/composer/packages_presenter.rb @@ -11,7 +11,7 @@ module Packages end def root - path = api_v4_group___packages_composer_package_name_path({ id: @group.id, package_name: '%package%', format: '.json' }, true) + path = api_v4_group___packages_composer_package_name_path({ id: @group.id, package_name: '%package%$%hash%', format: '.json' }, true) { 'packages' => [], 'provider-includes' => { 'p/%hash%.json' => { 'sha256' => provider_sha } }, 'providers-url' => path } end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 0f5b601f2b0..55b550d8544 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -77,19 +77,19 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def readme_path - filename_path(:readme) + filename_path(repository.readme_path) end def changelog_path - filename_path(:changelog) + filename_path(repository.changelog&.name) end def license_path - filename_path(:license_blob) + filename_path(repository.license_blob&.name) end def ci_configuration_path - filename_path(:gitlab_ci_yml) + filename_path(repository.gitlab_ci_yml&.name) end def contribution_guide_path @@ -244,11 +244,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def readme_anchor_data - if current_user && can_current_user_push_to_default_branch? && repository.readme.nil? + if current_user && can_current_user_push_to_default_branch? && readme_path.nil? AnchorData.new(false, statistic_icon + _('Add README'), empty_repo? ? add_readme_ide_path : add_readme_path) - elsif repository.readme + elsif readme_path AnchorData.new(false, statistic_icon('doc-text') + _('README'), default_view != 'readme' ? readme_path : '#readme', @@ -397,13 +397,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated current_user && can?(current_user, :create_cluster, project) end - def filename_path(filename) - if blob = repository.public_send(filename) # rubocop:disable GitlabSecurity/PublicSend - project_blob_path( - project, - tree_join(default_branch, blob.name) - ) - end + def filename_path(filepath) + return if filepath.blank? + + project_blob_path(project, tree_join(default_branch, filepath)) end def anonymous_project_view diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb index 8f3fc53af10..b52f3411c49 100644 --- a/app/presenters/projects/import_export/project_export_presenter.rb +++ b/app/presenters/projects/import_export/project_export_presenter.rb @@ -15,6 +15,10 @@ module Projects self.respond_to?(:override_description) ? override_description : super end + def protected_branches + project.exported_protected_branches + end + private def converted_group_members diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb new file mode 100644 index 00000000000..19a90d002aa --- /dev/null +++ b/app/presenters/search_service_presenter.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class SearchServicePresenter < Gitlab::View::Presenter::Delegated + include RendersCommits + + presents :search_service + + SCOPE_PRELOAD_METHOD = { + projects: :with_web_entity_associations, + issues: :with_web_entity_associations, + merge_requests: :with_web_entity_associations, + epics: :with_web_entity_associations + }.freeze + + SORT_ENABLED_SCOPES = %w(issues merge_requests).freeze + + def search_objects + @search_objects ||= begin + objects = search_service.search_objects(SCOPE_PRELOAD_METHOD[scope.to_sym]) + + case scope + when 'users' + objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord + when 'commits' + prepare_commits_for_rendering(objects) + else + objects + end + end + end + + def show_sort_dropdown? + SORT_ENABLED_SCOPES.include?(scope) + end + + def show_results_status? + !without_count? || show_snippets? || show_sort_dropdown? + end + + def without_count? + search_objects.is_a?(Kaminari::PaginatableWithoutCount) + end +end diff --git a/app/serializers/admin/user_entity.rb b/app/serializers/admin/user_entity.rb new file mode 100644 index 00000000000..ad96c101822 --- /dev/null +++ b/app/serializers/admin/user_entity.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Admin + class UserEntity < API::Entities::UserSafe + include RequestAwareEntity + include UsersHelper + include UserActionsHelper + + expose :created_at + expose :email + expose :last_activity_on + expose :avatar_url + expose :badges do |user| + user_badges_in_admin_section(user) + end + + expose :projects_count do |user| + user.authorized_projects.length + end + + expose :actions do |user| + admin_actions(user) + end + + private + + def current_user + options[:current_user] + end + end +end diff --git a/app/serializers/admin/user_serializer.rb b/app/serializers/admin/user_serializer.rb new file mode 100644 index 00000000000..09036428bab --- /dev/null +++ b/app/serializers/admin/user_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Admin + class UserSerializer < BaseSerializer + entity UserEntity + end +end diff --git a/app/serializers/codequality_degradation_entity.rb b/app/serializers/codequality_degradation_entity.rb new file mode 100644 index 00000000000..be561052507 --- /dev/null +++ b/app/serializers/codequality_degradation_entity.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CodequalityDegradationEntity < Grape::Entity + expose :description + expose :severity + + expose :file_path do |degradation| + degradation.dig(:location, :path) + end + + expose :line do |degradation| + degradation.dig(:location, :lines, :begin) || degradation.dig(:location, :positions, :begin, :line) + end +end diff --git a/app/serializers/codequality_reports_comparer_entity.rb b/app/serializers/codequality_reports_comparer_entity.rb new file mode 100644 index 00000000000..1de4e56c57d --- /dev/null +++ b/app/serializers/codequality_reports_comparer_entity.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CodequalityReportsComparerEntity < Grape::Entity + expose :status + + expose :new_errors, using: CodequalityDegradationEntity + expose :resolved_errors, using: CodequalityDegradationEntity + expose :existing_errors, using: CodequalityDegradationEntity + + expose :summary do + expose :total_count, as: :total + expose :resolved_count, as: :resolved + expose :errors_count, as: :errored + end +end diff --git a/app/serializers/codequality_reports_comparer_serializer.rb b/app/serializers/codequality_reports_comparer_serializer.rb new file mode 100644 index 00000000000..2c6eb33aa9f --- /dev/null +++ b/app/serializers/codequality_reports_comparer_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CodequalityReportsComparerSerializer < BaseSerializer + entity CodequalityReportsComparerEntity +end diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb index 633b117d392..fcf6700cb59 100644 --- a/app/serializers/concerns/user_status_tooltip.rb +++ b/app/serializers/concerns/user_status_tooltip.rb @@ -8,12 +8,18 @@ module UserStatusTooltip include UsersHelper included do - expose :user_status_if_loaded, as: :status_tooltip_html + expose :status_tooltip_html, if: -> (*) { status_loaded? } do |user| + user_status(user) + end + + expose :show_status do |user| + status_loaded? && show_status_emoji?(user.status) + end - def user_status_if_loaded - return unless object.association(:status).loaded? + private - user_status(object) + def status_loaded? + object.association(:status).loaded? end end end diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb index 5036f28184c..1409f023f21 100644 --- a/app/serializers/diff_file_base_entity.rb +++ b/app/serializers/diff_file_base_entity.rb @@ -118,7 +118,7 @@ class DiffFileBaseEntity < Grape::Entity strong_memoize(:submodule_links) do next unless diff_file.submodule? - options[:submodule_links].for(diff_file.blob, diff_file.content_sha, diff_file) + options[:submodule_links]&.for(diff_file.blob, diff_file.content_sha, diff_file) end end diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb index 8973f23734a..7b0de3bce4e 100644 --- a/app/serializers/diffs_metadata_entity.rb +++ b/app/serializers/diffs_metadata_entity.rb @@ -2,7 +2,9 @@ class DiffsMetadataEntity < DiffsEntity unexpose :diff_files - expose :raw_diff_files, as: :diff_files, using: DiffFileMetadataEntity + expose :diff_files, using: DiffFileMetadataEntity do |diffs, _| + diffs.raw_diff_files(sorted: true) + end expose :conflict_resolution_path do |_, options| presenter(options[:merge_request]).conflict_resolution_path diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 0bd9c602bf5..8c6ad010d69 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -3,6 +3,9 @@ class EnvironmentEntity < Grape::Entity include RequestAwareEntity + UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT = + %i[manual_actions scheduled_actions playable_build cluster].freeze + expose :id expose :global_id do |environment| @@ -17,6 +20,11 @@ class EnvironmentEntity < Grape::Entity expose :last_deployment, using: DeploymentEntity expose :stop_action_available?, as: :has_stop_action + expose :upcoming_deployment, expose_nil: false do |environment, ops| + DeploymentEntity.represent(environment.upcoming_deployment, + ops.merge(except: UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT)) + end + expose :metrics_path, if: -> (*) { environment.has_metrics? } do |environment| metrics_project_environment_path(environment.project, environment) end diff --git a/app/serializers/import/bulk_import_entity.rb b/app/serializers/import/bulk_import_entity.rb index 8f0a9dd4428..9daa6699a20 100644 --- a/app/serializers/import/bulk_import_entity.rb +++ b/app/serializers/import/bulk_import_entity.rb @@ -12,4 +12,8 @@ class Import::BulkImportEntity < Grape::Entity expose :full_path do |entity| entity['full_path'] end + + expose :web_url do |entity| + entity['web_url'] + end end diff --git a/app/serializers/merge_request_assignee_entity.rb b/app/serializers/merge_request_assignee_entity.rb deleted file mode 100644 index b7ef7449270..00000000000 --- a/app/serializers/merge_request_assignee_entity.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class MergeRequestAssigneeEntity < ::API::Entities::UserBasic - expose :can_merge do |assignee, options| - options[:merge_request]&.can_be_merged_by?(assignee) - end -end - -MergeRequestAssigneeEntity.prepend_if_ee('EE::MergeRequestAssigneeEntity') diff --git a/app/serializers/merge_request_current_user_entity.rb b/app/serializers/merge_request_current_user_entity.rb new file mode 100644 index 00000000000..fbdb4e505ec --- /dev/null +++ b/app/serializers/merge_request_current_user_entity.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class MergeRequestCurrentUserEntity < CurrentUserEntity + include RequestAwareEntity + include BlobHelper + include TreeHelper + + expose :can_fork do |user| + project && can?(user, :fork_project, request.project) + end + + expose :can_create_merge_request do |user| + project && can?(user, :create_merge_request_in, project) + end + + expose :fork_path, if: -> (*) { project } do |user| + params = edit_blob_fork_params("Edit") + project_forks_path(project, namespace_key: user.namespace.id, continue: params) + end + + def project + request.respond_to?(:project) && request.project + end +end diff --git a/app/serializers/merge_request_reviewer_entity.rb b/app/serializers/merge_request_reviewer_entity.rb deleted file mode 100644 index fefd116014f..00000000000 --- a/app/serializers/merge_request_reviewer_entity.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class MergeRequestReviewerEntity < ::API::Entities::UserBasic - expose :can_merge do |reviewer, options| - options[:merge_request]&.can_be_merged_by?(reviewer) - end -end - -MergeRequestReviewerEntity.prepend_if_ee('EE::MergeRequestReviewerEntity') diff --git a/app/serializers/merge_request_sidebar_extras_entity.rb b/app/serializers/merge_request_sidebar_extras_entity.rb index 9db8e52abef..261b6e8e519 100644 --- a/app/serializers/merge_request_sidebar_extras_entity.rb +++ b/app/serializers/merge_request_sidebar_extras_entity.rb @@ -2,10 +2,10 @@ class MergeRequestSidebarExtrasEntity < IssuableSidebarExtrasEntity expose :assignees do |merge_request| - MergeRequestAssigneeEntity.represent(merge_request.assignees, merge_request: merge_request) + MergeRequestUserEntity.represent(merge_request.assignees, merge_request: merge_request) end expose :reviewers, if: -> (m) { m.allows_reviewers? } do |merge_request| - MergeRequestReviewerEntity.represent(merge_request.reviewers, merge_request: merge_request) + MergeRequestUserEntity.represent(merge_request.reviewers, merge_request: merge_request) end end diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb index 53257b0602c..604c9cabd50 100644 --- a/app/serializers/merge_request_user_entity.rb +++ b/app/serializers/merge_request_user_entity.rb @@ -1,26 +1,9 @@ # frozen_string_literal: true -class MergeRequestUserEntity < CurrentUserEntity - include RequestAwareEntity - include BlobHelper - include TreeHelper - - expose :can_fork do |user| - can?(user, :fork_project, request.project) if project - end - - expose :can_create_merge_request do |user| - project && can?(user, :create_merge_request_in, project) - end - - expose :fork_path, if: -> (*) { project } do |user| - params = edit_blob_fork_params("Edit") - project_forks_path(project, namespace_key: user.namespace.id, continue: params) - end - - def project - return false unless request.respond_to?(:project) && request.project - - request.project +class MergeRequestUserEntity < ::API::Entities::UserBasic + expose :can_merge do |reviewer, options| + options[:merge_request]&.can_be_merged_by?(reviewer) end end + +MergeRequestUserEntity.prepend_if_ee('EE::MergeRequestUserEntity') diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index e46b269ea35..afd4d5b9a2b 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -2,6 +2,9 @@ class MergeRequestWidgetEntity < Grape::Entity include RequestAwareEntity + include ProjectsHelper + include ApplicationHelper + include ApplicationSettingsHelper SUGGEST_PIPELINE = 'suggest_pipeline' @@ -48,6 +51,10 @@ class MergeRequestWidgetEntity < Grape::Entity help_page_path('user/project/merge_requests/resolve_conflicts.md') end + expose :reviewing_and_managing_merge_requests_docs_path do |merge_request| + help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: "checkout-merge-requests-locally-through-the-head-ref") + end + expose :merge_request_pipelines_docs_path do |merge_request| help_page_path('ci/merge_request_pipelines/index.md') end @@ -67,15 +74,15 @@ class MergeRequestWidgetEntity < Grape::Entity ) end - expose :user_callouts_path, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request| + expose :user_callouts_path do |_merge_request| user_callouts_path end - expose :suggest_pipeline_feature_id, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request| + expose :suggest_pipeline_feature_id do |_merge_request| SUGGEST_PIPELINE end - expose :is_dismissed_suggest_pipeline, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request| + expose :is_dismissed_suggest_pipeline do |_merge_request| current_user && current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE) end @@ -87,6 +94,10 @@ class MergeRequestWidgetEntity < Grape::Entity new_project_pipeline_path(merge_request.project) end + expose :source_project_default_url do |merge_request| + merge_request.source_project && default_url_to_repo(merge_request.source_project) + end + # Rendering and redacting Markdown can be expensive. These links are # just nice to have in the merge request widget, so only # include them if they are explicitly requested on first load. diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb index fe59686278c..1118b1aa4fe 100644 --- a/app/serializers/paginated_diff_entity.rb +++ b/app/serializers/paginated_diff_entity.rb @@ -13,7 +13,7 @@ class PaginatedDiffEntity < Grape::Entity submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository) DiffFileEntity.represent( - diffs.diff_files, + diffs.diff_files(sorted: true), options.merge( submodule_links: submodule_links, code_navigation_path: code_navigation_path(diffs), diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index a45214670fa..ab2c6dfeace 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -75,3 +75,5 @@ class PipelineSerializer < BaseSerializer ] end end + +PipelineSerializer.prepend_if_ee('EE::PipelineSerializer') diff --git a/app/serializers/rollout_status_entity.rb b/app/serializers/rollout_status_entity.rb new file mode 100644 index 00000000000..9f4c844859b --- /dev/null +++ b/app/serializers/rollout_status_entity.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class RolloutStatusEntity < Grape::Entity + include RequestAwareEntity + + expose :status, as: :status + + # To be removed in API v5 + expose :has_legacy_app_label do |_rollout_status| + false + end + + expose :instances, if: -> (rollout_status, _) { rollout_status.found? } + expose :completion, if: -> (rollout_status, _) { rollout_status.found? } + expose :complete?, as: :is_completed, if: -> (rollout_status, _) { rollout_status.found? } + expose :canary_ingress, using: RolloutStatuses::IngressEntity, expose_nil: false, + if: -> (rollout_status, _) { rollout_status.found? && rollout_status.canary_ingress_exists? } +end diff --git a/app/serializers/rollout_statuses/ingress_entity.rb b/app/serializers/rollout_statuses/ingress_entity.rb new file mode 100644 index 00000000000..a68d936b86c --- /dev/null +++ b/app/serializers/rollout_statuses/ingress_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module RolloutStatuses + class IngressEntity < Grape::Entity + expose :canary_weight + end +end diff --git a/app/serializers/user_entity.rb b/app/serializers/user_entity.rb index 8909ae8df2c..9386c06b87a 100644 --- a/app/serializers/user_entity.rb +++ b/app/serializers/user_entity.rb @@ -2,3 +2,5 @@ class UserEntity < API::Entities::UserPath end + +UserEntity.prepend_if_ee('EE::UserEntity') diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index d988caea92d..dfbd787298d 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -8,7 +8,7 @@ class UserSerializer < BaseSerializer merge_request = opts[:project].merge_requests.find_by_iid!(params[:merge_request_iid]) preload_max_member_access(merge_request.project, Array(resource)) - super(resource, opts.merge(merge_request: merge_request), MergeRequestAssigneeEntity) + super(resource, opts.merge(merge_request: merge_request), MergeRequestUserEntity) else super end @@ -20,3 +20,5 @@ class UserSerializer < BaseSerializer project.team.max_member_access_for_user_ids(users.map(&:id)) end end + +UserSerializer.prepend_if_ee('EE::UserSerializer') diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb index ddd5add42bd..253c3a84fef 100644 --- a/app/services/admin/propagate_integration_service.rb +++ b/app/services/admin/propagate_integration_service.rb @@ -7,7 +7,7 @@ module Admin def propagate if integration.instance? update_inherited_integrations - create_integration_for_groups_without_integration if Feature.enabled?(:group_level_integrations, default_enabled: true) + create_integration_for_groups_without_integration create_integration_for_projects_without_integration else update_inherited_descendant_integrations diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb index 28ce5401a6c..753162bfdbf 100644 --- a/app/services/alert_management/process_prometheus_alert_service.rb +++ b/app/services/alert_management/process_prometheus_alert_service.rb @@ -1,10 +1,16 @@ # frozen_string_literal: true module AlertManagement - class ProcessPrometheusAlertService < BaseService + class ProcessPrometheusAlertService + include BaseServiceUtility include Gitlab::Utils::StrongMemoize include ::IncidentManagement::Settings + def initialize(project, payload) + @project = project + @payload = payload + end + def execute return bad_request unless incoming_payload.has_required_attributes? @@ -19,6 +25,8 @@ module AlertManagement private + attr_reader :project, :payload + def process_alert_management_alert if incoming_payload.resolved? process_resolved_alert_management_alert @@ -127,7 +135,7 @@ module AlertManagement strong_memoize(:incoming_payload) do Gitlab::AlertManagement::Payload.parse( project, - params, + payload, monitoring_tool: Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus] ) end diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index df9217bea32..7792b811b4e 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -9,6 +9,16 @@ module ApplicationSettings MARKDOWN_CACHE_INVALIDATING_PARAMS = %w(asset_proxy_enabled asset_proxy_url asset_proxy_secret_key asset_proxy_whitelist).freeze def execute + result = update_settings + + auto_approve_blocked_users if result + + result + end + + private + + def update_settings validate_classification_label(application_setting, :external_authorization_service_default_label) unless bypass_external_auth? if application_setting.errors.any? @@ -40,8 +50,6 @@ module ApplicationSettings @application_setting.save end - private - def usage_stats_updated? params.key?(:usage_ping_enabled) || params.key?(:version_check_enabled) end @@ -95,6 +103,20 @@ module ApplicationSettings def bypass_external_auth? params.key?(:external_authorization_service_enabled) && !Gitlab::Utils.to_boolean(params[:external_authorization_service_enabled]) end + + def auto_approve_blocked_users + return unless should_auto_approve_blocked_users? + + ApproveBlockedPendingApprovalUsersWorker.perform_async(current_user.id) + end + + def should_auto_approve_blocked_users? + return false unless application_setting.previous_changes.key?(:require_admin_approval_after_user_signup) + + enabled_previous, enabled_current = application_setting.previous_changes[:require_admin_approval_after_user_signup] + + enabled_previous && !enabled_current + end end end diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index 831a25a637e..d74f20511bd 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -130,6 +130,7 @@ module Auth ContainerRepository.create_from_path!(path) end + # Overridden in EE def can_access?(requested_project, requested_action) return false unless requested_project.container_registry_enabled? return false if requested_project.repository_access_level == ::ProjectFeature::DISABLED @@ -226,11 +227,16 @@ module Auth end end + # Overridden in EE + def extra_info + {} + end + def log_if_actions_denied(type, requested_project, requested_actions, authorized_actions) return if requested_actions == authorized_actions log_info = { - message: "Denied container registry permissions", + message: 'Denied container registry permissions', scope_type: type, requested_project_path: requested_project.full_path, requested_actions: requested_actions, @@ -238,9 +244,11 @@ module Auth username: current_user&.username, user_id: current_user&.id, project_path: project&.full_path - }.compact + }.merge!(extra_info).compact Gitlab::AuthLogger.warn(log_info) end end end + +Auth::ContainerRegistryAuthenticationService.prepend_if_ee('EE::Auth::ContainerRegistryAuthenticationService') diff --git a/app/services/auth/dependency_proxy_authentication_service.rb b/app/services/auth/dependency_proxy_authentication_service.rb new file mode 100644 index 00000000000..1b8c16b7c79 --- /dev/null +++ b/app/services/auth/dependency_proxy_authentication_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Auth + class DependencyProxyAuthenticationService < BaseService + AUDIENCE = 'dependency_proxy' + HMAC_KEY = 'gitlab-dependency-proxy' + DEFAULT_EXPIRE_TIME = 1.minute + + def execute(authentication_abilities:) + return error('dependency proxy not enabled', 404) unless ::Gitlab.config.dependency_proxy.enabled + return error('access forbidden', 403) unless current_user + + { token: authorized_token.encoded } + end + + class << self + include ::Gitlab::Utils::StrongMemoize + + def secret + strong_memoize(:secret) do + OpenSSL::HMAC.hexdigest( + 'sha256', + ::Settings.attr_encrypted_db_key_base, + HMAC_KEY + ) + end + end + + def token_expire_at + Time.current + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes + end + end + + private + + def authorized_token + JSONWebToken::HMACToken.new(self.class.secret).tap do |token| + token['user_id'] = current_user.id + token.expire_time = self.class.token_expire_at + end + end + end +end diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb index 9c7a165776e..a21ceee083f 100644 --- a/app/services/boards/lists/create_service.rb +++ b/app/services/boards/lists/create_service.rb @@ -6,17 +6,21 @@ module Boards include Gitlab::Utils::StrongMemoize def execute(board) - List.transaction do - case type - when :backlog - create_backlog(board) - else - target = target(board) - position = next_position(board) - - create_list(board, type, target, position) - end - end + list = case type + when :backlog + create_backlog(board) + else + target = target(board) + position = next_position(board) + + return ServiceResponse.error(message: _('%{board_target} not found') % { board_target: type.to_s.capitalize }) if target.blank? + + create_list(board, type, target, position) + end + + return ServiceResponse.error(message: list.errors.full_messages) unless list.persisted? + + ServiceResponse.success(payload: { list: list }) end private @@ -33,7 +37,7 @@ module Boards def target(board) strong_memoize(:target) do - available_labels.find(params[:label_id]) + available_labels.find_by(id: params[:label_id]) # rubocop: disable CodeReuse/ActiveRecord end end diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb index 4fbf1026019..d74320e92a3 100644 --- a/app/services/boards/lists/generate_service.rb +++ b/app/services/boards/lists/generate_service.rb @@ -7,7 +7,11 @@ module Boards return false unless board.lists.movable.empty? List.transaction do - label_params.each { |params| create_list(board, params) } + label_params.each do |params| + response = create_list(board, params) + + raise ActiveRecord::Rollback unless response.success? + end end true diff --git a/app/services/ci/compare_codequality_reports_service.rb b/app/services/ci/compare_codequality_reports_service.rb new file mode 100644 index 00000000000..20f5378f051 --- /dev/null +++ b/app/services/ci/compare_codequality_reports_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Ci + class CompareCodequalityReportsService < CompareReportsBaseService + def comparer_class + Gitlab::Ci::Reports::CodequalityReportsComparer + end + + def serializer_class + CodequalityReportsComparerSerializer + end + + def get_report(pipeline) + pipeline&.codequality_reports + end + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index e3bab2de44e..dbe81521cfc 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -18,6 +18,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules, Gitlab::Ci::Pipeline::Chain::Seed, Gitlab::Ci::Pipeline::Chain::Limit::Size, + Gitlab::Ci::Pipeline::Chain::Limit::Deployments, Gitlab::Ci::Pipeline::Chain::Validate::External, Gitlab::Ci::Pipeline::Chain::Populate, Gitlab::Ci::Pipeline::Chain::StopDryRun, @@ -90,7 +91,9 @@ module Ci # rubocop: enable Metrics/ParameterLists def execute!(*args, &block) - execute(*args, &block).tap do |pipeline| + source, params = args[0], Hash(args[1]) + + execute(source, **params, &block).tap do |pipeline| unless pipeline.persisted? raise CreateError, pipeline.full_error_messages end diff --git a/app/services/ci/list_config_variables_service.rb b/app/services/ci/list_config_variables_service.rb index 4a5b3a92a2c..88dac514bb9 100644 --- a/app/services/ci/list_config_variables_service.rb +++ b/app/services/ci/list_config_variables_service.rb @@ -2,7 +2,26 @@ module Ci class ListConfigVariablesService < ::BaseService + include ReactiveCaching + + self.reactive_cache_key = ->(service) { [service.class.name, service.id] } + self.reactive_cache_work_type = :external_dependency + self.reactive_cache_worker_finder = ->(id, *_args) { from_cache(id) } + + def self.from_cache(id) + project_id, user_id = id.split('-') + + project = Project.find(project_id) + user = User.find(user_id) + + new(project, user) + end + def execute(sha) + with_reactive_cache(sha) { |result| result } + end + + def calculate_reactive_cache(sha) config = project.ci_config_for(sha) return {} unless config @@ -12,5 +31,10 @@ module Ci result.valid? ? result.variables_with_data : {} end + + # Required for ReactiveCaching, it is also used in `reactive_cache_worker_finder` + def id + "#{project.id}-#{current_user.id}" + end end end diff --git a/app/services/ci/test_cases_service.rb b/app/services/ci/test_cases_service.rb deleted file mode 100644 index 3139b567571..00000000000 --- a/app/services/ci/test_cases_service.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module Ci - class TestCasesService - MAX_TRACKABLE_FAILURES = 200 - - def execute(build) - return unless Feature.enabled?(:test_failure_history, build.project) - return unless build.has_test_reports? - return unless build.project.default_branch_or_master == build.ref - - test_suite = generate_test_suite_report(build) - - track_failures(build, test_suite) - end - - private - - def generate_test_suite_report(build) - build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) - end - - def track_failures(build, test_suite) - return if test_suite.failed_count > MAX_TRACKABLE_FAILURES - - test_suite.failed.keys.each_slice(100) do |keys| - Ci::TestCase.transaction do - test_cases = Ci::TestCase.find_or_create_by_batch(build.project, keys) - Ci::TestCaseFailure.insert_all(test_case_failures(test_cases, build)) - end - end - end - - def test_case_failures(test_cases, build) - test_cases.map do |test_case| - { - test_case_id: test_case.id, - build_id: build.id, - failed_at: build.finished_at - } - end - end - end -end diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb new file mode 100644 index 00000000000..99a2592ec06 --- /dev/null +++ b/app/services/ci/test_failure_history_service.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Ci + class TestFailureHistoryService + class Async + attr_reader :service + + def initialize(service) + @service = service + end + + def perform_if_needed + TestFailureHistoryWorker.perform_async(service.pipeline.id) if service.should_track_failures? + end + end + + MAX_TRACKABLE_FAILURES = 200 + + attr_reader :pipeline + delegate :project, to: :pipeline + + def initialize(pipeline) + @pipeline = pipeline + end + + def execute + return unless should_track_failures? + + track_failures + end + + def should_track_failures? + return false unless Feature.enabled?(:test_failure_history, project) + return false unless project.default_branch_or_master == pipeline.ref + + # We fetch for up to MAX_TRACKABLE_FAILURES + 1 builds. So if ever we get + # 201 total number of builds with the assumption that each job has at least + # 1 failed test case, then we have at least 201 failed test cases which exceeds + # the MAX_TRACKABLE_FAILURES of 200. If this is the case, let's early exit so we + # don't have to parse each JUnit report of each of the 201 builds. + failed_builds.length <= MAX_TRACKABLE_FAILURES + end + + def async + Async.new(self) + end + + private + + def failed_builds + @failed_builds ||= pipeline.builds_with_failed_tests(limit: MAX_TRACKABLE_FAILURES + 1) + end + + def track_failures + failed_test_cases = gather_failed_test_cases(failed_builds) + + return if failed_test_cases.size > MAX_TRACKABLE_FAILURES + + failed_test_cases.keys.each_slice(100) do |key_hashes| + Ci::TestCase.transaction do + ci_test_cases = Ci::TestCase.find_or_create_by_batch(project, key_hashes) + failures = test_case_failures(ci_test_cases, failed_test_cases) + + Ci::TestCaseFailure.insert_all(failures) + end + end + end + + def gather_failed_test_cases(failed_builds) + failed_builds.each_with_object({}) do |build, failed_test_cases| + test_suite = generate_test_suite!(build) + test_suite.failed.keys.each do |key| + failed_test_cases[key] = build + end + end + end + + def generate_test_suite!(build) + # Returns an instance of Gitlab::Ci::Reports::TestSuite + build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + end + + def test_case_failures(ci_test_cases, failed_test_cases) + ci_test_cases.map do |test_case| + build = failed_test_cases[test_case.key_hash] + + { + test_case_id: test_case.id, + build_id: build.id, + failed_at: build.finished_at + } + end + end + end +end diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb index fb67b0d2355..f01d41d9414 100644 --- a/app/services/ci/update_build_state_service.rb +++ b/app/services/ci/update_build_state_service.rb @@ -82,6 +82,10 @@ module Ci unless checksum.valid? metrics.increment_trace_operation(operation: :invalid) + if checksum.corrupted? + metrics.increment_trace_operation(operation: :corrupted) + end + next unless log_invalid_chunks? ::Gitlab::ErrorTracking.log_exception(InvalidTraceError.new, @@ -89,7 +93,8 @@ module Ci build_id: build.id, state_crc32: checksum.state_crc32, chunks_crc32: checksum.chunks_crc32, - chunks_count: checksum.chunks_count + chunks_count: checksum.chunks_count, + chunks_corrupted: checksum.corrupted? ) end end @@ -151,13 +156,21 @@ module Ci end def has_checksum? - params.dig(:checksum).present? + trace_checksum.present? end def build_running? build_state == 'running' end + def trace_checksum + params.dig(:output, :checksum) || params.dig(:checksum) + end + + def trace_bytesize + params.dig(:output, :bytesize) + end + def pending_state strong_memoize(:pending_state) { ensure_pending_state } end @@ -166,7 +179,8 @@ module Ci build_state = Ci::BuildPendingState.safe_find_or_create_by( build_id: build.id, state: params.fetch(:state), - trace_checksum: params.fetch(:checksum), + trace_checksum: trace_checksum, + trace_bytesize: trace_bytesize, failure_reason: params.dig(:failure_reason) ) diff --git a/app/services/clusters/applications/prometheus_health_check_service.rb b/app/services/clusters/applications/prometheus_health_check_service.rb index e609d9f0b7b..eda47f56e72 100644 --- a/app/services/clusters/applications/prometheus_health_check_service.rb +++ b/app/services/clusters/applications/prometheus_health_check_service.rb @@ -63,8 +63,10 @@ module Clusters def send_notification(project) notification_payload = build_notification_payload(project) - token = project.alerts_service.data.token - Projects::Alerting::NotifyService.new(project, nil, notification_payload).execute(token) + integration = project.alert_management_http_integrations.active.first + + Projects::Alerting::NotifyService.new(project, notification_payload).execute(integration&.token, integration) + @logger.info(message: 'Successfully notified of Prometheus newly unhealthy', cluster_id: @cluster.id, project_id: project.id) end diff --git a/app/services/clusters/aws/authorize_role_service.rb b/app/services/clusters/aws/authorize_role_service.rb index 188c4aebc5f..7ca20289bf7 100644 --- a/app/services/clusters/aws/authorize_role_service.rb +++ b/app/services/clusters/aws/authorize_role_service.rb @@ -29,7 +29,7 @@ module Clusters rescue *ERRORS => e Gitlab::ErrorTracking.track_exception(e) - Response.new(:unprocessable_entity, {}) + Response.new(:unprocessable_entity, response_details(e)) end private @@ -47,6 +47,28 @@ module Clusters def credentials Clusters::Aws::FetchCredentialsService.new(role).execute end + + def response_details(exception) + message = + case exception + when ::Aws::STS::Errors::AccessDenied + _("Access denied: %{error}") % { error: exception.message } + when ::Aws::STS::Errors::ServiceError + _("AWS service error: %{error}") % { error: exception.message } + when ActiveRecord::RecordNotFound + _("Error: Unable to find AWS role for current user") + when ActiveRecord::RecordInvalid + exception.message + when Clusters::Aws::FetchCredentialsService::MissingRoleError + _("Error: No AWS provision role found for user") + when ::Aws::Errors::MissingCredentialsError + _("Error: No AWS credentials were supplied") + else + _('An error occurred while authorizing your role') + end + + { message: message }.compact + end end end end diff --git a/app/services/clusters/aws/fetch_credentials_service.rb b/app/services/clusters/aws/fetch_credentials_service.rb index 96abbb43969..497e676f549 100644 --- a/app/services/clusters/aws/fetch_credentials_service.rb +++ b/app/services/clusters/aws/fetch_credentials_service.rb @@ -30,10 +30,17 @@ module Clusters attr_reader :provider, :region def client - ::Aws::STS::Client.new(credentials: gitlab_credentials, region: region) + ::Aws::STS::Client.new(**client_args) + end + + def client_args + { region: region, credentials: gitlab_credentials }.compact end def gitlab_credentials + # These are not needed for IAM instance profiles + return unless access_key_id.present? && secret_access_key.present? + ::Aws::Credentials.new(access_key_id, secret_access_key) end diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb index a58e9aefcec..76d59cf2159 100644 --- a/app/services/concerns/exclusive_lease_guard.rb +++ b/app/services/concerns/exclusive_lease_guard.rb @@ -21,7 +21,7 @@ module ExclusiveLeaseGuard lease = exclusive_lease.try_obtain unless lease - log_error("Cannot obtain an exclusive lease for #{self.class.name}. There must be another instance already in execution.") + log_error("Cannot obtain an exclusive lease for #{lease_key}. There must be another instance already in execution.") return end diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index 4f4032e77b9..c1c93aa604e 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -8,10 +8,14 @@ module Users attr_reader :noteable end + private + def noteable_owner return [] unless noteable && noteable.author.present? - [user_as_hash(noteable.author)] + [noteable.author].tap do |users| + preload_status(users) + end end def participants_in_noteable @@ -22,23 +26,29 @@ module Users end def sorted(users) - users.uniq.to_a.compact.sort_by(&:username).map do |user| - user_as_hash(user) + users.uniq.to_a.compact.sort_by(&:username).tap do |users| + preload_status(users) end end def groups - group_counts = GroupMember - .of_groups(current_user.authorized_groups) - .non_request - .count_users_by_group_id + current_user.authorized_groups.with_route.sort_by(&:path) + end - current_user.authorized_groups.with_route.sort_by(&:path).map do |group| - group_as_hash(group, group_counts) - end + def render_participants_as_hash(participants) + participants.map(&method(:participant_as_hash)) end - private + def participant_as_hash(participant) + case participant + when Group + group_as_hash(participant) + when User + user_as_hash(participant) + else + participant + end + end def user_as_hash(user) { @@ -46,12 +56,11 @@ module Users username: user.username, name: user.name, avatar_url: user.avatar_url, - availability: nil + availability: lazy_user_availability(user).itself # calling #itself to avoid returning a BatchLoader instance } - # Return nil for availability for now due to https://gitlab.com/gitlab-org/gitlab/-/issues/285442 end - def group_as_hash(group, group_counts) + def group_as_hash(group) { type: group.class.name, username: group.full_path, @@ -61,5 +70,27 @@ module Users mentionsDisabled: group.mentions_disabled } end + + def group_counts + @group_counts ||= GroupMember + .of_groups(current_user.authorized_groups) + .non_request + .count_users_by_group_id + end + + def preload_status(users) + users.each { |u| lazy_user_availability(u) } + end + + def lazy_user_availability(user) + BatchLoader.for(user.id).batch do |user_ids, loader| + user_ids.each_slice(1_000) do |sliced_user_ids| + UserStatus + .select(:user_id, :availability) + .primary_key_in(sliced_user_ids) + .each { |status| loader.call(status.user_id, status.availability) } + end + end + end end end diff --git a/app/services/container_expiration_policies/cleanup_service.rb b/app/services/container_expiration_policies/cleanup_service.rb index f2bc2beab63..4719c99af6d 100644 --- a/app/services/container_expiration_policies/cleanup_service.rb +++ b/app/services/container_expiration_policies/cleanup_service.rb @@ -20,7 +20,8 @@ module ContainerExpirationPolicies if result[:status] == :success repository.update!( expiration_policy_cleanup_status: :cleanup_unscheduled, - expiration_policy_started_at: nil + expiration_policy_started_at: nil, + expiration_policy_completed_at: Time.zone.now ) success(:finished) else diff --git a/app/services/dependency_proxy/auth_token_service.rb b/app/services/dependency_proxy/auth_token_service.rb new file mode 100644 index 00000000000..16279ed12b0 --- /dev/null +++ b/app/services/dependency_proxy/auth_token_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module DependencyProxy + class AuthTokenService < DependencyProxy::BaseService + attr_reader :token + + def initialize(token) + @token = token + end + + def execute + JSONWebToken::HMACToken.decode(token, ::Auth::DependencyProxyAuthenticationService.secret).first + end + + class << self + def decoded_token_payload(token) + self.new(token).execute + end + end + end +end diff --git a/app/services/dependency_proxy/base_service.rb b/app/services/dependency_proxy/base_service.rb index 1b2d4b14a27..944877fd5f9 100644 --- a/app/services/dependency_proxy/base_service.rb +++ b/app/services/dependency_proxy/base_service.rb @@ -2,6 +2,16 @@ module DependencyProxy class BaseService < ::BaseService + class DownloadError < StandardError + attr_reader :http_status + + def initialize(message, http_status) + @http_status = http_status + + super(message) + end + end + private def registry diff --git a/app/services/dependency_proxy/download_blob_service.rb b/app/services/dependency_proxy/download_blob_service.rb index 3c690683bf6..b3548c8a126 100644 --- a/app/services/dependency_proxy/download_blob_service.rb +++ b/app/services/dependency_proxy/download_blob_service.rb @@ -2,16 +2,6 @@ module DependencyProxy class DownloadBlobService < DependencyProxy::BaseService - class DownloadError < StandardError - attr_reader :http_status - - def initialize(message, http_status) - @http_status = http_status - - super(message) - end - end - def initialize(image, blob_sha, token) @image = image @blob_sha = blob_sha diff --git a/app/services/dependency_proxy/find_or_create_manifest_service.rb b/app/services/dependency_proxy/find_or_create_manifest_service.rb new file mode 100644 index 00000000000..6b46f5e4c59 --- /dev/null +++ b/app/services/dependency_proxy/find_or_create_manifest_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module DependencyProxy + class FindOrCreateManifestService < DependencyProxy::BaseService + def initialize(group, image, tag, token) + @group = group + @image = image + @tag = tag + @token = token + @file_name = "#{@image}:#{@tag}.json" + @manifest = nil + end + + def execute + @manifest = @group.dependency_proxy_manifests + .find_or_initialize_by_file_name(@file_name) + + head_result = DependencyProxy::HeadManifestService.new(@image, @tag, @token).execute + + return success(manifest: @manifest) if cached_manifest_matches?(head_result) + + pull_new_manifest + respond + rescue Timeout::Error, *Gitlab::HTTP::HTTP_ERRORS + respond + end + + private + + def pull_new_manifest + DependencyProxy::PullManifestService.new(@image, @tag, @token).execute_with_manifest do |new_manifest| + @manifest.update!( + digest: new_manifest[:digest], + file: new_manifest[:file], + size: new_manifest[:file].size + ) + end + end + + def cached_manifest_matches?(head_result) + @manifest && @manifest.digest == head_result[:digest] + end + + def respond + if @manifest.persisted? + success(manifest: @manifest) + else + error('Failed to download the manifest from the external registry', 503) + end + end + end +end diff --git a/app/services/dependency_proxy/head_manifest_service.rb b/app/services/dependency_proxy/head_manifest_service.rb new file mode 100644 index 00000000000..87d9c417c98 --- /dev/null +++ b/app/services/dependency_proxy/head_manifest_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module DependencyProxy + class HeadManifestService < DependencyProxy::BaseService + def initialize(image, tag, token) + @image = image + @tag = tag + @token = token + end + + def execute + response = Gitlab::HTTP.head(manifest_url, headers: auth_headers) + + if response.success? + success(digest: response.headers['docker-content-digest']) + else + error(response.body, response.code) + end + rescue Timeout::Error => exception + error(exception.message, 599) + end + + private + + def manifest_url + registry.manifest_url(@image, @tag) + end + end +end diff --git a/app/services/dependency_proxy/pull_manifest_service.rb b/app/services/dependency_proxy/pull_manifest_service.rb index fc54ef85c96..5c804489fd1 100644 --- a/app/services/dependency_proxy/pull_manifest_service.rb +++ b/app/services/dependency_proxy/pull_manifest_service.rb @@ -8,13 +8,25 @@ module DependencyProxy @token = token end - def execute + def execute_with_manifest + raise ArgumentError, 'Block must be provided' unless block_given? + response = Gitlab::HTTP.get(manifest_url, headers: auth_headers) if response.success? - success(manifest: response.body) + file = Tempfile.new + + begin + file.write(response) + file.flush + + yield(success(file: file, digest: response.headers['docker-content-digest'])) + ensure + file.close + file.unlink + end else - error(response.body, response.code) + yield(error(response.body, response.code)) end rescue Timeout::Error => exception error(exception.message, 599) diff --git a/app/services/environments/canary_ingress/update_service.rb b/app/services/environments/canary_ingress/update_service.rb new file mode 100644 index 00000000000..474c3de23d9 --- /dev/null +++ b/app/services/environments/canary_ingress/update_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Environments + module CanaryIngress + class UpdateService < ::BaseService + def execute_async(environment) + result = validate(environment) + + return result unless result[:status] == :success + + Environments::CanaryIngress::UpdateWorker.perform_async(environment.id, params) + + success + end + + # This method actually executes the PATCH request to Kubernetes, + # that is used by internal processes i.e. sidekiq worker. + # You should always use `execute_async` to properly validate user's requests. + def execute(environment) + canary_ingress = environment.ingresses&.find(&:canary?) + + unless canary_ingress.present? + return error(_('Canary Ingress does not exist in the environment.')) + end + + if environment.patch_ingress(canary_ingress, patch_data) + success + else + error(_('Failed to update the Canary Ingress.'), :bad_request) + end + end + + private + + def validate(environment) + unless Feature.enabled?(:canary_ingress_weight_control, environment.project, default_enabled: true) + return error(_("Feature flag is not enabled on the environment's project.")) + end + + unless can?(current_user, :update_environment, environment) + return error(_('You do not have permission to update the environment.')) + end + + unless params[:weight].is_a?(Integer) && (0..100).cover?(params[:weight]) + return error(_('Canary weight must be specified and valid range (0..100).')) + end + + if environment.has_running_deployments? + return error(_('There are running deployments on the environment. Please retry later.')) + end + + if ::Gitlab::ApplicationRateLimiter.throttled?(:update_environment_canary_ingress, scope: [environment]) + return error(_("This environment's canary ingress has been updated recently. Please retry later.")) + end + + success + end + + def patch_data + { + metadata: { + annotations: { + Gitlab::Kubernetes::Ingress::ANNOTATION_KEY_CANARY_WEIGHT => params[:weight].to_s + } + } + } + end + end + end +end diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb index b4ca90f7aae..de3a55d10fc 100644 --- a/app/services/feature_flags/create_service.rb +++ b/app/services/feature_flags/create_service.rb @@ -5,7 +5,6 @@ module FeatureFlags def execute return error('Access Denied', 403) unless can_create? return error('Version is invalid', :bad_request) unless valid_version? - return error('New version feature flags are not enabled for this project', :bad_request) unless flag_version_enabled? ActiveRecord::Base.transaction do feature_flag = project.operations_feature_flags.new(params) @@ -40,13 +39,5 @@ module FeatureFlags def valid_version? !params.key?(:version) || Operations::FeatureFlag.versions.key?(params[:version]) end - - def flag_version_enabled? - params[:version] != 'new_version_flag' || new_version_feature_flags_enabled? - end - - def new_version_feature_flags_enabled? - ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true) - end end end diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index ea5b2f401b3..1ca1bfa0c05 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -135,11 +135,12 @@ module Git # We only need the last commit for the event push, and we don't # need the full deltas either. @event_push_data ||= Gitlab::DataBuilder::Push.build( - push_data_params(commits: commits.last, with_changed_files: false)) + **push_data_params(commits: commits.last, with_changed_files: false) + ) end def push_data - @push_data ||= Gitlab::DataBuilder::Push.build(push_data_params(commits: limited_commits)) + @push_data ||= Gitlab::DataBuilder::Push.build(**push_data_params(commits: limited_commits)) # Dependent code may modify the push data, so return a duplicate each time @push_data.dup diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index d00ca83441a..4edcff0e3d0 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -118,7 +118,7 @@ module Git commits_to_sync = limited_commits.select { |commit| Atlassian::JiraIssueKeyExtractor.has_keys?(commit.safe_message) }.map(&:sha) if branch_to_sync || commits_to_sync.any? - JiraConnect::SyncBranchWorker.perform_async(project.id, branch_to_sync, commits_to_sync) + JiraConnect::SyncBranchWorker.perform_async(project.id, branch_to_sync, commits_to_sync, Atlassian::JiraConnect::Client.generate_update_sequence_id) end end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 016c31cbccc..52600f5b88f 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -34,7 +34,7 @@ module Groups if @group.save @group.add_owner(current_user) @group.create_namespace_settings - Service.create_from_active_default_integrations(@group, :group_id) if Feature.enabled?(:group_level_integrations, default_enabled: true) + Service.create_from_active_default_integrations(@group, :group_id) end end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index aad574aeaf5..e800e546a45 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -28,9 +28,11 @@ module Groups Group.transaction do update_group_attributes ensure_ownership + update_integrations end post_update_hooks(@updated_project_ids) + propagate_integrations true end @@ -196,6 +198,17 @@ module Groups raise TransferError, result[:message] unless result[:status] == :success end end + + def update_integrations + @group.services.inherit.delete_all + Service.create_from_active_default_integrations(@group, :group_id) + end + + def propagate_integrations + @group.services.inherit.each do |integration| + PropagateIntegrationWorker.perform_async(integration.id) + end + end end end diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb index 86e8215821e..cdb23370ddc 100644 --- a/app/services/import/bitbucket_server_service.rb +++ b/app/services/import/bitbucket_server_service.rb @@ -81,11 +81,9 @@ module Import def blocked_url? Gitlab::UrlBlocker.blocked_url?( url, - { - allow_localhost: allow_local_requests?, - allow_local_network: allow_local_requests?, - schemes: %w(http https) - } + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + schemes: %w(http https) ) end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index 948dba2d206..847c5eb4397 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -31,9 +31,8 @@ module Import project_name, target_namespace, current_user, - access_params, - type: provider - ).execute(extra_project_attrs) + type: provider, + **access_params).execute(extra_project_attrs) end def repo @@ -71,11 +70,9 @@ module Import def blocked_url? Gitlab::UrlBlocker.blocked_url?( url, - { - allow_localhost: allow_local_requests?, - allow_local_network: allow_local_requests?, - schemes: %w(http https) - } + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + schemes: %w(http https) ) end diff --git a/app/services/incident_management/incidents/update_severity_service.rb b/app/services/incident_management/incidents/update_severity_service.rb index 5b150f3f02e..faa9277c469 100644 --- a/app/services/incident_management/incidents/update_severity_service.rb +++ b/app/services/incident_management/incidents/update_severity_service.rb @@ -12,7 +12,7 @@ module IncidentManagement end def execute - return unless issuable.incident? + return unless issuable.supports_severity? update_severity! add_system_note diff --git a/app/services/integrations/test/project_service.rb b/app/services/integrations/test/project_service.rb index 39471d373f9..d72ca928c34 100644 --- a/app/services/integrations/test/project_service.rb +++ b/app/services/integrations/test/project_service.rb @@ -16,9 +16,7 @@ module Integrations def data strong_memoize(:data) do - next pipeline_events_data if integration.is_a?(::PipelinesEmailService) - - case event + case event || integration.default_test_event when 'push', 'tag_push' push_events_data when 'note', 'confidential_note' @@ -37,8 +35,6 @@ module Integrations deployment_events_data when 'release' releases_events_data - else - push_events_data end end end diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb index bf5f643a51b..5a2665285de 100644 --- a/app/services/issuable/import_csv/base_service.rb +++ b/app/services/issuable/import_csv/base_service.rb @@ -38,20 +38,19 @@ module Issuable def with_csv_lines csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8) - verify_headers!(csv_data) + validate_headers_presence!(csv_data.lines.first) - csv_parsing_params = { + CSV.new( + csv_data, col_sep: detect_col_sep(csv_data.lines.first), headers: true, header_converters: :symbol - } - - CSV.new(csv_data, csv_parsing_params).each.with_index(2) + ).each.with_index(2) end - def verify_headers!(data) - headers = data.lines.first.downcase - return if headers.include?('title') && headers.include?('description') + def validate_headers_presence!(headers) + headers.downcase! if headers + return if headers && headers.include?('title') && headers.include?('description') raise CSV::MalformedCSVError end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 978ea6fe9bc..25f319da03b 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -73,22 +73,6 @@ module Issues Milestones::IssuesCountService.new(milestone).delete_cache end - - # Applies label "incident" (creates it if missing) to incident issues. - # Please use in "after" hooks only to ensure we are not appyling - # labels prematurely. - def add_incident_label(issue) - return unless issue.incident? - - label = ::IncidentManagement::CreateIncidentLabelService - .new(project, current_user) - .execute - .payload[:label] - - return if issue.label_ids.include?(label.id) - - issue.labels << label - end end end diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb new file mode 100644 index 00000000000..789da312958 --- /dev/null +++ b/app/services/issues/clone_service.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Issues + class CloneService < Issuable::Clone::BaseService + CloneError = Class.new(StandardError) + + def execute(issue, target_project, with_notes: false) + @target_project = target_project + @with_notes = with_notes + + unless issue.can_clone?(current_user, target_project) + raise CloneError, s_('CloneIssue|Cannot clone issue due to insufficient permissions!') + end + + if target_project.pending_delete? + raise CloneError, s_('CloneIssue|Cannot clone issue to target project as it is pending deletion.') + end + + super(issue, target_project) + + notify_participants + + queue_copy_designs + + new_entity + end + + private + + attr_reader :target_project + attr_reader :with_notes + + def update_new_entity + # we don't call `super` because we want to be able to decide whether or not to copy all comments over. + update_new_entity_description + update_new_entity_attributes + copy_award_emoji + copy_notes if with_notes + end + + def update_old_entity + # no-op + # The base_service closes the old issue, we don't want that, so we override here so nothing happens. + end + + def create_new_entity + new_params = { + id: nil, + iid: nil, + project: target_project, + author: current_user, + assignee_ids: original_entity.assignee_ids + } + + new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params) + + # Skip creation of system notes for existing attributes of the issue. The system notes of the old + # issue are copied over so we don't want to end up with duplicate notes. + CreateService.new(target_project, current_user, new_params).execute(skip_system_notes: true) + end + + def queue_copy_designs + return unless original_entity.designs.present? + + response = DesignManagement::CopyDesignCollection::QueueService.new( + current_user, + original_entity, + new_entity + ).execute + + log_error(response.message) if response.error? + end + + def notify_participants + notification_service.async.issue_cloned(original_entity, new_entity, current_user) + end + + def add_note_from + SystemNoteService.noteable_cloned(new_entity, target_project, + original_entity, current_user, + direction: :from) + end + + def add_note_to + SystemNoteService.noteable_cloned(original_entity, old_project, + new_entity, current_user, + direction: :to) + end + end +end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index fb7683f940d..44de8eb6389 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -49,6 +49,22 @@ module Issues def user_agent_detail_service UserAgentDetailService.new(@issue, @request) end + + # Applies label "incident" (creates it if missing) to incident issues. + # For use in "after" hooks only to ensure we are not appyling + # labels prematurely. + def add_incident_label(issue) + return unless issue.incident? + + label = ::IncidentManagement::CreateIncidentLabelService + .new(project, current_user) + .execute + .payload[:label] + + return if issue.label_ids.include?(label.id) + + issue.labels << label + end end end diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb index 1dcdfb9faea..8f513632929 100644 --- a/app/services/issues/export_csv_service.rb +++ b/app/services/issues/export_csv_service.rb @@ -34,7 +34,7 @@ module Issues private def associations_to_preload - %i(author assignees timelogs) + %i(author assignees timelogs milestone) end def header_to_value_hash diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b9832400302..127ed04cf51 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -9,7 +9,7 @@ module Issues handle_move_between_ids(issue) filter_spam_check_params change_issue_duplicate(issue) - move_issue_to_new_project(issue) || update_task_event(issue) || update(issue) + move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue) end def update(issue) @@ -34,7 +34,6 @@ module Issues end def after_update(issue) - add_incident_label(issue) IssuesChannel.broadcast_to(issue, event: 'updated') if Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:broadcast_issue_updates, issue.project) end @@ -127,6 +126,18 @@ module Issues private + def clone_issue(issue) + target_project = params.delete(:target_clone_project) + with_notes = params.delete(:clone_with_notes) + + return unless target_project && + issue.can_clone?(current_user, target_project) + + # we've pre-empted this from running in #execute, so let's go ahead and update the Issue now. + update(issue) + Issues::CloneService.new(project, current_user).execute(issue, target_project, with_notes: with_notes) + end + def create_merge_request_from_quick_action create_merge_request_params = params.delete(:create_merge_request) return unless create_merge_request_params diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb index 4ed8df0f235..098aae9284c 100644 --- a/app/services/jira/requests/base.rb +++ b/app/services/jira/requests/base.rb @@ -18,14 +18,19 @@ module Jira request end + # We have to add the context_path here because the Jira client is not taking it into account def base_api_url - "/rest/api/#{api_version}" + "#{context_path}/rest/api/#{api_version}" end private attr_reader :jira_service, :project + def context_path + client.options[:context_path].to_s + end + # override this method in the specific request class implementation if a differnt API version is required def api_version JIRA_API_VERSION diff --git a/app/services/jira_connect/sync_service.rb b/app/services/jira_connect/sync_service.rb index f8855fb6deb..b2af284f1f0 100644 --- a/app/services/jira_connect/sync_service.rb +++ b/app/services/jira_connect/sync_service.rb @@ -6,13 +6,15 @@ module JiraConnect self.project = project end - def execute(commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil) - JiraConnectInstallation.for_project(project).each do |installation| + # Parameters: see Atlassian::JiraConnect::Client#send_info + # Includes: update_sequence_id, commits, branches, merge_requests, pipelines + def execute(**args) + JiraConnectInstallation.for_project(project).flat_map do |installation| client = Atlassian::JiraConnect::Client.new(installation.base_url, installation.shared_secret) - response = client.store_dev_info(project: project, commits: commits, branches: branches, merge_requests: merge_requests, update_sequence_id: update_sequence_id) + responses = client.send_info(project: project, **args) - log_response(response) + responses.each { |r| log_response(r) } end end @@ -29,7 +31,7 @@ module JiraConnect jira_response: response&.to_json } - if response && response['errorMessages'] + if response && (response['errorMessages'] || response['rejectedBuilds'].present?) logger.error(message) else logger.info(message) diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 088e6f031c8..3588cda180f 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -38,6 +38,8 @@ module Members end end + enqueue_onboarding_progress_action(source) if members.size > errors.size + return success unless errors.any? error(errors.to_sentence) @@ -50,6 +52,10 @@ module Members limit && limit < 0 ? nil : limit end + + def enqueue_onboarding_progress_action(source) + Namespaces::OnboardingUserAddedWorker.perform_async(source.id) + end end end diff --git a/app/services/members/invitation_reminder_email_service.rb b/app/services/members/invitation_reminder_email_service.rb index e589cdc2fa3..688618ec4b4 100644 --- a/app/services/members/invitation_reminder_email_service.rb +++ b/app/services/members/invitation_reminder_email_service.rb @@ -14,8 +14,6 @@ module Members end def execute - return unless experiment_enabled? - reminder_index = days_on_which_to_send_reminders.index(days_after_invitation_sent) return unless reminder_index @@ -24,10 +22,6 @@ module Members private - def experiment_enabled? - Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, invitation.invite_email) - end - def days_after_invitation_sent (Date.today - invitation.created_at.to_date).to_i end diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index f0c85ae03c9..fbb9d5fa9dc 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -11,6 +11,8 @@ module MergeRequests merge_request.diffs(include_stats: false).write_cache merge_request.create_cross_references!(current_user) + + NamespaceOnboardingAction.create_action(merge_request.target_project.namespace, :merge_request_created) end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index aa591312c6a..265b211066e 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -58,7 +58,7 @@ module MergeRequests return unless project.jira_subscription_exists? if Atlassian::JiraIssueKeyExtractor.has_keys?(merge_request.title, merge_request.description) - JiraConnect::SyncMergeRequestWorker.perform_async(merge_request.id) + JiraConnect::SyncMergeRequestWorker.perform_async(merge_request.id, Atlassian::JiraConnect::Client.generate_update_sequence_id) end end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 8c069ea5bb0..bff7a43dd7b 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -11,7 +11,7 @@ module MergeRequests params.delete(:target_project_id) params.delete(:source_branch) - if merge_request.closed_without_fork? + if merge_request.closed_or_merged_without_fork? params.delete(:target_branch) params.delete(:force_remove_source_branch) end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index b2826b5c905..9fffb6c372b 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -67,7 +67,7 @@ module Notes track_event(note, current_user) if Feature.enabled?(:notes_create_service_tracking, project) - Gitlab::Tracking.event('Notes::CreateService', 'execute', tracking_data_for(note)) + Gitlab::Tracking.event('Notes::CreateService', 'execute', **tracking_data_for(note)) end if note.for_merge_request? && note.diff_note? && note.start_of_discussion? diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 85113d3ca22..4ff462191fe 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -353,7 +353,7 @@ class NotificationService issue = note.noteable support_bot = User.support_bot - return unless issue.service_desk_reply_to.present? + return unless issue.external_author.present? return unless issue.project.service_desk_enabled? return if note.author == support_bot return unless issue.subscribed?(support_bot, issue.project) @@ -380,6 +380,10 @@ class NotificationService end end + def user_admin_rejection(name, email) + mailer.user_admin_rejection_email(name, email).deliver_later + end + # Members def new_access_request(member) return true unless member.notifiable?(:subscription) @@ -500,6 +504,16 @@ class NotificationService end end + def issue_cloned(issue, new_issue, current_user) + recipients = NotificationRecipients::BuildService.build_recipients(issue, current_user, action: 'cloned') + + recipients.map do |recipient| + email = mailer.issue_cloned_email(recipient.user, issue, new_issue, current_user, recipient.reason) + email.deliver_later + email + end + end + def project_exported(project, current_user) return true unless notifiable?(current_user, :mention, project: project) diff --git a/app/services/onboarding_progress_service.rb b/app/services/onboarding_progress_service.rb new file mode 100644 index 00000000000..ebe7caabdef --- /dev/null +++ b/app/services/onboarding_progress_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class OnboardingProgressService + def initialize(namespace) + @namespace = namespace.root_ancestor + end + + def execute(action:) + NamespaceOnboardingAction.create_action(@namespace, action) + end +end diff --git a/app/services/packages/composer/create_package_service.rb b/app/services/packages/composer/create_package_service.rb index 2d2f1568187..0f5429f667e 100644 --- a/app/services/packages/composer/create_package_service.rb +++ b/app/services/packages/composer/create_package_service.rb @@ -16,6 +16,8 @@ module Packages composer_json: composer_json }) end + + created_package end private diff --git a/app/services/packages/conan/create_package_file_service.rb b/app/services/packages/conan/create_package_file_service.rb index 2db5c4e507b..1bde9606492 100644 --- a/app/services/packages/conan/create_package_file_service.rb +++ b/app/services/packages/conan/create_package_file_service.rb @@ -12,7 +12,7 @@ module Packages end def execute - package.package_files.create!( + package_file = package.package_files.build( file: file, size: params['file.size'], file_name: params[:file_name], @@ -25,6 +25,13 @@ module Packages conan_file_type: params[:conan_file_type] } ) + + if params[:build].present? + package_file.package_file_build_infos << package_file.package_file_build_infos.build(pipeline: params[:build].pipeline) + end + + package_file.save! + package_file end end end diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb index c4492389da9..f0328ceb08a 100644 --- a/app/services/packages/create_event_service.rb +++ b/app/services/packages/create_event_service.rb @@ -4,7 +4,11 @@ module Packages class CreateEventService < BaseService def execute if Feature.enabled?(:collect_package_events_redis) && redis_event_name - ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event(current_user.id, redis_event_name) + if guest? + ::Gitlab::UsageDataCounters::GuestPackageEventCounter.count(redis_event_name) + else + ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event(current_user.id, redis_event_name) + end end if Feature.enabled?(:collect_package_events) && Gitlab::Database.read_write? @@ -45,5 +49,9 @@ module Packages :guest end end + + def guest? + originator_type == :guest + end end end diff --git a/app/services/packages/create_package_service.rb b/app/services/packages/create_package_service.rb index e3b0ad218e2..fcf252cf971 100644 --- a/app/services/packages/create_package_service.rb +++ b/app/services/packages/create_package_service.rb @@ -8,9 +8,9 @@ module Packages project .packages .with_package_type(package_type) - .safe_find_or_create_by!(name: name, version: version) do |pkg| - pkg.creator = package_creator - yield pkg if block_given? + .safe_find_or_create_by!(name: name, version: version) do |package| + package.creator = package_creator + add_build_info(package) end end @@ -18,7 +18,9 @@ module Packages project .packages .with_package_type(package_type) - .create!(package_attrs(attrs)) + .create!(package_attrs(attrs)) do |package| + add_build_info(package) + end end private @@ -34,5 +36,11 @@ module Packages def package_creator current_user if current_user.is_a?(User) end + + def add_build_info(package) + if params[:build].present? + package.build_infos.new(pipeline: params[:build].pipeline) + end + end end end diff --git a/app/services/packages/generic/create_package_file_service.rb b/app/services/packages/generic/create_package_file_service.rb index f25e8b0ae56..b14b1c193ec 100644 --- a/app/services/packages/generic/create_package_file_service.rb +++ b/app/services/packages/generic/create_package_file_service.rb @@ -18,9 +18,12 @@ module Packages build: params[:build] } - ::Packages::Generic::FindOrCreatePackageService + package = ::Packages::Generic::FindOrCreatePackageService .new(project, current_user, package_params) .execute + + package.build_infos.safe_find_or_create_by!(pipeline: params[:build].pipeline) if params[:build].present? + package end def create_package_file(package) diff --git a/app/services/packages/generic/find_or_create_package_service.rb b/app/services/packages/generic/find_or_create_package_service.rb index 97f774a836b..0a6099e4d35 100644 --- a/app/services/packages/generic/find_or_create_package_service.rb +++ b/app/services/packages/generic/find_or_create_package_service.rb @@ -4,11 +4,7 @@ module Packages module Generic class FindOrCreatePackageService < ::Packages::CreatePackageService def execute - find_or_create_package!(::Packages::Package.package_types['generic']) do |package| - if params[:build].present? - package.build_infos.new(pipeline: params[:build].pipeline) - end - end + find_or_create_package!(::Packages::Package.package_types['generic']) end end end diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb index a2a61ff8d93..f598b5e7cd4 100644 --- a/app/services/packages/maven/find_or_create_package_service.rb +++ b/app/services/packages/maven/find_or_create_package_service.rb @@ -46,7 +46,7 @@ module Packages .execute end - package.build_infos.create!(pipeline: params[:build].pipeline) if params[:build].present? + package.build_infos.safe_find_or_create_by!(pipeline: params[:build].pipeline) if params[:build].present? package end diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb index c4b75348bba..22396eb7687 100644 --- a/app/services/packages/npm/create_package_service.rb +++ b/app/services/packages/npm/create_package_service.rb @@ -17,10 +17,6 @@ module Packages def create_npm_package! package = create_package!(:npm, name: name, version: version) - if build.present? - package.build_infos.create!(pipeline: build.pipeline) - end - ::Packages::CreatePackageFileService.new(package, file_params).execute ::Packages::CreateDependencyService.new(package, package_dependencies).execute ::Packages::Npm::CreateTagService.new(package, dist_tag).execute @@ -50,10 +46,6 @@ module Packages params[:versions][version] end - def build - params[:build] - end - def dist_tag params['dist-tags'].each_key.first end diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb index c49efca0fc5..cb8d9559dc9 100644 --- a/app/services/packages/pypi/create_package_service.rb +++ b/app/services/packages/pypi/create_package_service.rb @@ -19,6 +19,8 @@ module Packages Packages::Pypi::Metadatum.upsert(meta.attributes) ::Packages::CreatePackageFileService.new(created_package, file_params).execute + + created_package end end @@ -32,6 +34,7 @@ module Packages def file_params { + build: params[:build], file: params[:content], file_name: params[:content].original_filename, file_md5: params[:md5_digest], diff --git a/app/services/pages/legacy_storage_lease.rb b/app/services/pages/legacy_storage_lease.rb new file mode 100644 index 00000000000..3f42fc8c63b --- /dev/null +++ b/app/services/pages/legacy_storage_lease.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Pages + module LegacyStorageLease + extend ActiveSupport::Concern + + include ::ExclusiveLeaseGuard + + LEASE_TIMEOUT = 1.hour + + # override method from exclusive lease guard to guard it by feature flag + # TODO: just remove this method after testing this in production + # https://gitlab.com/gitlab-org/gitlab/-/issues/282464 + def try_obtain_lease + return yield unless Feature.enabled?(:pages_use_legacy_storage_lease, project, default_enabled: true) + + super + end + + def lease_key + "pages_legacy_storage:#{project.id}" + end + + def lease_timeout + LEASE_TIMEOUT + end + end +end diff --git a/app/services/pages/zip_directory_service.rb b/app/services/pages/zip_directory_service.rb new file mode 100644 index 00000000000..a27ad5fda46 --- /dev/null +++ b/app/services/pages/zip_directory_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Pages + class ZipDirectoryService + InvalidArchiveError = Class.new(RuntimeError) + InvalidEntryError = Class.new(RuntimeError) + + PUBLIC_DIR = 'public' + + def initialize(input_dir) + @input_dir = File.realpath(input_dir) + @output_file = File.join(@input_dir, "@migrated.zip") # '@' to avoid any name collision with groups or projects + end + + def execute + FileUtils.rm_f(@output_file) + + count = 0 + ::Zip::File.open(@output_file, ::Zip::File::CREATE) do |zipfile| + write_entry(zipfile, PUBLIC_DIR) + count = zipfile.entries.count + end + + [@output_file, count] + end + + private + + def write_entry(zipfile, zipfile_path) + disk_file_path = File.join(@input_dir, zipfile_path) + + unless valid_path?(disk_file_path) + # archive without public directory is completelly unusable + raise InvalidArchiveError if zipfile_path == PUBLIC_DIR + + # archive with invalid entry will just have this entry missing + raise InvalidEntryError + end + + case File.lstat(disk_file_path).ftype + when 'directory' + recursively_zip_directory(zipfile, disk_file_path, zipfile_path) + when 'file', 'link' + zipfile.add(zipfile_path, disk_file_path) + else + raise InvalidEntryError + end + rescue InvalidEntryError => e + Gitlab::ErrorTracking.track_exception(e, input_dir: @input_dir, disk_file_path: disk_file_path) + end + + def recursively_zip_directory(zipfile, disk_file_path, zipfile_path) + zipfile.mkdir(zipfile_path) + + entries = Dir.entries(disk_file_path) - %w[. ..] + entries = entries.map { |entry| File.join(zipfile_path, entry) } + + write_entries(zipfile, entries) + end + + def write_entries(zipfile, entries) + entries.each do |zipfile_path| + write_entry(zipfile, zipfile_path) + end + end + + # that should never happen, but we want to be safer + # in theory without this we would allow to use symlinks + # to pack any directory on disk + # it isn't possible because SafeZip doesn't extract such archives + def valid_path?(disk_file_path) + realpath = File.realpath(disk_file_path) + + realpath == File.join(@input_dir, PUBLIC_DIR) || + realpath.start_with?(File.join(@input_dir, PUBLIC_DIR + "/")) + # happens if target of symlink isn't there + rescue => e + Gitlab::ErrorTracking.track_exception(e, input_dir: @input_dir, disk_file_path: disk_file_path) + + false + end + end +end diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb index 79b613f6a88..bd9588844ad 100644 --- a/app/services/post_receive_service.rb +++ b/app/services/post_receive_service.rb @@ -40,6 +40,8 @@ class PostReceiveService response.add_basic_message(redirect_message) response.add_basic_message(project_created_message) + + record_onboarding_progress end response @@ -90,6 +92,10 @@ class PostReceiveService banner&.message end + + def record_onboarding_progress + NamespaceOnboardingAction.create_action(project.namespace, :git_write) + end end PostReceiveService.prepend_if_ee('EE::PostReceiveService') diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index ab8f53a3757..014fb0e3ed3 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -2,10 +2,16 @@ module Projects module Alerting - class NotifyService < BaseService + class NotifyService + include BaseServiceUtility include Gitlab::Utils::StrongMemoize include ::IncidentManagement::Settings + def initialize(project, payload) + @project = project + @payload = payload + end + def execute(token, integration = nil) @integration = integration @@ -24,7 +30,7 @@ module Projects private - attr_reader :integration + attr_reader :project, :payload, :integration def process_alert if alert.persisted? @@ -101,7 +107,7 @@ module Projects def incoming_payload strong_memoize(:incoming_payload) do - Gitlab::AlertManagement::Payload.parse(project, params.to_h) + Gitlab::AlertManagement::Payload.parse(project, payload.to_h) end end @@ -110,7 +116,7 @@ module Projects end def valid_payload_size? - Gitlab::Utils::DeepSize.new(params).valid? + Gitlab::Utils::DeepSize.new(payload).valid? end def active_integration? diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb index 505ddaf50e3..410cf6c624e 100644 --- a/app/services/projects/container_repository/delete_tags_service.rb +++ b/app/services/projects/container_repository/delete_tags_service.rb @@ -36,6 +36,7 @@ module Projects def log_response(response) log_data = LOG_DATA_BASE.merge( container_repository_id: @container_repository.id, + project_id: @container_repository.project_id, message: 'deleted tags', deleted_tags_count: response[:deleted]&.size ).compact diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 1cd81fe37c7..228115d72b8 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -14,7 +14,7 @@ module Projects groups + project_members - participants.uniq + render_participants_as_hash(participants.uniq) end def project_members diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index 8ad4f59373d..93165a58470 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -3,7 +3,7 @@ module Projects module Prometheus module Alerts - class NotifyService < BaseService + class NotifyService include Gitlab::Utils::StrongMemoize include ::IncidentManagement::Settings @@ -17,28 +17,35 @@ module Projects SUPPORTED_VERSION = '4' - def execute(token, _integration = nil) + def initialize(project, payload) + @project = project + @payload = payload + end + + def execute(token, integration = nil) return bad_request unless valid_payload_size? - return unprocessable_entity unless self.class.processable?(params) - return unauthorized unless valid_alert_manager_token?(token) + return unprocessable_entity unless self.class.processable?(payload) + return unauthorized unless valid_alert_manager_token?(token, integration) process_prometheus_alerts ServiceResponse.success end - def self.processable?(params) + def self.processable?(payload) # Workaround for https://gitlab.com/gitlab-org/gitlab/-/issues/220496 - return false unless params + return false unless payload - REQUIRED_PAYLOAD_KEYS.subset?(params.keys.to_set) && - params['version'] == SUPPORTED_VERSION + REQUIRED_PAYLOAD_KEYS.subset?(payload.keys.to_set) && + payload['version'] == SUPPORTED_VERSION end private + attr_reader :project, :payload + def valid_payload_size? - Gitlab::Utils::DeepSize.new(params).valid? + Gitlab::Utils::DeepSize.new(payload).valid? end def firings @@ -50,12 +57,12 @@ module Projects end def alerts - params['alerts'] + payload['alerts'] end - def valid_alert_manager_token?(token) + def valid_alert_manager_token?(token, integration) valid_for_manual?(token) || - valid_for_alerts_endpoint?(token) || + valid_for_alerts_endpoint?(token, integration) || valid_for_managed?(token) end @@ -70,11 +77,10 @@ module Projects end end - def valid_for_alerts_endpoint?(token) - return false unless project.alerts_service_activated? + def valid_for_alerts_endpoint?(token, integration) + return false unless integration&.active? - # Here we are enforcing the existence of the token - compare_token(token, project.alerts_service.token) + compare_token(token, integration.token) end def valid_for_managed?(token) @@ -122,7 +128,7 @@ module Projects def process_prometheus_alerts alerts.each do |alert| AlertManagement::ProcessPrometheusAlertService - .new(project, nil, alert.to_h) + .new(project, alert.to_h) .execute end end diff --git a/app/services/projects/schedule_bulk_repository_shard_moves_service.rb b/app/services/projects/schedule_bulk_repository_shard_moves_service.rb new file mode 100644 index 00000000000..dd49910207f --- /dev/null +++ b/app/services/projects/schedule_bulk_repository_shard_moves_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Projects + # Tries to schedule a move for every project with repositories on the source shard + class ScheduleBulkRepositoryShardMovesService + include BaseServiceUtility + + def execute(source_storage_name, destination_storage_name = nil) + shard = Shard.find_by_name!(source_storage_name) + + ProjectRepository.for_shard(shard).each_batch(column: :project_id) do |relation| + Project.id_in(relation.select(:project_id)).each do |project| + project.with_lock do + next if project.repository_storage != source_storage_name + + storage_move = project.repository_storage_moves.build( + source_storage_name: source_storage_name, + destination_storage_name: destination_storage_name + ) + + unless storage_move.schedule + log_info("Project #{project.full_path} (#{project.id}) was skipped: #{storage_move.errors.full_messages.to_sentence}") + end + end + end + end + + success + end + + def self.enqueue(source_storage_name, destination_storage_name = nil) + ::ProjectScheduleBulkRepositoryShardMovesWorker.perform_async(source_storage_name, destination_storage_name) + end + end +end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 5178c76f0fc..1574c90d2ac 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -59,7 +59,7 @@ module Projects raise TransferError.new(s_("TransferProject|Root namespace can't be updated if project has NPM packages")) end - attempt_transfer_transaction + proceed_to_transfer end # rubocop: enable CodeReuse/ActiveRecord @@ -67,7 +67,7 @@ module Projects new_namespace.root_ancestor == project.namespace.root_ancestor end - def attempt_transfer_transaction + def proceed_to_transfer Project.transaction do project.expire_caches_before_rename(@old_path) @@ -87,6 +87,8 @@ module Projects # Move uploads move_project_uploads(project) + update_integrations + project.old_path_with_namespace = @old_path update_repository_configuration(@new_path) @@ -214,6 +216,11 @@ module Projects project.shared_runners_enabled = false end end + + def update_integrations + project.services.inherit.delete_all + Service.create_from_active_default_integrations(project, :project_id) + end end end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index b9c579a130f..53872c67f49 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -4,6 +4,9 @@ module Projects class UpdatePagesService < BaseService InvalidStateError = Class.new(StandardError) FailedToExtractError = Class.new(StandardError) + ExclusiveLeaseTaken = Class.new(StandardError) + + include ::Pages::LegacyStorageLease BLOCK_SIZE = 32.kilobytes PUBLIC_DIR = 'public' @@ -109,6 +112,17 @@ module Projects end def deploy_page!(archive_public_path) + deployed = try_obtain_lease do + deploy_page_unsafe!(archive_public_path) + true + end + + unless deployed + raise ExclusiveLeaseTaken, "Failed to deploy pages - other deployment is in progress" + end + end + + def deploy_page_unsafe!(archive_public_path) # Do atomic move of pages # Move and removal may not be atomic, but they are significantly faster then extracting and removal # 1. We move deployed public to previous public path (file removal is slow) @@ -125,8 +139,6 @@ module Projects end def create_pages_deployment(artifacts_path, build) - return unless Feature.enabled?(:zip_pages_deployments, project, default_enabled: true) - # we're using the full archive and pages daemon needs to read it # so we want the total count from entries, not only "public/" directory # because it better approximates work we need to do before we can serve the site diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb index 38ef80ced56..d0e1577bd8d 100644 --- a/app/services/releases/base_service.rb +++ b/app/services/releases/base_service.rb @@ -11,8 +11,6 @@ module Releases @project, @current_user, @params = project, user, params.dup end - delegate :repository, to: :project - def tag_name params[:tag] end @@ -39,22 +37,18 @@ module Releases end end - def existing_tag - strong_memoize(:existing_tag) do - repository.find_tag(tag_name) - end - end - - def tag_exist? - existing_tag.present? - end - def repository strong_memoize(:repository) do project.repository end end + def existing_tag + strong_memoize(:existing_tag) do + repository.find_tag(tag_name) + end + end + def milestones return [] unless param_for_milestone_titles_provided? @@ -78,7 +72,7 @@ module Releases end def param_for_milestone_titles_provided? - params.key?(:milestones) + !!params[:milestones] end def execute_hooks(release, action = 'create') diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index deefe559d5d..11fdbaf3169 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -10,7 +10,7 @@ module Releases # should be found before the creation of new tag # because tag creation can spawn new pipeline # which won't have any data for evidence yet - evidence_pipeline = find_evidence_pipeline + evidence_pipeline = Releases::EvidencePipelineFinder.new(project, params).execute tag = ensure_tag @@ -78,26 +78,10 @@ module Releases ) end - def find_evidence_pipeline - # TODO: remove this with the release creation moved to it's own form https://gitlab.com/gitlab-org/gitlab/-/issues/214245 - return params[:evidence_pipeline] if params[:evidence_pipeline] - - sha = existing_tag&.dereferenced_target&.sha - sha ||= repository.commit(ref)&.sha - - return unless sha - - project.ci_pipelines.for_sha(sha).last - end - def create_evidence!(release, pipeline) - return if release.historical_release? + return if release.historical_release? || release.upcoming_release? - if release.upcoming_release? - CreateEvidenceWorker.perform_at(release.released_at, release.id, pipeline&.id) - else - CreateEvidenceWorker.perform_async(release.id, pipeline&.id) - end + ::Releases::CreateEvidenceWorker.perform_async(release.id, pipeline&.id) end end end diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb index dc23f727079..ddf3b05ac10 100644 --- a/app/services/resource_events/change_labels_service.rb +++ b/app/services/resource_events/change_labels_service.rb @@ -24,6 +24,8 @@ module ResourceEvents Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, labels) # rubocop:disable Gitlab/BulkInsert resource.expire_note_etag_cache + + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user) if resource.is_a?(Issue) end private diff --git a/app/services/service_desk_settings/update_service.rb b/app/services/service_desk_settings/update_service.rb index c837b75f439..32d1c5c1c87 100644 --- a/app/services/service_desk_settings/update_service.rb +++ b/app/services/service_desk_settings/update_service.rb @@ -5,7 +5,7 @@ module ServiceDeskSettings def execute settings = ServiceDeskSetting.safe_find_or_create_by!(project_id: project.id) - unless ::Feature.enabled?(:service_desk_custom_address, project) + unless ::Feature.enabled?(:service_desk_custom_address, project, default_enabled: true) params.delete(:project_key) end diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb index 2fbeaf4405c..8ab1193b04f 100644 --- a/app/services/submit_usage_ping_service.rb +++ b/app/services/submit_usage_ping_service.rb @@ -43,8 +43,6 @@ class SubmitUsagePingService private def save_raw_usage_data(usage_data) - return unless Feature.enabled?(:save_raw_usage_data) - RawUsageData.safe_find_or_create_by(recorded_at: usage_data[:recorded_at]) do |record| record.payload = usage_data end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index 0d369c23b57..881a139437a 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SystemHooksService + BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember].freeze + def execute_hooks_for(model, event) data = build_event_data(model, event) @@ -20,6 +22,9 @@ class SystemHooksService private def build_event_data(model, event) + # return entire event data from its builder class, if available. + return builder_driven_event_data(model, event) if builder_driven_event_data_available?(model) + data = { event_name: build_event_name(model, event), created_at: model.created_at&.xmlschema, @@ -62,8 +67,6 @@ class SystemHooksService old_full_path: model.full_path_before_last_save ) end - when GroupMember - data.merge!(group_member_data(model)) end data @@ -75,10 +78,6 @@ class SystemHooksService return "user_add_to_team" if event == :create return "user_remove_from_team" if event == :destroy return "user_update_for_team" if event == :update - when GroupMember - return 'user_add_to_group' if event == :create - return 'user_remove_from_group' if event == :destroy - return 'user_update_for_group' if event == :update else "#{model.class.name.downcase}_#{event}" end @@ -128,19 +127,6 @@ class SystemHooksService } end - def group_member_data(model) - { - group_name: model.group.name, - group_path: model.group.path, - group_id: model.group.id, - user_username: model.user.username, - user_name: model.user.name, - user_email: model.user.email, - user_id: model.user.id, - group_access: model.human_access - } - end - def user_data(model) { name: model.name, @@ -149,6 +135,17 @@ class SystemHooksService username: model.username } end + + def builder_driven_event_data_available?(model) + model.class.in?(BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES) + end + + def builder_driven_event_data(model, event) + case model + when GroupMember + Gitlab::HookData::GroupMemberBuilder.new(model).build(event) + end + end end SystemHooksService.prepend_if_ee('EE::SystemHooksService') diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index eacc88f98a3..58f72e9badc 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -226,6 +226,10 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_moved(noteable_ref, direction) end + def noteable_cloned(noteable, project, noteable_ref, author, direction:) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_cloned(noteable_ref, direction) + end + def mark_duplicate_issue(noteable, project, author, canonical_issue) ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_duplicate_issue(canonical_issue) end diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 7a73af0a81a..b344b240a07 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -242,6 +242,29 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: 'moved')) end + # Called when noteable has been cloned + # + # noteable_ref - Referenced noteable + # direction - symbol, :to or :from + # + # Example Note text: + # + # "cloned to some_namespace/project_new#11" + # + # Returns the created Note object + def noteable_cloned(noteable_ref, direction) + unless [:to, :from].include?(direction) + raise ArgumentError, "Invalid direction `#{direction}`" + end + + cross_reference = noteable_ref.to_reference(project) + body = "cloned #{direction} #{cross_reference}" + + issue_activity_counter.track_issue_cloned_action(author: author) if noteable.is_a?(Issue) && direction == :to + + create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned')) + end + # Called when the confidentiality changes # # Example Note text: diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb index 403944557a2..ba6ead41836 100644 --- a/app/services/upload_service.rb +++ b/app/services/upload_service.rb @@ -6,16 +6,18 @@ class UploadService end def execute - return unless @file && @file.size <= max_attachment_size + return unless file && file.size <= max_attachment_size - uploader = @uploader_class.new(@model, nil, @uploader_context) - uploader.store!(@file) + uploader = uploader_class.new(model, nil, **uploader_context) + uploader.store!(file) uploader end private + attr_reader :model, :file, :uploader_class, :uploader_context + def max_attachment_size Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i end diff --git a/app/services/users/approve_service.rb b/app/services/users/approve_service.rb index 27668e9430e..debd1e8cd17 100644 --- a/app/services/users/approve_service.rb +++ b/app/services/users/approve_service.rb @@ -7,8 +7,9 @@ module Users end def execute(user) - return error(_('You are not allowed to approve a user')) unless allowed? - return error(_('The user you are trying to approve is not pending an approval')) unless approval_required?(user) + return error(_('You are not allowed to approve a user'), :forbidden) unless allowed? + return error(_('The user you are trying to approve is not pending an approval'), :conflict) if user.active? + return error(_('The user you are trying to approve is not pending an approval'), :conflict) unless approval_required?(user) if user.activate # Resends confirmation email if the user isn't confirmed yet. @@ -18,9 +19,9 @@ module Users DeviseMailer.user_admin_approval(user).deliver_later after_approve_hook(user) - success + success(message: 'Success', http_status: :created) else - error(user.errors.full_messages.uniq.join('. ')) + error(user.errors.full_messages.uniq.join('. '), :unprocessable_entity) end end diff --git a/app/services/users/reject_service.rb b/app/services/users/reject_service.rb new file mode 100644 index 00000000000..dd72547c688 --- /dev/null +++ b/app/services/users/reject_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Users + class RejectService < BaseService + def initialize(current_user) + @current_user = current_user + end + + def execute(user) + return error(_('You are not allowed to reject a user')) unless allowed? + return error(_('This user does not have a pending request')) unless user.blocked_pending_approval? + + user.delete_async(deleted_by: current_user, params: { hard_delete: true }) + + NotificationService.new.user_admin_rejection(user.name, user.email) + + success + end + + private + + attr_reader :current_user + + def allowed? + can?(current_user, :reject_user) + end + end +end diff --git a/app/services/users/set_status_service.rb b/app/services/users/set_status_service.rb index 356c8782af1..a907937070f 100644 --- a/app/services/users/set_status_service.rb +++ b/app/services/users/set_status_service.rb @@ -14,10 +14,10 @@ module Users def execute return false unless can?(current_user, :update_user_status, target_user) - if params[:emoji].present? || params[:message].present? || params[:availability].present? - set_status - else + if status_cleared? remove_status + else + set_status end end @@ -25,8 +25,7 @@ module Users def set_status params[:emoji] = UserStatus::DEFAULT_EMOJI if params[:emoji].blank? - params.delete(:availability) if params[:availability].blank? - return false if params[:availability].present? && UserStatus.availabilities.keys.exclude?(params[:availability]) + params[:availability] = UserStatus.availabilities[:not_set] unless new_user_availability user_status.update(params) end @@ -38,5 +37,15 @@ module Users def user_status target_user.status || target_user.build_status end + + def status_cleared? + params[:emoji].blank? && + params[:message].blank? && + (new_user_availability.blank? || new_user_availability == UserStatus.availabilities[:not_set]) + end + + def new_user_availability + UserStatus.availabilities[params[:availability]] + end end end diff --git a/app/services/users/validate_otp_service.rb b/app/services/users/validate_otp_service.rb index a9ce7959aea..c8a9f217d22 100644 --- a/app/services/users/validate_otp_service.rb +++ b/app/services/users/validate_otp_service.rb @@ -2,10 +2,14 @@ module Users class ValidateOtpService < BaseService + include ::Gitlab::Auth::Otp::Fortinet + def initialize(current_user) @current_user = current_user - @strategy = if Feature.enabled?(:forti_authenticator, current_user) + @strategy = if forti_authenticator_enabled?(current_user) ::Gitlab::Auth::Otp::Strategies::FortiAuthenticator.new(current_user) + elsif forti_token_cloud_enabled?(current_user) + ::Gitlab::Auth::Otp::Strategies::FortiTokenCloud.new(current_user) else ::Gitlab::Auth::Otp::Strategies::Devise.new(current_user) end diff --git a/app/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb index 2306313fc82..d80725cb051 100644 --- a/app/uploaders/terraform/state_uploader.rb +++ b/app/uploaders/terraform/state_uploader.rb @@ -6,17 +6,33 @@ module Terraform storage_options Gitlab.config.terraform_state - delegate :project_id, to: :model + delegate :terraform_state, :project_id, to: :model # Use Lockbox to encrypt/decrypt the stored file (registers CarrierWave callbacks) encrypt(key: :key) def filename - "#{model.uuid}.tfstate" + # This check is required to maintain backwards compatibility with + # states that were created prior to versioning being supported. + # This can be removed in 14.0 when support for these states is dropped. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/258960 + if terraform_state.versioning_enabled? + "#{model.version}.tfstate" + else + "#{model.uuid}.tfstate" + end end def store_dir - project_id.to_s + # This check is required to maintain backwards compatibility with + # states that were created prior to versioning being supported. + # This can be removed in 14.0 when support for these states is dropped. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/258960 + if terraform_state.versioning_enabled? + Gitlab::HashedPath.new(model.uuid, root_hash: project_id) + else + project_id.to_s + end end def key diff --git a/app/uploaders/terraform/versioned_state_uploader.rb b/app/uploaders/terraform/versioned_state_uploader.rb deleted file mode 100644 index e50ab6c7dc6..00000000000 --- a/app/uploaders/terraform/versioned_state_uploader.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Terraform - class VersionedStateUploader < StateUploader - delegate :terraform_state, to: :model - - def filename - if terraform_state.versioning_enabled? - "#{model.version}.tfstate" - else - "#{model.uuid}.tfstate" - end - end - - def store_dir - if terraform_state.versioning_enabled? - Gitlab::HashedPath.new(model.uuid, root_hash: project_id) - else - project_id.to_s - end - end - end -end diff --git a/app/validators/json_schema_validator.rb b/app/validators/json_schema_validator.rb index f8c1727035c..fee4a00cec5 100644 --- a/app/validators/json_schema_validator.rb +++ b/app/validators/json_schema_validator.rb @@ -12,6 +12,7 @@ class JsonSchemaValidator < ActiveModel::EachValidator FILENAME_ALLOWED = /\A[a-z0-9_-]*\Z/.freeze FilenameError = Class.new(StandardError) + JSON_VALIDATOR_MAX_DRAFT_VERSION = 4 def initialize(options) raise ArgumentError, "Expected 'filename' as an argument" unless options[:filename] @@ -29,10 +30,18 @@ class JsonSchemaValidator < ActiveModel::EachValidator private def valid_schema?(value) - JSON::Validator.validate(schema_path, value) + if draft_version > JSON_VALIDATOR_MAX_DRAFT_VERSION + JSONSchemer.schema(Pathname.new(schema_path)).valid?(value) + else + JSON::Validator.validate(schema_path, value) + end end def schema_path Rails.root.join('app', 'validators', 'json_schemas', "#{options[:filename]}.json").to_s end + + def draft_version + options[:draft] || JSON_VALIDATOR_MAX_DRAFT_VERSION + end end diff --git a/app/validators/json_schemas/codeclimate.json b/app/validators/json_schemas/codeclimate.json new file mode 100644 index 00000000000..56056c62c4e --- /dev/null +++ b/app/validators/json_schemas/codeclimate.json @@ -0,0 +1,34 @@ +{ + "description": "Codequality used by codeclimate parser", + "type": "object", + "required": ["description", "fingerprint", "severity", "location"], + "properties": { + "description": { "type": "string" }, + "fingerprint": { "type": "string" }, + "severity": { "type": "string" }, + "location": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "lines": { + "type": "object", + "properties": { + "begin": { "type": "integer" } + } + }, + "positions": { + "type": "object", + "properties": { + "begin": { + "type": "object", + "properties": { + "line": { "type": "integer" } + } + } + } + } + } + } + }, + "additionalProperties": true +} diff --git a/app/validators/json_schemas/http_integration_payload_attribute_mapping.json b/app/validators/json_schemas/http_integration_payload_attribute_mapping.json new file mode 100644 index 00000000000..e457b8a292b --- /dev/null +++ b/app/validators/json_schemas/http_integration_payload_attribute_mapping.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "patternProperties": { + ".*": { + "type": "object", + "required": ["path", "type"], + "properties": { + "path": { "type": "array" }, + "type": { "type": "string" } + }, + "additionalProperties": false + } + } +} diff --git a/app/validators/json_schemas/vulnerability_finding_details.json b/app/validators/json_schemas/vulnerability_finding_details.json new file mode 100644 index 00000000000..f2940866f4b --- /dev/null +++ b/app/validators/json_schemas/vulnerability_finding_details.json @@ -0,0 +1,182 @@ +{ + "type": "object", + "description": "The schema for vulnerability finding details", + "additionalProperties": false, + "patternProperties": { + "^.*$": { + "allOf": [ + { "$ref": "#/definitions/named_field" }, + { "$ref": "#/definitions/type_list" } + ] + } + }, + "definitions": { + "type_list": { + "oneOf": [ + { "$ref": "#/definitions/named_list" }, + { "$ref": "#/definitions/list" }, + { "$ref": "#/definitions/table" }, + + { "$ref": "#/definitions/text" }, + { "$ref": "#/definitions/url" }, + { "$ref": "#/definitions/code" }, + { "$ref": "#/definitions/int" }, + + { "$ref": "#/definitions/commit" }, + { "$ref": "#/definitions/file_location" }, + { "$ref": "#/definitions/module_location" } + ] + }, + "lang_text": { + "type": "object", + "required": [ "value", "lang" ], + "properties": { + "lang": { "type": "string" }, + "value": { "type": "string" } + } + }, + "lang_text_list": { + "type": "array", + "items": { "$ref": "#/definitions/lang_text" } + }, + "named_field": { + "type": "object", + "required": [ "name" ], + "properties": { + "name": { "$ref": "#/definitions/lang_text_list" }, + "description": { "$ref": "#/definitions/lang_text_list" } + } + }, + "named_list": { + "type": "object", + "description": "An object with named and typed fields", + "required": [ "type", "items" ], + "properties": { + "type": { "const": "named-list" }, + "items": { + "type": "object", + "patternProperties": { + "^.*$": { + "allOf": [ + { "$ref": "#/definitions/named_field" }, + { "$ref": "#/definitions/type_list" } + ] + } + } + } + } + }, + "list": { + "type": "object", + "description": "A list of typed fields", + "required": [ "type", "items" ], + "properties": { + "type": { "const": "list" }, + "items": { + "type": "array", + "items": { "$ref": "#/definitions/type_list" } + } + } + }, + "table": { + "type": "object", + "description": "A table of typed fields", + "required": [], + "properties": { + "type": { "const": "table" }, + "items": { + "type": "object", + "properties": { + "header": { + "type": "array", + "items": { + "$ref": "#/definitions/type_list" + } + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/type_list" + } + } + } + } + } + } + }, + "text": { + "type": "object", + "description": "Raw text", + "required": [ "type", "value" ], + "properties": { + "type": { "const": "text" }, + "value": { "$ref": "#/definitions/lang_text_list" } + } + }, + "url": { + "type": "object", + "description": "A single URL", + "required": [ "type", "href" ], + "properties": { + "type": { "const": "url" }, + "text": { "$ref": "#/definitions/lang_text_list" }, + "href": { "type": "string" } + } + }, + "code": { + "type": "object", + "description": "A codeblock", + "required": [ "type", "value" ], + "properties": { + "type": { "const": "code" }, + "value": { "type": "string" }, + "lang": { "type": "string" } + } + }, + "int": { + "type": "object", + "description": "An integer", + "required": [ "type", "value" ], + "properties": { + "type": { "const": "int" }, + "value": { "type": "integer" }, + "format": { + "type": "string", + "enum": [ "default", "hex" ] + } + } + }, + "commit": { + "type": "object", + "description": "A specific commit within the project", + "required": [ "type", "value" ], + "properties": { + "type": { "const": "commit" }, + "value": { "type": "string", "description": "The commit SHA" } + } + }, + "file_location": { + "type": "object", + "description": "A location within a file in the project", + "required": [ "type", "file_name", "line_start" ], + "properties": { + "type": { "const": "file-location" }, + "file_name": { "type": "string" }, + "line_start": { "type": "integer" }, + "line_end": { "type": "integer" } + } + }, + "module_location": { + "type": "object", + "description": "A location within a binary module of the form module+relative_offset", + "required": [ "type", "module_name", "offset" ], + "properties": { + "type": { "const": "module-location" }, + "module_name": { "type": "string" }, + "offset": { "type": "integer" } + } + } + } +} diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index ad3795445d1..67ac9d1c7b8 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -19,7 +19,7 @@ = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" %hr = f.hidden_field :header_logo_cache - = f.file_field :header_logo, class: "" + = f.file_field :header_logo, class: "", accept: 'image/*' .hint Maximum file size is 1MB. Pages are optimized for a 28px tall header logo %hr @@ -38,7 +38,7 @@ = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" %hr = f.hidden_field :favicon_cache - = f.file_field :favicon, class: '' + = f.file_field :favicon, class: '', accept: 'image/*' .hint Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}. %br @@ -70,7 +70,7 @@ = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo" %hr = f.hidden_field :logo_cache - = f.file_field :logo, class: "" + = f.file_field :logo, class: "", accept: 'image/*' .hint Maximum file size is 1MB. Pages are optimized for a 640x360 px logo. diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index f46eb84ce8e..46155f3f670 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -52,6 +52,9 @@ = link_to _('More information'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), target: '_blank' .form-group + = f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light' + = f.text_field :personal_access_token_prefix, placeholder: _('Max 20 characters'), class: 'form-control' + .form-group = f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold' .form-check = f.check_box :user_show_add_ssh_key_message, class: 'form-check-input' diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml index 5c0e544eaad..589d754be04 100644 --- a/app/views/admin/application_settings/_eks.html.haml +++ b/app/views/admin/application_settings/_eks.html.haml @@ -24,8 +24,13 @@ .form-group = f.label :eks_access_key_id, 'Access key ID', class: 'label-bold' = f.text_field :eks_access_key_id, class: 'form-control' + .form-text.text-muted + = _('AWS Access Key. Only required if not using role instance credentials') + .form-group = f.label :eks_secret_access_key, 'Secret access key', class: 'label-bold' = f.password_field :eks_secret_access_key, autocomplete: 'off', class: 'form-control' + .form-text.text-muted + = _('AWS Secret Access Key. Only required if not using role instance credentials') = f.submit 'Save changes', class: "gl-button btn btn-success" diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml index c1565cf42e1..b06070d15d4 100644 --- a/app/views/admin/application_settings/_ip_limits.html.haml +++ b/app/views/admin/application_settings/_ip_limits.html.haml @@ -2,44 +2,52 @@ = form_errors(@application_setting) %fieldset + %h5 + = _('Unauthenticated request rate limit') .form-group .form-check = f.check_box :throttle_unauthenticated_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_unauthenticated_checkbox' } - = f.label :throttle_unauthenticated_enabled, class: 'form-check-label' do + = f.label :throttle_unauthenticated_enabled, class: 'form-check-label label-bold' do Enable unauthenticated request rate limit %span.form-text.text-muted Helps reduce request volume (e.g. from crawlers or abusive bots) .form-group - = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'label-bold' + = f.label :throttle_unauthenticated_requests_per_period, 'Max unauthenticated requests per period per IP', class: 'label-bold' = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control' .form-group - = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold' + = f.label :throttle_unauthenticated_period_in_seconds, 'Unauthenticated rate limit period in seconds', class: 'label-bold' = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control' + %hr + %h5 + = _('Authenticated API request rate limit') .form-group .form-check = f.check_box :throttle_authenticated_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_api_checkbox' } - = f.label :throttle_authenticated_api_enabled, class: 'form-check-label' do + = f.label :throttle_authenticated_api_enabled, class: 'form-check-label label-bold' do Enable authenticated API request rate limit %span.form-text.text-muted Helps reduce request volume (e.g. from crawlers or abusive bots) .form-group - = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'label-bold' + = f.label :throttle_authenticated_api_requests_per_period, 'Max authenticated API requests per period per user', class: 'label-bold' = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control' .form-group - = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold' + = f.label :throttle_authenticated_api_period_in_seconds, 'Authenticated API rate limit period in seconds', class: 'label-bold' = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control' + %hr + %h5 + = _('Authenticated web request rate limit') .form-group .form-check = f.check_box :throttle_authenticated_web_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_web_checkbox' } - = f.label :throttle_authenticated_web_enabled, class: 'form-check-label' do + = f.label :throttle_authenticated_web_enabled, class: 'form-check-label label-bold' do Enable authenticated web request rate limit %span.form-text.text-muted Helps reduce request volume (e.g. from crawlers or abusive bots) .form-group - = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'label-bold' + = f.label :throttle_authenticated_web_requests_per_period, 'Max authenticated web requests per period per user', class: 'label-bold' = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control' .form-group - = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold' + = f.label :throttle_authenticated_web_period_in_seconds, 'Authenticated web rate limit period in seconds', class: 'label-bold' = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control' = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml new file mode 100644 index 00000000000..1547b28c651 --- /dev/null +++ b/app/views/admin/application_settings/_kroki.html.haml @@ -0,0 +1,25 @@ +- expanded = integration_expanded?('kroki_') +%section.settings.as-kroki.no-animate#js-kroki-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Kroki') + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Allow rendering of diagrams in AsciiDoc and Markdown documents using %{link}.').html_safe % { link: link_to('Kroki', 'https://kroki.io', target: '_blank') } + .settings-content + = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-kroki-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) if expanded + + %fieldset + .form-group + .form-check + = f.check_box :kroki_enabled, class: 'form-check-input' + = f.label :kroki_enabled, _('Enable Kroki'), class: 'form-check-label' + .form-group + = f.label :kroki_url, 'Kroki URL', class: 'label-bold' + = f.text_field :kroki_url, class: 'form-control', placeholder: 'http://your-kroki-instance:8000' + .form-text.text-muted + = (_('When Kroki is enabled, GitLab sends diagrams to an instance of Kroki to display them as images. You can use the free public cloud instance %{kroki_public_url} or you can %{install_link} on your own infrastructure. Once you\'ve installed Kroki, make sure to update the server URL to point to your instance.') % { kroki_public_url: '<code>https://kroki.io</code>', install_link: link_to('install Kroki', 'https://docs.kroki.io/kroki/setup/install/', target: '_blank') }).html_safe + + = f.submit _('Save changes'), class: "btn gl-button btn-success" diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index 30acb773424..77a310c73a8 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -18,7 +18,7 @@ = f.label :plantuml_enabled, _('Enable PlantUML'), class: 'form-check-label' .form-group = f.label :plantuml_url, 'PlantUML URL', class: 'label-bold' - = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' + = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://your-plantuml-instance:8080' .form-text.text-muted Allow rendering of = link_to "PlantUML", "http://plantuml.com" diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index c3deb8af99e..2f2d42e297e 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -11,7 +11,7 @@ = _("When enabled, any user visiting %{host} will be able to create an account.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" } .form-group .form-check - = f.check_box :require_admin_approval_after_user_signup, class: 'form-check-input' + = f.check_box :require_admin_approval_after_user_signup, class: 'form-check-input', data: { qa_selector: 'require_admin_approval_after_user_signup_checkbox' } = f.label :require_admin_approval_after_user_signup, class: 'form-check-label' do = _('Require admin approval for new sign-ups') .form-text.text-muted @@ -77,4 +77,4 @@ = f.label :after_sign_up_text, class: 'label-bold' = f.text_area :after_sign_up_text, class: 'form-control', rows: 4 .form-text.text-muted Markdown enabled - = f.submit 'Save changes', class: "gl-button btn btn-success" + = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index 46d8a8ac9c7..709ce497132 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -66,4 +66,12 @@ .form-group = f.label field_name, "#{type.upcase} SSH keys", class: 'label-bold' = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' + + .form-group + %label.label-bold= s_('AdminSettings|Feed token') + .form-check + = f.check_box :disable_feed_token, class: 'form-check-input' + = f.label :disable_feed_token, class: 'form-check-label' do + = s_('AdminSettings|Disable feed token') + = f.submit _('Save changes'), class: "gl-button btn btn-success" diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 5c3f68843a2..8f15dcac40a 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -35,7 +35,7 @@ .settings-content = render 'diff_limits' -%section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'sign_up_restrictions_settings_content' } } .settings-header %h4 = _('Sign-up restrictions') @@ -103,20 +103,10 @@ = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview.') = f.submit _('Save changes'), class: "gl-button btn btn-success" -- if Feature.enabled?(:maintenance_mode) - %section.settings.no-animate#js-maintenance-mode-toggle{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4 - = _('Maintenance mode') - %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } - = expanded_by_default? ? _('Collapse') : _('Expand') - %p - = _('Prevent users from performing write operations on GitLab while performing maintenance.') - .settings-content - #js-maintenance-mode-settings - += render_if_exists 'admin/application_settings/maintenance_mode_settings_form' = render_if_exists 'admin/application_settings/elasticsearch_form' = render 'admin/application_settings/gitpod' += render 'admin/application_settings/kroki' = render 'admin/application_settings/plantuml' = render 'admin/application_settings/sourcegraph' = render_if_exists 'admin/application_settings/slack' diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 9f1b7195ab7..4959e596148 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -6,7 +6,7 @@ .settings-header %h4 = _('Metrics - Prometheus') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Enable and configure Prometheus metrics.') @@ -17,7 +17,7 @@ .settings-header %h4 = _('Metrics - Grafana') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Enable and configure Grafana.') @@ -28,7 +28,7 @@ .settings-header %h4 = _('Profiling - Performance bar') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Enable access to the Performance Bar for a given group.') @@ -42,7 +42,7 @@ .settings-header#usage-statistics %h4 = _('Usage statistics') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Enable or disable version check and usage ping.') diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 220a211cca6..8cc04392752 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -9,7 +9,7 @@ dismissible: true.to_s } } = notice[:message].html_safe -- if @license.present? && show_license_breakdown? +- if @license.present? .license-panel.gl-mt-5 = render_if_exists 'admin/licenses/summary' = render_if_exists 'admin/licenses/breakdown' diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml index dc3bda3a994..75398f3aa21 100644 --- a/app/views/admin/dev_ops_report/show.html.haml +++ b/app/views/admin/dev_ops_report/show.html.haml @@ -3,7 +3,7 @@ .container .gl-mt-3 - - if Gitlab.ee? && Feature.enabled?(:devops_adoption_feature) && License.feature_available?(:devops_adoption) + - if Gitlab.ee? && Feature.enabled?(:devops_adoption_feature, default_enabled: true) && License.feature_available?(:devops_adoption) = render_if_exists 'admin/dev_ops_report/devops_tabs' - else = render 'report' diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 6174da14ac0..e4517dca6d0 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -1,6 +1,7 @@ = form_for [:admin, @group] do |f| = form_errors(@group) = render 'shared/group_form', f: f + = render 'shared/group_form_description', f: f = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group = render_if_exists 'admin/namespace_plan', f: f diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index 17bb054b869..5bc5404fada 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -18,28 +18,28 @@ .gl-mt-3 = form.check_box :repository_update_events, class: 'float-left' - .prepend-left-20 + .gl-ml-6 = form.label :repository_update_events, class: 'list-label' do %strong Repository update events %p.light This URL will be triggered when repository is updated %li = form.check_box :push_events, class: 'float-left' - .prepend-left-20 + .gl-ml-6 = form.label :push_events, class: 'list-label' do %strong Push events %p.light This URL will be triggered for each branch updated to the repository %li = form.check_box :tag_push_events, class: 'float-left' - .prepend-left-20 + .gl-ml-6 = form.label :tag_push_events, class: 'list-label' do %strong Tag push events %p.light This URL will be triggered when a new tag is pushed to the repository %li = form.check_box :merge_requests_events, class: 'float-left' - .prepend-left-20 + .gl-ml-6 = form.label :merge_requests_events, class: 'list-label' do %strong Merge request events %p.light diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index 76d37626fff..f204e620e9d 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -1,7 +1,7 @@ - page_title _("Labels") %div - = link_to new_admin_label_path, class: "float-right btn gl-button btn-nr btn-success" do + = link_to new_admin_label_path, class: "float-right btn gl-button btn-success" do = _('New label') %h3.page-title = _('Labels') diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml index 3b3de042511..c6627ae0f27 100644 --- a/app/views/admin/runners/_sort_dropdown.html.haml +++ b/app/views/admin/runners/_sort_dropdown.html.haml @@ -3,7 +3,7 @@ .dropdown.inline.gl-ml-3 %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } = sorted_by - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort %li = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sorted_by) diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 2c4befb1be2..06925964dc5 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -9,7 +9,7 @@ %span.runner-state.runner-state-specific Specific -- page_title _("Runners") +- page_title @runner.short_sha - add_to_breadcrumbs _("Runners"), admin_runners_path - breadcrumb_title "##{@runner.id}" @@ -39,17 +39,18 @@ %thead %tr %th Assigned projects - %th - @runner.runner_projects.each do |runner_project| - project = runner_project.project - if project - %tr.alert-info + %tr %td - %strong - = project.full_name - %td - .float-right - = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'gl-button btn btn-danger btn-sm' + .gl-alert.gl-alert-danger + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + %strong + = project.full_name + .gl-alert-actions + = link_to s_('Disable'), admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn gl-alert-action btn-info btn-md gl-button' %table.table.unassigned-projects %thead diff --git a/app/views/admin/users/_approve_user.html.haml b/app/views/admin/users/_approve_user.html.haml index b4d960d909c..f61c9fa4b80 100644 --- a/app/views/admin/users/_approve_user.html.haml +++ b/app/views/admin/users/_approve_user.html.haml @@ -4,4 +4,4 @@ .card-body = render partial: 'admin/users/user_approve_effects' %br - = link_to s_('AdminUsers|Approve user'), approve_admin_user_path(user), method: :put, class: "btn gl-button btn-info", data: { confirm: s_('AdminUsers|Are you sure?') } + = link_to s_('AdminUsers|Approve user'), approve_admin_user_path(user), method: :put, class: "btn gl-button btn-info", data: { confirm: s_('AdminUsers|Are you sure?'), qa_selector: 'approve_user_button' } diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml index e56bbd06575..f6e7cefafe7 100644 --- a/app/views/admin/users/_modals.html.haml +++ b/app/views/admin/users/_modals.html.haml @@ -1,10 +1,5 @@ -#user-modal -#modal-texts.hidden{ "hidden": true, "aria-hidden": true } - %div{ data: { modal: "deactivate", - title: s_("AdminUsers|Deactivate User %{username}?"), - action: s_("AdminUsers|Deactivate") } } - = render partial: 'admin/users/user_deactivation_effects' - +#js-delete-user-modal +#js-modal-texts.hidden{ "hidden": true, "aria-hidden": true } %div{ data: { modal: "delete", title: s_("AdminUsers|Delete User %{username}?"), action: s_('AdminUsers|Delete user'), diff --git a/app/views/admin/users/_reject_pending_user.html.haml b/app/views/admin/users/_reject_pending_user.html.haml new file mode 100644 index 00000000000..17108427330 --- /dev/null +++ b/app/views/admin/users/_reject_pending_user.html.haml @@ -0,0 +1,7 @@ +.card.border-danger + .card-header.bg-danger.gl-text-white + = s_('AdminUsers|This user has requested access') + .card-body + = render partial: 'admin/users/user_reject_effects' + %br + = link_to s_('AdminUsers|Reject request'), reject_admin_user_path(user), method: :delete, class: "btn gl-button btn-danger", data: { confirm: s_('AdminUsers|Are you sure?') } diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index 679c4805280..31fd3aea94d 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -37,26 +37,25 @@ - elsif user.blocked? - if user.blocked_pending_approval? = link_to s_('AdminUsers|Approve'), approve_admin_user_path(user), method: :put - %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_block_data(user, user_block_effects) } - = s_('AdminUsers|Block') + = link_to s_('AdminUsers|Reject'), reject_admin_user_path(user), method: :delete - else - = link_to _('Unblock'), unblock_admin_user_path(user), method: :put + %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_unblock_data(user) } + = s_('AdminUsers|Unblock') - else %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_block_data(user, user_block_effects) } = s_('AdminUsers|Block') - if user.can_be_deactivated? %li - %button.btn.btn-default-tertiary{ data: { 'gl-modal-action': 'deactivate', - url: deactivate_admin_user_path(user), - username: sanitize_name(user.name) } } + %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_deactivation_data(user, user_deactivation_effects) } = s_('AdminUsers|Deactivate') - elsif user.deactivated? %li - = link_to _('Activate'), activate_admin_user_path(user), method: :put + %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_activation_data(user) } + = s_('AdminUsers|Activate') - if user.access_locked? %li = link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') } - - if can?(current_user, :destroy_user, user) + - if can?(current_user, :destroy_user, user) && !user.blocked_pending_approval? %li.divider - if user.can_be_removed? %li diff --git a/app/views/admin/users/_user_deactivation_effects.html.haml b/app/views/admin/users/_user_deactivation_effects.html.haml deleted file mode 100644 index dc3896e18c0..00000000000 --- a/app/views/admin/users/_user_deactivation_effects.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -%p - = s_('AdminUsers|Deactivating a user has the following effects:') -%ul - %li - = s_('AdminUsers|The user will be logged out') - %li - = s_('AdminUsers|The user will not be able to access git repositories') - %li - = s_('AdminUsers|The user will not be able to access the API') - %li - = s_('AdminUsers|The user will not receive any notifications') - %li - = s_('AdminUsers|The user will not be able to use slash commands') - %li - = s_('AdminUsers|When the user logs back in, their account will reactivate as a fully active account') - %li - = s_('AdminUsers|Personal projects, group and user history will be left intact') - = render_if_exists 'admin/users/user_deactivation_effects_on_seats' diff --git a/app/views/admin/users/_user_reject_effects.html.haml b/app/views/admin/users/_user_reject_effects.html.haml new file mode 100644 index 00000000000..17b6862b0cc --- /dev/null +++ b/app/views/admin/users/_user_reject_effects.html.haml @@ -0,0 +1,10 @@ +%p + = s_('AdminUsers|Rejected users:') +%ul + %li + = s_('AdminUsers|Cannot sign in or access instance information') + %li + = s_('AdminUsers|Will be deleted') +%p + - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path("user/profile/account/delete_account", anchor: "associated-records") } + = s_('AdminUsers|For more information, please refer to the %{link_start}user account deletion documentation.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 2e179d2d845..b86abb893a9 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -31,7 +31,7 @@ = s_('AdminUsers|Blocked') %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) = nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do - = link_to admin_users_path(filter: "blocked_pending_approval") do + = link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do = s_('AdminUsers|Pending approval') %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval) = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do @@ -69,7 +69,11 @@ = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do = title -- if @users.empty? +- if Feature.enabled?(:vue_admin_users) + #js-admin-users-app{ data: admin_users_data_attributes(@users) } + .gl-spinner-container.gl-my-7 + %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } } +- elsif @users.empty? .nothing-here-block.border-top-0 = s_('AdminUsers|No users found') - else diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 9c6f151a6b1..26f78ea4d6a 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -42,7 +42,7 @@ = sprite_icon('close', size: 16, css_class: 'gl-icon') %li %span.light ID: - %strong + %strong{ data: { qa_selector: 'user_id_content' } } = @user.id %li %span.light= _('Namespace ID:') @@ -158,24 +158,21 @@ .card-body = render partial: 'admin/users/user_activation_effects' %br - = link_to 'Activate user', activate_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' } + %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_activation_data(@user) } + = s_('AdminUsers|Activate user') - elsif @user.can_be_deactivated? .card.border-warning .card-header.bg-warning.text-white Deactivate this user .card-body - = render partial: 'admin/users/user_deactivation_effects' + = user_deactivation_effects %br - %button.btn.gl-button.btn-warning{ data: { 'gl-modal-action': 'deactivate', - content: 'You can always re-activate their account, their data will remain intact.', - url: deactivate_admin_user_path(@user), - username: sanitize_name(@user.name) } } + %button.btn.gl-button.btn-warning.js-confirm-modal-button{ data: user_deactivation_data(@user, s_('AdminUsers|You can always re-activate their account, their data will remain intact.')) } = s_('AdminUsers|Deactivate user') - - if @user.blocked? - if @user.blocked_pending_approval? = render 'admin/users/approve_user', user: @user - = render 'admin/users/block_user', user: @user + = render 'admin/users/reject_pending_user', user: @user - else .card.border-info .card-header.gl-bg-blue-500.gl-text-white @@ -186,7 +183,8 @@ %li Log in %li Access Git repositories %br - = link_to 'Unblock user', unblock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: s_('AdminUsers|Are you sure?') } + %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_unblock_data(@user) } + = s_('AdminUsers|Unblock user') - elsif !@user.internal? = render 'admin/users/block_user', user: @user @@ -198,52 +196,52 @@ %p This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account. %br = link_to 'Unlock user', unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' } - - .card.border-danger - .card-header.bg-danger.text-white - = s_('AdminUsers|Delete user') - .card-body - - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) - %p Deleting a user has the following effects: - = render 'users/deletion_guidance', user: @user - %br - %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete', - delete_user_url: admin_user_path(@user), - block_user_url: block_admin_user_path(@user), - username: sanitize_name(@user.name) } } - = s_('AdminUsers|Delete user') - - else - - if @user.solo_owned_groups.present? - %p - This user is currently an owner in these groups: - %strong= @user.solo_owned_groups.map(&:name).join(', ') + - if !@user.blocked_pending_approval? + .card.border-danger + .card-header.bg-danger.text-white + = s_('AdminUsers|Delete user') + .card-body + - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) + %p Deleting a user has the following effects: + = render 'users/deletion_guidance', user: @user + %br + %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete', + delete_user_url: admin_user_path(@user), + block_user_url: block_admin_user_path(@user), + username: sanitize_name(@user.name) } } + = s_('AdminUsers|Delete user') + - else + - if @user.solo_owned_groups.present? + %p + This user is currently an owner in these groups: + %strong= @user.solo_owned_groups.map(&:name).join(', ') + %p + You must transfer ownership or delete these groups before you can delete this user. + - else + %p + You don't have access to delete this user. + + .card.border-danger + .card-header.bg-danger.text-white + = s_('AdminUsers|Delete user and contributions') + .card-body + - if can?(current_user, :destroy_user, @user) %p - You must transfer ownership or delete these groups before you can delete this user. + This option deletes the user and any contributions that + would usually be moved to the + = succeed "." do + = link_to "system ghost user", help_page_path("user/profile/account/delete_account") + As well as the user's personal projects, groups owned solely by + the user, and projects in them, will also be removed. Commits + to other projects are unaffected. + %br + %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions', + delete_user_url: admin_user_path(@user, hard_delete: true), + block_user_url: block_admin_user_path(@user), + username: @user.name } } + = s_('AdminUsers|Delete user and contributions') - else %p You don't have access to delete this user. - .card.border-danger - .card-header.bg-danger.text-white - = s_('AdminUsers|Delete user and contributions') - .card-body - - if can?(current_user, :destroy_user, @user) - %p - This option deletes the user and any contributions that - would usually be moved to the - = succeed "." do - = link_to "system ghost user", help_page_path("user/profile/account/delete_account") - As well as the user's personal projects, groups owned solely by - the user, and projects in them, will also be removed. Commits - to other projects are unaffected. - %br - %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions', - delete_user_url: admin_user_path(@user, hard_delete: true), - block_user_url: block_admin_user_path(@user), - username: @user.name } } - = s_('AdminUsers|Delete user and contributions') - - else - %p - You don't have access to delete this user. - = render partial: 'admin/users/modals' diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index d1ea7fec49d..573b96caae5 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -32,7 +32,7 @@ %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true } %span.dropdown-toggle-text = _('Select project') - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %span.form-text.text-muted .form-group @@ -43,7 +43,7 @@ %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true } %span.dropdown-toggle-text = _('Select project to choose zone') - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %p.form-text.text-muted = s_('ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: zones_link_url }, help_link_end: help_link_end } @@ -59,7 +59,7 @@ %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true } %span.dropdown-toggle-text = _('Select project and zone to choose machine type') - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %p.form-text.text-muted = s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end } diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 6ac852af2db..cb464eeafbb 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -27,6 +27,7 @@ provider_type: @cluster.provider_type, pre_installed_knative: @cluster.knative_pre_installed? ? 'true': 'false', help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), + helm_help_path: help_page_path('user/clusters/applications.md', anchor: 'helm'), ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'), ingress_dns_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint'), ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'), diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 9a9fbfc1ee8..c34e457dbd9 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -71,7 +71,7 @@ = sort_options_hash[@sort] - else = sort_title_recently_created - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-sort.dropdown-menu-right %li = link_to todos_filter_path(sort: sort_value_label_priority) do @@ -85,9 +85,8 @@ - if @todos.any? .js-todos-list-container{ data: { qa_selector: "todos_list_container" } } .js-todos-options{ data: { per_page: @todos.limit_value, current_page: @todos.current_page, total_pages: @todos.total_pages } } - .card.card-without-border.card-without-margin - %ul.content-list.todos-list - = render @todos + %ul.content-list.todos-list + = render @todos = paginate @todos, theme: "gitlab" .js-nothing-here-container.todos-all-done.hidden.svg-content = image_tag 'illustrations/todos_all_done.svg' diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml index a1fcbea5bf2..bf321bb690b 100644 --- a/app/views/devise/confirmations/almost_there.haml +++ b/app/views/devise/confirmations/almost_there.haml @@ -1,7 +1,7 @@ -.well-confirmation.text-center.append-bottom-20 +.well-confirmation.text-center.gl-mb-6 %h1.gl-mt-0 Almost there... - %p.lead.append-bottom-20 + %p.lead.gl-mb-6 Please check your email to confirm your account %hr - if Gitlab::CurrentSettings.after_sign_up_text.present? @@ -9,6 +9,6 @@ = markdown_field(Gitlab::CurrentSettings, :after_sign_up_text) %p.text-center No confirmation email received? Please check your spam folder or -.append-bottom-20.prepend-top-20.text-center +.gl-mb-6.prepend-top-20.text-center %a.btn.btn-lg.btn-success{ href: new_user_confirmation_path } Request new confirmation email diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 0dc98001881..3c4cbbbc3bd 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -1,7 +1,12 @@ - max_first_name_length = max_last_name_length = 127 - max_username_length = 255 - min_username_length = 2 +- omniauth_providers_placement ||= :bottom + .gl-mb-3.gl-p-4.gl-border-gray-100.gl-border-1.gl-border-solid.gl-rounded-base + - if show_omniauth_providers && omniauth_providers_placement == :top + = render 'devise/shared/signup_omniauth_providers_top' + = form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f| .devise-errors = render 'devise/shared/error_messages', resource: resource @@ -23,7 +28,7 @@ .form-group = f.label :email, class: 'label-bold' = f.email_field :email, value: @invite_email, class: 'form-control middle', data: { qa_selector: 'new_user_email_field' }, required: true, title: _('Please provide a valid email address.') - .form-group.append-bottom-20#password-strength + .form-group.gl-mb-5#password-strength = f.label :password, class: 'label-bold' = f.password_field :password, class: 'form-control bottom', data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } %p.gl-field-hint.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } @@ -33,5 +38,5 @@ .submit-container = f.submit button_text, class: 'btn gl-button btn-success', data: { qa_selector: 'new_user_register_button' } = render 'devise/shared/terms_of_service_notice' - - if show_omniauth_providers + - if show_omniauth_providers && omniauth_providers_placement == :bottom = render 'devise/shared/signup_omniauth_providers' diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml new file mode 100644 index 00000000000..ece886b3cdd --- /dev/null +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -0,0 +1,9 @@ +%label.label-bold.d-block + = _("Create an account using:") +.d-flex.justify-content-between.flex-wrap + - providers.each do |provider| + = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn gl-button gl-display-flex gl-align-items-center gl-text-left gl-mb-2 gl-p-2 omniauth-btn oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do + - if provider_has_icon?(provider) + = provider_image_tag(provider) + %span.ml-2 + = label_for_provider(provider) diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml index 68098f1865b..a653d44d694 100644 --- a/app/views/devise/shared/_signup_omniauth_providers.haml +++ b/app/views/devise/shared/_signup_omniauth_providers.haml @@ -1,13 +1,3 @@ .omniauth-divider.d-flex.align-items-center.text-center = _("or") -%label.label-bold.d-block - = _("Create an account using:") -- providers = enabled_button_based_providers -.d-flex.justify-content-between.flex-wrap - - providers.each do |provider| - - has_icon = provider_has_icon?(provider) - = link_to omniauth_authorize_path(:user, provider), method: :post, class: "gl-button btn d-flex align-items-center omniauth-btn text-left oauth-login mb-2 p-2 #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do - - if has_icon - = provider_image_tag(provider) - %span.ml-2 - = label_for_provider(provider) += render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers diff --git a/app/views/devise/shared/_signup_omniauth_providers_top.haml b/app/views/devise/shared/_signup_omniauth_providers_top.haml new file mode 100644 index 00000000000..1deacad88c4 --- /dev/null +++ b/app/views/devise/shared/_signup_omniauth_providers_top.haml @@ -0,0 +1,3 @@ += render 'devise/shared/signup_omniauth_provider_list', providers: experiment_enabled_button_based_providers +.omniauth-divider.d-flex.align-items-center.text-center + = _("or") diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml index 96f4f07176e..d145ac3f359 100644 --- a/app/views/devise/unlocks/new.html.haml +++ b/app/views/devise/unlocks/new.html.haml @@ -4,7 +4,7 @@ = form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f| .devise-errors = render "devise/shared/error_messages", resource: resource - .form-group.append-bottom-20 + .form-group.gl-mb-6 = f.label :email = f.email_field :email, class: 'form-control', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: 'Please provide a valid email address.' .clearfix diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index bf17eb4fe3e..b5bfbc7bd1c 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -10,7 +10,7 @@ - if current_user.admin? .text-warning %p - = icon("exclamation-triangle fw") + = sprite_icon('warning-solid') = html_escape(_('You are an admin, which means granting access to %{client_name} will allow them to interact with GitLab as an admin as well. Proceed with caution.')) % { client_name: tag.strong(@pre_auth.client.name) } %p - link_to_client = link_to(@pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer') diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index 6fc156cf4ed..2ead8fc2cfd 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -10,7 +10,7 @@ = visibility_level_label(params[:visibility_level].to_i) - else = _('Any') - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right %li = link_to filter_projects_path(visibility_level: nil) do diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml index 07394eec107..f141b646e69 100644 --- a/app/views/groups/_create_chat_team.html.haml +++ b/app/views/groups/_create_chat_team.html.haml @@ -1,10 +1,10 @@ .form-group .col-sm-2.col-form-label = f.label :create_chat_team do - %span.mattermost-icon + %span.gl-display-flex = custom_icon('icon_mattermost') - Mattermost - .col-sm-10 + %span.gl-ml-2 Mattermost + .col-sm-12 .form-check.js-toggle-container .js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: false }, true, false) = f.label :create_chat_team, class: 'form-check-label' do diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index ee08829d990..67f278a06f3 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -6,10 +6,10 @@ .row.mb-3 .home-panel-title-row.col-md-12.col-lg-6.d-flex .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none - = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64) + = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo') .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline - %h1.home-panel-title.gl-mt-3.gl-mb-2 + %h1.home-panel-title.gl-mt-3.gl-mb-2{ itemprop: 'name' } = @group.name %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } = visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) @@ -34,7 +34,7 @@ - if @group.description.present? .group-home-desc.mt-1 .home-panel-description - .home-panel-description-markdown.read-more-container + .home-panel-description-markdown.read-more-container{ itemprop: 'description' } = markdown_field(@group, :description) %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } = _("Read more") diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml new file mode 100644 index 00000000000..c95e7c16161 --- /dev/null +++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml @@ -0,0 +1,25 @@ += form_with url: configure_import_bulk_imports_path, class: 'group-form gl-show-field-errors' do |f| + = form_errors(@group) + + .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5 + %h4 + = s_('GroupsNew|Import groups from another instance of GitLab') + %p + = s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.') + .form-group.gl-display-flex.gl-flex-direction-column + = f.label :bulk_import_gitlab_url, s_('GroupsNew|GitLab source URL'), for: 'import_gitlab_url' + = f.text_field :bulk_import_gitlab_url, placeholder: 'https://gitlab.example.com', class: 'gl-form-input col-xs-12 col-sm-8', + required: true, + title: s_('GroupsNew|Please fill in GitLab source URL.'), + id: 'import_gitlab_url' + .form-group.gl-display-flex.gl-flex-direction-column + = f.label :bulk_import_gitlab_access_token, s_('GroupsNew|Personal access token'), for: 'import_gitlab_token' + .gl-font-weight-normal + - pat_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/profile/personal_access_tokens') } + = s_('GroupsNew|Navigate to user settings to find your %{link_start}personal access token%{link_end}.').html_safe % { link_start: pat_link_start, link_end: '</a>'.html_safe } + = f.text_field :bulk_import_gitlab_access_token, placeholder: s_('GroupsNew|e.g. h8d3f016698e...'), class: 'gl-form-input gl-mt-3 col-xs-12 col-sm-8', + required: true, + title: s_('GroupsNew|Please fill in your personal access token.'), + id: 'import_gitlab_token' + .gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5 + = f.submit s_('GroupsNew|Connect instance'), class: 'btn gl-button btn-success' diff --git a/app/views/groups/_import_group_pane.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml index 9ad8ebbb37d..171f3e0371a 100644 --- a/app/views/groups/_import_group_pane.html.haml +++ b/app/views/groups/_import_group_from_file_panel.html.haml @@ -5,18 +5,22 @@ = form_with url: import_gitlab_group_path, class: 'group-form gl-show-field-errors', multipart: true do |f| = form_errors(@group) - .row - .form-group.group-name.col-sm-12 - = f.label :name, _('Group name'), class: 'label-bold' - = f.text_field :name, placeholder: s_('GroupsNew|My Awesome Group'), class: 'js-autofill-group-name form-control input-lg', + .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5 + %h4 + = _('Import group from file') + %p + = s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.') + .form-group.gl-display-flex.gl-flex-direction-column + = f.label :name, _('New group name'), for: 'import_group_name' + = f.text_field :name, placeholder: s_('GroupsNew|My Awesome Group'), class: 'js-autofill-group-name gl-form-input col-xs-12 col-sm-8', required: true, title: _('Please fill in a descriptive name for your group.'), - autofocus: true + autofocus: true, + id: 'import_group_name' - .row - .form-group.col-xs-12.col-sm-8 - = f.label :path, _('Group URL'), class: 'label-bold' - .input-group.gl-field-error-anchor + .form-group.gl-display-flex.gl-flex-direction-column + = f.label :import_group_path, _('New group URL'), for: 'import_group_path' + .input-group.gl-field-error-anchor.col-xs-12.col-sm-8.gl-p-0 .group-root-path.input-group-prepend.has-tooltip{ title: group_path, :'data-placement' => 'bottom' } .input-group-text %span @@ -35,18 +39,12 @@ %span.gl-path-suggestions %p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.') %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group path availability...') - - .row - .form-group.col-md-12 - = s_('GroupsNew|To copy a GitLab group between installations, navigate to the group settings page for the original installation, generate an export file, and upload it here.') - .row - .form-group.col-sm-12 - = f.label :file, s_('GroupsNew|Import a GitLab group export file'), class: 'label-bold' - %div - = render 'shared/file_picker_button', f: f, field: :file, help_text: nil - - .row - .form-actions.col-sm-12 - = f.submit s_('GroupsNew|Import group'), class: 'btn btn-success' - = link_to _('Cancel'), new_group_path, class: 'btn btn-cancel' - + .form-group + = f.label :file, s_('GroupsNew|Upload file') + .gl-font-weight-normal + - import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/group/settings/import_export') } + = s_('GroupsNew|To import a group, navigate to the group settings for the GitLab source instance, %{link_start}generate an export file%{link_end}, and upload it here.').html_safe % { link_start: import_export_link_start, link_end: '</a>'.html_safe } + .gl-mt-3 + = render 'shared/file_picker_button', f: f, field: :file, help_text: nil, classes: 'gl-button btn-success-secondary gl-mr-2' + .gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5 + = f.submit _('Import'), class: 'btn gl-button btn-success' diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml index d9706556e79..3872bbcd062 100644 --- a/app/views/groups/_new_group_fields.html.haml +++ b/app/views/groups/_new_group_fields.html.haml @@ -2,12 +2,7 @@ = render 'shared/group_form', f: f, autofocus: true .row - .form-group.group-description-holder.col-sm-12 - = f.label :avatar, _("Group avatar"), class: 'label-bold' - %div - = render 'shared/choose_avatar_button', f: f - - .form-group.col-sm-12 + .form-group.col-sm-12.gl-mb-0 %label.label-bold = _('Visibility level') %p @@ -15,8 +10,13 @@ = link_to _('View the documentation'), help_page_path("public_access/public_access"), target: '_blank' = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false - = render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled - +- if Gitlab.config.mattermost.enabled + .row + = render 'create_chat_team', f: f +.row + .col-sm-4 + = render_if_exists 'shared/groups/invite_members' +.row .form-actions.col-sm-12 = f.submit _('Create group'), class: "btn btn-success" = link_to _('Cancel'), dashboard_groups_path, class: 'btn btn-cancel' diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml index cb15fe339e1..d9ab828a83b 100644 --- a/app/views/groups/_subgroups_and_projects.html.haml +++ b/app/views/groups/_subgroups_and_projects.html.haml @@ -3,6 +3,6 @@ = render "shared/groups/empty_state" %section{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } - .js-groups-list-holder + .js-groups-list-holder{ data: { show_schema_markup: 'true'} } .loading-container.text-center.prepend-top-20 .spinner.spinner-md diff --git a/app/views/groups/dependency_proxies/_url.html.haml b/app/views/groups/dependency_proxies/_url.html.haml index 9242954b684..25a2442f4d4 100644 --- a/app/views/groups/dependency_proxies/_url.html.haml +++ b/app/views/groups/dependency_proxies/_url.html.haml @@ -1,4 +1,4 @@ -- proxy_url = "#{group_url(@group)}/dependency_proxy/containers" +- proxy_url = "#{group_url(@group)}#{DependencyProxy::URL_SUFFIX}" %h5.prepend-top-20= _('Dependency proxy URL') diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml index ff1312eb763..2ecf92e0769 100644 --- a/app/views/groups/dependency_proxies/show.html.haml +++ b/app/views/groups/dependency_proxies/show.html.haml @@ -7,7 +7,7 @@ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/packages/dependency_proxy/index') } = _('Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } -- if @group.public? +- if Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true) || @group.public? - if can?(current_user, :admin_dependency_proxy, @group) = form_for(@dependency_proxy, method: :put, url: group_dependency_proxy_path(@group)) do |f| .form-group diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index eafee325500..33cd90ce5d3 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -8,7 +8,7 @@ .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } = _('Naming, visibility') - %button.btn.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.js-settings-toggle{ type: 'button' } = _('Collapse') %p = _('Update your group name, description, avatar, and visibility.') @@ -19,7 +19,7 @@ .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } = _('Permissions, LFS, 2FA') - %button.btn.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p = _('Advanced permissions, Large File Storage and Two-Factor authentication settings.') @@ -32,7 +32,7 @@ .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } = s_('GroupSettings|Badges') - %button.btn.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p = s_('GroupSettings|Customize your group badges.') @@ -40,6 +40,7 @@ .settings-content = render 'shared/badges/badge_settings' += render_if_exists 'groups/compliance_frameworks', expanded: expanded = render_if_exists 'groups/custom_project_templates_setting' = render_if_exists 'groups/templates_setting', expanded: expanded @@ -47,7 +48,7 @@ .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } = _('Advanced') - %button.btn.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p = _('Perform advanced options such as changing path, transferring, exporting, or removing the group.') diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 2a87b42ef13..a1527a74898 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -4,6 +4,7 @@ - show_access_requests = can_manage_members && @requesters.exists? - invited_active = params[:search_invited].present? || params[:invited_members_page].present? - vue_members_list_enabled = Feature.enabled?(:vue_group_members_list, @group, default_enabled: true) +- filtered_search_enabled = Feature.enabled?(:group_members_filtered_search, @group, default_enabled: true) - current_user_is_group_owner = @group && @group.has_owner?(current_user) - form_item_label_css_class = 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center' @@ -54,20 +55,21 @@ .tab-content #tab-members.tab-pane{ class: ('active' unless invited_active) } .card.card-without-border - = render 'groups/group_members/tab_pane/header' do - = render 'groups/group_members/tab_pane/title' do - = html_escape(_('Members with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do - .gl-px-3.gl-py-2 - .search-control-wrap.gl-relative - = render 'shared/members/search_field' - - if can_manage_members + - unless filtered_search_enabled + = render 'groups/group_members/tab_pane/header' do + = render 'groups/group_members/tab_pane/title' do + = html_escape(_('Members with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do + .gl-px-3.gl-py-2 + .search-control-wrap.gl-relative + = render 'shared/members/search_field' + - if can_manage_members + = render 'groups/group_members/tab_pane/form_item' do + = label_tag '2fa', _('2FA'), class: form_item_label_css_class + = render 'shared/members/filter_2fa_dropdown' = render 'groups/group_members/tab_pane/form_item' do - = label_tag '2fa', _('2FA'), class: form_item_label_css_class - = render 'shared/members/filter_2fa_dropdown' - = render 'groups/group_members/tab_pane/form_item' do - = label_tag :sort_by, _('Sort by'), class: form_item_label_css_class - = render 'shared/members/sort_dropdown' + = label_tag :sort_by, _('Sort by'), class: form_item_label_css_class + = render 'shared/members/sort_dropdown' - if vue_members_list_enabled .js-group-members-list{ data: group_members_list_data_attributes(@group, @members) } .loading @@ -83,9 +85,10 @@ - if @group.shared_with_group_links.any? #tab-groups.tab-pane .card.card-without-border - = render 'groups/group_members/tab_pane/header' do - = render 'groups/group_members/tab_pane/title' do - = html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + - unless filtered_search_enabled + = render 'groups/group_members/tab_pane/header' do + = render 'groups/group_members/tab_pane/title' do + = html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - if vue_members_list_enabled .js-group-linked-list{ data: linked_groups_list_data_attributes(@group) } .loading @@ -97,11 +100,12 @@ - if show_invited_members #tab-invited-members.tab-pane{ class: ('active' if invited_active) } .card.card-without-border - = render 'groups/group_members/tab_pane/header' do - = render 'groups/group_members/tab_pane/title' do - = html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do - = render 'shared/members/search_field', name: 'search_invited' + - unless filtered_search_enabled + = render 'groups/group_members/tab_pane/header' do + = render 'groups/group_members/tab_pane/title' do + = html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do + = render 'shared/members/search_field', name: 'search_invited' - if vue_members_list_enabled .js-group-invited-members-list{ data: group_members_list_data_attributes(@group, @invited_members) } .loading @@ -117,9 +121,10 @@ - if show_access_requests #tab-access-requests.tab-pane .card.card-without-border - = render 'groups/group_members/tab_pane/header' do - = render 'groups/group_members/tab_pane/title' do - = html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + - unless filtered_search_enabled + = render 'groups/group_members/tab_pane/header' do + = render 'groups/group_members/tab_pane/title' do + = html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - if vue_members_list_enabled .js-group-access-requests-list{ data: group_members_list_data_attributes(@group, @requesters) } .loading diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index a231702012c..920a6ccd9ec 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -31,14 +31,17 @@ %span.d-none.d-sm-block= s_('GroupsNew|Import group') %span.d-block.d-sm-none= s_('GroupsNew|Import') - .tab-content.gitlab-tab-content + .tab-content.gitlab-tab-content.gl-border-none .tab-pane.js-toggle-container{ id: 'create-group-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } = form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f| = render 'new_group_fields', f: f, group_name_id: 'create-group-name' - .tab-pane.js-toggle-container{ id: 'import-group-pane', class: active_when(active_tab) == 'import', role: 'tabpanel' } + .tab-pane.no-padding.js-toggle-container{ id: 'import-group-pane', class: active_when(active_tab) == 'import', role: 'tabpanel' } - if import_sources_enabled? - = render 'import_group_pane', active_tab: active_tab, autofocus: true + - if Feature.enabled?(:bulk_import) + = render 'import_group_from_another_instance_panel' + .gl-mt-7.gl-border-b-solid.gl-border-gray-100.gl-border-1 + = render 'import_group_from_file_panel' - else .nothing-here-block %h4= s_('GroupsNew|No import options available') diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index 21882c3e3ce..e26b8317c1c 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -16,4 +16,6 @@ "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'), "is_admin": current_user&.admin.to_s, is_group_page: "true", + "group_path": @group.full_path, + "gid_prefix": container_repository_gid_prefix, character_error: @character_error.to_s } } diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 9d5ec5008dc..109e7c3831e 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,5 +1,6 @@ -- breadcrumb_title _("Details") - @content_class = "limit-container-width" unless fluid_layout +- page_itemtype 'https://schema.org/Organization' +- @skip_current_level_breadcrumb = true - if show_thanks_for_purchase_banner? = render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index fca73f118b3..4cf08b1d2be 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/import' - provider = local_assigns.fetch(:provider) - extra_data = local_assigns.fetch(:extra_data, {}) - filterable = local_assigns.fetch(:filterable, true) diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml index d909f6a13f0..6757c32d1e1 100644 --- a/app/views/import/bulk_imports/status.html.haml +++ b/app/views/import/bulk_imports/status.html.haml @@ -1 +1,12 @@ -- page_title 'Bulk Import' +- add_to_breadcrumbs 'New group', admin_users_path +- add_page_specific_style 'page_bundles/import' +- breadcrumb_title _('Import groups') + +%h1.gl-my-0.gl-py-4.gl-font-size-h1.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1 + = s_('BulkImport|Import groups from GitLab') +%p.gl-my-0.gl-py-5.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1 + = s_('BulkImport|Importing groups from %{link}').html_safe % { link: external_link(@source_url, @source_url) } + +#import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json), + available_namespaces_path: import_available_namespaces_path(format: :json), + create_bulk_import_path: import_bulk_imports_path(format: :json) } } diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index ba6a5657d12..b62f98f5ded 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -7,4 +7,6 @@ = sprite_icon('github', css_class: 'gl-mr-2') = _('Import repositories from GitHub') -= render 'import/githubish_status', provider: 'github' +- paginatable = Feature.enabled?(:remove_legacy_github_client) + += render 'import/githubish_status', provider: 'github', paginatable: paginatable diff --git a/app/views/import/google_code/new.html.haml b/app/views/import/google_code/new.html.haml deleted file mode 100644 index 1edd224956c..00000000000 --- a/app/views/import/google_code/new.html.haml +++ /dev/null @@ -1,63 +0,0 @@ -- page_title _("Google Code import") -- header_title _("Projects"), root_path -%h3.page-title.gl-display-flex - .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('google', css_class: 'gl-mr-2') - = _('Import projects from Google Code') -%hr - -= form_tag callback_import_google_code_path, multipart: true do - %p - = _('Follow the steps below to export your Google Code project data.') - = _("In the next step, you'll be able to select the projects you want to import.") - %ol - %li - %p - - link_to_google_takeout = link_to(_("Google Takeout"), "https://www.google.com/settings/takeout", target: '_blank', rel: 'noopener noreferrer') - = _("Go to %{link_to_google_takeout}.").html_safe % { link_to_google_takeout: link_to_google_takeout } - %li - %p - = _("Make sure you're logged into the account that owns the projects you'd like to import.") - %li - %p - = html_escape(_('Click the %{strong_open}Select none%{strong_close} button on the right, since we only need "Google Code Project Hosting".')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - %li - %p - = html_escape(_('Scroll down to %{strong_open}Google Code Project Hosting%{strong_close} and enable the switch on the right.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - %li - %p - = html_escape(_('Choose %{strong_open}Next%{strong_close} at the bottom of the page.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - %li - %p - = _('Leave the "File type" and "Delivery method" options on their default values.') - %li - %p - = html_escape(_('Choose %{strong_open}Create archive%{strong_close} and wait for archiving to complete.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - %li - %p - = html_escape(_('Click the %{strong_open}Download%{strong_close} button and wait for downloading to complete.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - %li - %p - = _('Find the downloaded ZIP file and decompress it.') - %li - %p - = html_escape(_('Find the newly extracted %{code_open}Takeout/Google Code Project Hosting/GoogleCodeProjectHosting.json%{code_close} file.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %li - %p - = html_escape(_('Upload %{code_open}GoogleCodeProjectHosting.json%{code_close} here:')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %p - %input{ type: "file", name: "dump_file", id: "dump_file" } - %li - %p - = _('Do you want to customize how Google Code email addresses and usernames are imported into GitLab?') - %p - = label_tag :create_user_map_0 do - = radio_button_tag :create_user_map, 0, true - = _('No, directly import the existing email addresses and usernames.') - %p - = label_tag :create_user_map_1 do - = radio_button_tag :create_user_map, 1, false - = _('Yes, let me map Google Code users to full names or GitLab users.') - - %span - = submit_tag _('Continue to the next step'), class: "btn btn-success" diff --git a/app/views/import/google_code/new_user_map.html.haml b/app/views/import/google_code/new_user_map.html.haml deleted file mode 100644 index 833987dea4e..00000000000 --- a/app/views/import/google_code/new_user_map.html.haml +++ /dev/null @@ -1,37 +0,0 @@ -- page_title _("User map"), _("Google Code import") -- header_title _("Projects"), root_path -%h3.page-title.gl-display-flex - .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('google', css_class: 'gl-mr-2') - = _('Import projects from Google Code') -%hr - -= form_tag create_user_map_import_google_code_path do - %p - = _("Customize how Google Code email addresses and usernames are imported into GitLab. In the next step, you'll be able to select the projects you want to import.") - %p - = html_escape(_("The user map is a JSON document mapping the Google Code users that participated on your projects to the way their email addresses and usernames will be imported into GitLab. You can change this by changing the value on the right hand side of %{code_open}:%{code_close}. Be sure to preserve the surrounding double quotes, other punctuation and the email address or username on the left hand side.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %ul - %li - %strong= _("Default: Directly import the Google Code email address or username") - %p - = html_escape(_('%{code_open}"johnsmith@example.com": "johnsm...@example.com"%{code_close} will add "By johnsm...@example.com" to all issues and comments originally created by johnsmith@example.com. The email address or username is masked to ensure the user\'s privacy.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %li - %strong= _("Map a Google Code user to a GitLab user") - %p - = html_escape(_('%{code_open}"johnsmith@example.com": "@johnsmith"%{code_close} will add "By %{link_open}@johnsmith%{link_close}" to all issues and comments originally created by johnsmith@example.com, and will set %{link_open}@johnsmith%{link_close} as the assignee on all issues originally assigned to johnsmith@example.com.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe, link_open: '<a href="#">'.html_safe, link_close: '</a>'.html_safe } - %li - %strong= _("Map a Google Code user to a full name") - %p - = html_escape(_('%{code_open}"johnsmith@example.com": "John Smith"%{code_close} will add "By John Smith" to all issues and comments originally created by johnsmith@example.com.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %li - %strong= _("Map a Google Code user to a full email address") - %p - = html_escape(_('%{code_open}"johnsmith@example.com": "johnsmith@example.com"%{code_close} will add "By %{link_open}johnsmith@example.com%{link_close}" to all issues and comments originally created by johnsmith@example.com. By default, the email address or username is masked to ensure the user\'s privacy. Use this option if you want to show the full email address.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe, link_open: '<a href="#">'.html_safe, link_close: '</a>'.html_safe } - - .form-group.row - .col-sm-12 - = text_area_tag :user_map, Gitlab::Json.pretty_generate(@user_map), class: 'form-control', rows: 15 - - .form-actions - = submit_tag _('Continue to the next step'), class: "btn btn-success" diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml deleted file mode 100644 index 0004f0de69f..00000000000 --- a/app/views/import/google_code/status.html.haml +++ /dev/null @@ -1,78 +0,0 @@ -- page_title _("Google Code import") -- header_title _("Projects"), root_path -%h3.page-title.gl-display-flex - .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('google', css_class: 'gl-mr-2') - = _('Import projects from Google Code') - -- if @repos.any? - %p.light - = _('Select projects you want to import.') - %p.light - - link_to_customize = link_to(_("customize"), new_user_map_import_google_code_path) - = _("Optionally, you can %{link_to_customize} how Google Code email addresses and usernames are imported into GitLab.").html_safe % { link_to_customize: link_to_customize } - %hr - %p - - if @incompatible_repos.any? - = button_tag class: "btn btn-import btn-success js-import-all" do - = _("Import all compatible projects") - = loading_icon(css_class: 'loading-icon') - - else - = button_tag class: "btn btn-import btn-success js-import-all" do - = _("Import all projects") - = loading_icon(css_class: 'loading-icon') - -.table-responsive - %table.table.import-jobs - %colgroup.import-jobs-from-col - %colgroup.import-jobs-to-col - %colgroup.import-jobs-status-col - %thead - %tr - %th= _("From Google Code") - %th= _("To GitLab") - %th= _("Status") - %tbody - - @already_added_projects.each do |project| - %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } - %td - = link_to project.import_source, "https://code.google.com/p/#{project.import_source}", target: "_blank", rel: 'noopener noreferrer' - %td - = link_to project.full_path, project - %td.job-status - - case project.import_status - - when 'finished' - %span - = sprite_icon('check') - = _("done") - - when 'started' - = loading_icon - = _("started") - - else - = project.human_import_status_name - - - @repos.each do |repo| - %tr{ id: "repo_#{repo.id}" } - %td - = link_to repo.name, "https://code.google.com/p/#{repo.name}", target: "_blank", rel: 'noopener noreferrer' - %td.import-target - #{current_user.username}/#{repo.name} - %td.import-actions.job-status - = button_tag class: "btn btn-import js-add-to-import" do - = _("Import") - = loading_icon(css_class: 'loading-icon') - - @incompatible_repos.each do |repo| - %tr{ id: "repo_#{repo.id}" } - %td - = link_to repo.name, "https://code.google.com/p/#{repo.name}", target: "_blank", rel: 'noopener noreferrer' - %td.import-target - %td.import-actions-job-status - = label_tag _("Incompatible Project"), nil, class: "label badge-danger" - -- if @incompatible_repos.any? - %p - = _("One or more of your Google Code projects cannot be imported into GitLab directly because they use Subversion or Mercurial for version control, rather than Git.") - - link_to_import_flow = link_to(_("import flow"), new_import_google_code_path) - = _("Please convert them to Git on Google Code, and go through the %{link_to_import_flow} again.").html_safe % { link_to_import_flow: link_to_import_flow } - -.js-importer-status{ data: { jobs_import_path: "#{jobs_import_google_code_path}", import_path: "#{import_google_code_path}" } } diff --git a/app/views/import/manifest/_form.html.haml b/app/views/import/manifest/_form.html.haml index 2ee964974c3..1a3b945cfe5 100644 --- a/app/views/import/manifest/_form.html.haml +++ b/app/views/import/manifest/_form.html.haml @@ -19,5 +19,5 @@ = link_to sprite_icon('question-o'), help_page_path('user/project/import/manifest') .gl-mb-3 - = submit_tag _('List available repositories'), class: 'btn btn-success' - = link_to _('Cancel'), new_project_path, class: 'btn btn-cancel' + = submit_tag _('List available repositories'), class: 'gl-button btn btn-success' + = link_to _('Cancel'), new_project_path, class: 'gl-button btn btn-default btn-cancel' diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index 355ffabd7ec..b826a1b6fc6 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -62,5 +62,4 @@ = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? = webpack_bundle_tag 'jira_connect_app' -= page_specific_javascript_tag('jira_connect.js') -- add_page_specific_style 'page_bundles/jira_connect' +- add_page_specific_style 'page_bundles/jira_connect', defer: false diff --git a/app/views/layouts/_google_analytics.html.haml b/app/views/layouts/_google_analytics.html.haml index e8a5359e791..759e9ef36b9 100644 --- a/app/views/layouts/_google_analytics.html.haml +++ b/app/views/layouts/_google_analytics.html.haml @@ -1,4 +1,4 @@ -= javascript_tag nonce: true do += javascript_tag do :plain var _gaq = _gaq || []; _gaq.push(['_setAccount', '#{extra_config.google_analytics_id}']); diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml index ab03f1e7670..48eb9e40cc4 100644 --- a/app/views/layouts/_google_tag_manager_head.html.haml +++ b/app/views/layouts/_google_tag_manager_head.html.haml @@ -1,5 +1,5 @@ - if google_tag_manager_enabled? - = javascript_tag nonce: true do + = javascript_tag do :plain (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 1d12b30c58c..bdd506ab3be 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -88,5 +88,5 @@ = yield :meta_tags = render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id') - = render 'layouts/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id') + = render 'layouts/matomo' if extra_config.has_key?('matomo_url') && extra_config.has_key?('matomo_site_id') = render 'layouts/snowplow' diff --git a/app/views/layouts/_img_loader.html.haml b/app/views/layouts/_img_loader.html.haml index cddcd6e0af6..f6d7d163e6f 100644 --- a/app/views/layouts/_img_loader.html.haml +++ b/app/views/layouts/_img_loader.html.haml @@ -1,4 +1,4 @@ -= javascript_tag nonce: true do += javascript_tag do :plain if ('loading' in HTMLImageElement.prototype) { document.querySelectorAll('img.lazy').forEach(img => { diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml index 82ec92988eb..509f5be8097 100644 --- a/app/views/layouts/_init_auto_complete.html.haml +++ b/app/views/layouts/_init_auto_complete.html.haml @@ -4,7 +4,7 @@ - datasources = autocomplete_data_sources(object, noteable_type) - if object - = javascript_tag nonce: true do + = javascript_tag do :plain gl = window.gl || {}; gl.GfmAutoComplete = gl.GfmAutoComplete || {}; diff --git a/app/views/layouts/_init_client_detection_flags.html.haml b/app/views/layouts/_init_client_detection_flags.html.haml index 6537b86085f..03967bbbfcf 100644 --- a/app/views/layouts/_init_client_detection_flags.html.haml +++ b/app/views/layouts/_init_client_detection_flags.html.haml @@ -1,7 +1,7 @@ - client = client_js_flags - if client - = javascript_tag nonce: true do + = javascript_tag do :plain gl = window.gl || {}; gl.client = #{client.to_json}; diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml index a75b602ff6b..0ef50d1b122 100644 --- a/app/views/layouts/_loading_hints.html.haml +++ b/app/views/layouts/_loading_hints.html.haml @@ -6,6 +6,5 @@ - else %link{ { rel: 'preload', href: stylesheet_url('application'), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} } %link{ { rel: 'preload', href: stylesheet_url("highlight/themes/#{user_color_scheme}"), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} } -%link{ { rel: 'preload', href: asset_url("fontawesome-webfont.woff2?v=4.7.0"), as: 'font', type: 'font/woff2' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} } - if Gitlab::CurrentSettings.snowplow_enabled? && Gitlab::CurrentSettings.snowplow_collector_hostname %link{ rel: 'preconnect', href: Gitlab::CurrentSettings.snowplow_collector_hostname, crossorigin: '' } diff --git a/app/views/layouts/_matomo.html.haml b/app/views/layouts/_matomo.html.haml new file mode 100644 index 00000000000..fcd3156a162 --- /dev/null +++ b/app/views/layouts/_matomo.html.haml @@ -0,0 +1,15 @@ +<!-- Matomo --> += javascript_tag do + :plain + var _paq = window._paq = window._paq || []; + _paq.push(['trackPageView']); + _paq.push(['enableLinkTracking']); + (function() { + var u="//#{extra_config.matomo_url}/"; + _paq.push(['setTrackerUrl', u+'matomo.php']); + _paq.push(['setSiteId', "#{extra_config.matomo_site_id}"]); + var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; + g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); + })(); +<noscript><p><img src="//#{extra_config.matomo_url}/matomo.php?idsite=#{extra_config.matomo_site_id}" style="border:0;" alt="" /></p></noscript> +<!-- End Matomo Code --> diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index f6fc49393d8..c552454caa7 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -17,6 +17,7 @@ = render_account_recovery_regular_check = render_if_exists "layouts/header/ee_subscribable_banner" = render_if_exists "shared/namespace_storage_limit_alert" + = render_if_exists "shared/new_user_signups_cap_reached_alert" = yield :customize_homepage_banner - unless @hide_breadcrumbs = render "layouts/nav/breadcrumbs" diff --git a/app/views/layouts/_piwik.html.haml b/app/views/layouts/_piwik.html.haml deleted file mode 100644 index 361a7b03180..00000000000 --- a/app/views/layouts/_piwik.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- Piwik --> -= javascript_tag nonce: true do - :plain - var _paq = _paq || []; - _paq.push(['trackPageView']); - _paq.push(['enableLinkTracking']); - (function() { - var u="//#{extra_config.piwik_url}/"; - _paq.push(['setTrackerUrl', u+'piwik.php']); - _paq.push(['setSiteId', "#{extra_config.piwik_site_id}"]); - var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; - g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s); - })(); -<noscript><p><img src="//#{extra_config.piwik_url}/piwik.php?idsite=#{extra_config.piwik_site_id}" style="border:0;" alt="" /></p></noscript> -<!-- End Piwik Code --> diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml index d7ff5ad1094..9d14dfb3786 100644 --- a/app/views/layouts/_snowplow.html.haml +++ b/app/views/layouts/_snowplow.html.haml @@ -1,6 +1,6 @@ - return unless Gitlab::CurrentSettings.snowplow_enabled? -= javascript_tag nonce: true do += javascript_tag do :plain ;(function(p,l,o,w,i,n,g){if(!p[i]){p.GlobalSnowplowNamespace=p.GlobalSnowplowNamespace||[]; p.GlobalSnowplowNamespace.push(i);p[i]=function(){(p[i].q=p[i].q||[]).push(arguments) diff --git a/app/views/layouts/_startup_css_activation.haml b/app/views/layouts/_startup_css_activation.haml index a426d686c34..5fb53385acc 100644 --- a/app/views/layouts/_startup_css_activation.haml +++ b/app/views/layouts/_startup_css_activation.haml @@ -1,6 +1,6 @@ - return unless use_startup_css? -= javascript_tag nonce: true do += javascript_tag do :plain document.querySelectorAll('link[media="print"]').forEach(linkTag => { linkTag.setAttribute('data-startupcss', 'loading'); diff --git a/app/views/layouts/_startup_js.html.haml b/app/views/layouts/_startup_js.html.haml index 9c488e4f40d..35cd191c600 100644 --- a/app/views/layouts/_startup_js.html.haml +++ b/app/views/layouts/_startup_js.html.haml @@ -1,6 +1,6 @@ - return unless page_startup_api_calls.present? || page_startup_graphql_calls.present? -= javascript_tag nonce: true do += javascript_tag do :plain var gl = window.gl || {}; gl.startup_calls = #{page_startup_api_calls.to_json}; diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml index dc924a0e25d..25fe4c898ca 100644 --- a/app/views/layouts/errors.html.haml +++ b/app/views/layouts/errors.html.haml @@ -8,7 +8,7 @@ %body .page-container = yield - = javascript_tag nonce: true do + = javascript_tag do :plain (function(){ var goBackElement = document.querySelector('.js-go-back'); diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 6d2c5870e43..58fed89dfe7 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -8,7 +8,7 @@ - content_for :page_specific_javascripts do - if current_user - = javascript_tag nonce: true do + = javascript_tag do :plain window.uploads_path = "#{group_uploads_path(@group)}"; diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index addf2375222..d7ca93a296b 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -18,7 +18,7 @@ - if can?(current_user, :update_user_status, current_user) %li %button.btn.menu-item.js-set-status-modal-trigger{ type: 'button' } - - if current_user.status.present? + - if show_status_emoji?(current_user.status) || user_status_set_to_busy?(current_user.status) = s_('SetStatusModal|Edit status') - else = s_('SetStatusModal|Set status') diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 794d1589172..70ab0a56581 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -74,6 +74,7 @@ %span.gl-sr-only = s_('Nav|Help') = sprite_icon('question') + %span.notification-dot.rounded-circle.gl-absolute = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right = render 'layouts/header/help_dropdown' @@ -101,7 +102,7 @@ = sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left') - if ::Feature.enabled?(:whats_new_drawer, current_user) - #whats-new-app{ data: { storage_key: whats_new_storage_key } } + #whats-new-app{ data: { storage_key: whats_new_storage_key, versions: whats_new_versions, gitlab_dot_com: Gitlab.dev_env_org_or_com? } } - if can?(current_user, :update_user_status, current_user) .js-set-status-modal-wrapper{ data: user_status_data } diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml index 17f6e9af61a..0d4ecfc5a10 100644 --- a/app/views/layouts/jira_connect.html.haml +++ b/app/views/layouts/jira_connect.html.haml @@ -5,9 +5,11 @@ GitLab = stylesheet_link_tag 'https://unpkg.com/@atlaskit/css-reset@3.0.6/dist/bundle.css' = stylesheet_link_tag 'https://unpkg.com/@atlaskit/reduced-ui-pack@10.5.5/dist/bundle.css' + = yield :page_specific_styles + = javascript_include_tag 'https://connect-cdn.atl-paas.net/all.js' = javascript_include_tag 'https://unpkg.com/jquery@3.3.1/dist/jquery.min.js' - = yield :page_specific_styles + = Gon::Base.render_data(nonce: content_security_policy_nonce) = yield :head %body .ac-content diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml index f0cdb3d1a51..43f1011a85b 100644 --- a/app/views/layouts/nav/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/_breadcrumbs.html.haml @@ -1,6 +1,7 @@ - container = @no_breadcrumb_container ? 'container-fluid' : container_class - hide_top_links = @hide_top_links || false -- push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link) +- unless @skip_current_level_breadcrumb + - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link) %nav.breadcrumbs{ role: "navigation", class: [container, @content_class] } .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border) } @@ -16,8 +17,10 @@ - @breadcrumbs_extra_links.each do |extra| = breadcrumb_list_item link_to(extra[:text], extra[:link]) = render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :after - %li - %h2.breadcrumbs-sub-title= link_to @breadcrumb_title, breadcrumb_title_link + - unless @skip_current_level_breadcrumb + %li + %h2.breadcrumbs-sub-title + = link_to @breadcrumb_title, breadcrumb_title_link %script{ type:'application/ld+json' } :plain #{schema_breadcrumb_json} diff --git a/app/views/layouts/nav/groups_dropdown/_show.html.haml b/app/views/layouts/nav/groups_dropdown/_show.html.haml index 3ce1fa6bcca..d0394451a61 100644 --- a/app/views/layouts/nav/groups_dropdown/_show.html.haml +++ b/app/views/layouts/nav/groups_dropdown/_show.html.haml @@ -3,10 +3,10 @@ .frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar %ul = nav_link(path: 'dashboard/groups#index') do - = link_to dashboard_groups_path, class: 'qa-your-groups-link' do + = link_to dashboard_groups_path, class: 'qa-your-groups-link', data: { track_label: "groups_dropdown_your_groups", track_event: "click_link" } do = _('Your groups') = nav_link(path: 'groups#explore') do - = link_to explore_groups_path do + = link_to explore_groups_path, data: { track_label: "groups_dropdown_explore_groups", track_event: "click_link" } do = _('Explore groups') .frequent-items-dropdown-content #js-groups-dropdown{ data: { user_name: current_user.username, group: group_meta } } diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml index f2170f71532..91f999a9a74 100644 --- a/app/views/layouts/nav/projects_dropdown/_show.html.haml +++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml @@ -3,13 +3,13 @@ .frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar %ul = nav_link(path: 'dashboard/projects#index') do - = link_to dashboard_projects_path, class: 'qa-your-projects-link' do + = link_to dashboard_projects_path, class: 'qa-your-projects-link', data: { track_label: "projects_dropdown_your_projects", track_event: "click_link" } do = _('Your projects') = nav_link(path: 'projects#starred') do - = link_to starred_dashboard_projects_path do + = link_to starred_dashboard_projects_path, data: { track_label: "projects_dropdown_starred_projects", track_event: "click_link" } do = _('Starred projects') = nav_link(path: 'projects#trending') do - = link_to explore_root_path do + = link_to explore_root_path, data: { track_label: "projects_dropdown_explore_projects", track_event: "click_link" } do = _('Explore projects') .frequent-items-dropdown-content #js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } } diff --git a/app/views/layouts/nav/sidebar/_analytics_links.html.haml b/app/views/layouts/nav/sidebar/_analytics_links.html.haml index a99eb8cf457..970a1d5f2c7 100644 --- a/app/views/layouts/nav/sidebar/_analytics_links.html.haml +++ b/app/views/layouts/nav/sidebar/_analytics_links.html.haml @@ -4,7 +4,7 @@ - if navbar_links.any? = nav_link(path: all_paths) do - = link_to analytics_link.link, { data: { qa_selector: 'analytics_anchor' } } do + = link_to analytics_link.link, {class: 'shortcuts-analytics', data: { qa_selector: 'analytics_anchor' } } do .nav-icon-container = sprite_icon('chart') %span.nav-item-name{ data: { qa_selector: 'analytics_link' } } diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 5f4b1f8ad45..efe8e57cadf 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -159,11 +159,10 @@ %span = _('General') - - if group_level_integrations? - = nav_link(controller: :integrations) do - = link_to group_settings_integrations_path(@group), title: _('Integrations') do - %span - = _('Integrations') + = nav_link(controller: :integrations) do + = link_to group_settings_integrations_path(@group), title: _('Integrations') do + %span + = _('Integrations') = nav_link(path: 'groups#projects') do = link_to projects_group_path(@group), title: _('Projects') do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 5ff774d5d9c..5cadabd5f90 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -262,6 +262,8 @@ %span = _('Incidents') + = render_if_exists 'projects/sidebar/oncall_schedules' + - if project_nav_tab? :serverless = nav_link(controller: :functions) do = link_to project_serverless_functions_path(@project), title: _('Serverless') do @@ -322,7 +324,8 @@ = render_if_exists 'layouts/nav/sidebar/project_packages_link' - = render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user) + - if project_nav_tab? :analytics + = render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user) - if project_nav_tab?(:confluence) - confluence_url = project_wikis_confluence_path(@project) @@ -435,8 +438,6 @@ %span = _('Pages') - = render_if_exists 'projects/sidebar/settings_audit_events' - = render 'shared/sidebar_toggle_button' -# Shortcut to Project > Activity diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 62e5431e290..2df502d2899 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -10,7 +10,7 @@ - content_for :project_javascripts do - project = @target_project || @project - if current_user - = javascript_tag nonce: true do + = javascript_tag do :plain window.uploads_path = "#{project_uploads_path(project)}"; diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index 6cc53ba3342..54b5ec85ccc 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -4,7 +4,7 @@ - content_for :page_specific_javascripts do - if snippets_upload_path - = javascript_tag nonce: true do + = javascript_tag do :plain window.uploads_path = "#{snippets_upload_path}"; diff --git a/app/views/notify/issue_cloned_email.html.haml b/app/views/notify/issue_cloned_email.html.haml new file mode 100644 index 00000000000..a9e21e74e22 --- /dev/null +++ b/app/views/notify/issue_cloned_email.html.haml @@ -0,0 +1,7 @@ +- author_link = link_to @author.name, user_url(@author) +- if @can_access_project + - string = _("%{author_link} cloned %{original_issue} to %{new_issue}.").html_safe +- else + - string = _("%{author_link} cloned %{original_issue}. You don't have access to the new project.").html_safe +%p + = string % { author_link: author_link, original_issue: issue_reference_link(@issue), new_issue: issue_reference_link(@new_issue, full: true) } diff --git a/app/views/notify/issue_cloned_email.text.erb b/app/views/notify/issue_cloned_email.text.erb new file mode 100644 index 00000000000..8d3ff14df5a --- /dev/null +++ b/app/views/notify/issue_cloned_email.text.erb @@ -0,0 +1,8 @@ +Issue was cloned. + +<% if @can_access_project %> + New issue location: + <%= project_issue_url(@new_issue.project, @new_issue) %> +<% else %> + You don't have access to the project. +<% end %> diff --git a/app/views/notify/new_release_email.html.haml b/app/views/notify/new_release_email.html.haml index 45e99f3c07a..9cef4cd85cd 100644 --- a/app/views/notify/new_release_email.html.haml +++ b/app/views/notify/new_release_email.html.haml @@ -15,4 +15,4 @@ %p %h4= _("Release notes:") - = markdown_field(@release, :description) + = markdown(@release.description, pipeline: :email, author: @release.author) diff --git a/app/views/notify/user_admin_rejection_email.html.haml b/app/views/notify/user_admin_rejection_email.html.haml new file mode 100644 index 00000000000..24d6c05fa38 --- /dev/null +++ b/app/views/notify/user_admin_rejection_email.html.haml @@ -0,0 +1,5 @@ += email_default_heading(_('Hello %{name},') % { name: @name }) +%p + = _('Your request to join %{host} has been rejected.').html_safe % { host: link_to(root_url, root_url) } +%p + = _('Please contact your GitLab administrator if you think this is an error.') diff --git a/app/views/notify/user_admin_rejection_email.text.erb b/app/views/notify/user_admin_rejection_email.text.erb new file mode 100644 index 00000000000..cc676b82934 --- /dev/null +++ b/app/views/notify/user_admin_rejection_email.text.erb @@ -0,0 +1,6 @@ +<%= _('Hello %{name},') % { name: @name } %> + +<%= _('Your request to join %{host} has been rejected.') % { host: root_url } %> + +<%= _('Please contact your GitLab administrator if you think this is an error.') %> + diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index fed40b7f119..ca64c5f57b3 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -7,6 +7,14 @@ .gl-alert-body = s_('Profiles|Some options are unavailable for LDAP accounts') +- if params[:two_factor_auth_enabled_successfully] + .gl-alert.gl-alert-success.gl-my-5{ role: 'alert' } + = sprite_icon('check-circle', size: 16, css_class: 'gl-alert-icon gl-alert-icon-no-title') + %button.gl-alert-dismiss.js-close-2fa-enabled-success-alert{ type: 'button', aria: { label: _('Close') } } + = sprite_icon('close', size: 16) + .gl-alert-body + = _('Congratulations! You have enabled Two-factor Authentication!') + .row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 @@ -71,6 +79,11 @@ %strong= current_user.solo_owned_groups.map(&:name).join(', ') %p = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.') + - elsif !current_user.can_remove_self? + %p + = s_('Profiles|GitLab is unable to verify your identity automatically.') + %p + = s_('Profiles|Please email %{data_request} to begin the account deletion process.').html_safe % { data_request: mail_to('personal-data-request@gitlab.com') } - else %p = s_("Profiles|You don't have access to delete this user.") diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 6a420d7996a..81a543de7a3 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -21,7 +21,7 @@ %strong= _('Oops, are you sure?') %p= s_("Profiles|This doesn't look like a public SSH key, are you sure you want to add it? It will be publicly visible.") - %button.btn.btn-success.js-add-ssh-key-validation-confirm-submit= _("Yes, add it") + %button.btn.gl-button.btn-success.js-add-ssh-key-validation-confirm-submit= _("Yes, add it") .gl-mt-3 = f.submit s_('Profiles|Add key'), class: "gl-button btn btn-success js-add-ssh-key-validation-original-submit qa-add-key-button" diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml index ea698a296fb..b1578886098 100644 --- a/app/views/profiles/notifications/_group_settings.html.haml +++ b/app/views/profiles/notifications/_group_settings.html.haml @@ -12,5 +12,5 @@ = render 'shared/notifications/button', notification_setting: setting, emails_disabled: emails_disabled .table-section.section-30 - = form_for setting, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications' } do |f| + = form_for setting, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f| = f.select :notification_email, @user.public_verified_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email' diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 9c5cfe35cda..e1345a94fb1 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -3,10 +3,14 @@ %div - if @user.errors.any? - .gl-alert.gl-alert-danger - %ul - - @user.errors.full_messages.each do |msg| - %li= msg + .gl-alert.gl-alert-danger.gl-my-5 + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', css_class: 'gl-icon') + = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + %ul + - @user.errors.full_messages.each do |msg| + %li= msg = hidden_field_tag :notification_type, 'global' .row.gl-mt-3 diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 11750f2a6d5..577b64ba17a 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -32,22 +32,23 @@ active_tokens: @active_personal_access_tokens, revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) } -%hr -.row.gl-mt-3 - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_('AccessTokens|Feed token') - %p - = s_('AccessTokens|Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar, and is included in those feed URLs.') - %p - = s_('AccessTokens|It cannot be used to access any other data.') - .col-lg-8.feed-token-reset - = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold' - = text_field_tag :feed_token, current_user.feed_token, class: 'form-control js-select-on-focus', readonly: true - %p.form-text.text-muted - - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') } - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link } - = reset_message.html_safe +- unless Gitlab::CurrentSettings.disable_feed_token + %hr + .row.gl-mt-3 + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0 + = s_('AccessTokens|Feed token') + %p + = s_('AccessTokens|Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar, and is included in those feed URLs.') + %p + = s_('AccessTokens|It cannot be used to access any other data.') + .col-lg-8.feed-token-reset + = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold' + = text_field_tag :feed_token, current_user.feed_token, class: 'form-control js-select-on-focus', readonly: true + %p.form-text.text-muted + - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') } + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link } + = reset_message.html_safe - if incoming_email_token_enabled? %hr diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index ca5972f1b46..aeecb0c0d72 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -79,13 +79,12 @@ = f.check_box :show_whitespace_in_diffs, class: 'form-check-input' = f.label :show_whitespace_in_diffs, class: 'form-check-label' do = s_('Preferences|Show whitespace changes in diffs') - - if Feature.enabled?(:view_diffs_file_by_file, default_enabled: true) - .form-group.form-check - = f.check_box :view_diffs_file_by_file, class: 'form-check-input' - = f.label :view_diffs_file_by_file, class: 'form-check-label' do - = s_("Preferences|Show one file at a time on merge request's Changes tab") - .form-text.text-muted - = s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.") + .form-group.form-check + = f.check_box :view_diffs_file_by_file, class: 'form-check-input' + = f.label :view_diffs_file_by_file, class: 'form-check-label' do + = s_("Preferences|Show one file at a time on merge request's Changes tab") + .form-text.text-muted + = s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.") .form-group = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold' = f.number_field :tab_width, diff --git a/app/views/profiles/two_factor_auths/_codes.html.haml b/app/views/profiles/two_factor_auths/_codes.html.haml index 2cb7e022912..178a9d3f8b4 100644 --- a/app/views/profiles/two_factor_auths/_codes.html.haml +++ b/app/views/profiles/two_factor_auths/_codes.html.haml @@ -1,13 +1,18 @@ -%p.slead - - lose_2fa_message = _('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 %{b_start}will%{b_end} lose access to your account.') % { b_start:'<b>', b_end:'</b>' } - = lose_2fa_message.html_safe +- show_success_alert = local_assigns.fetch(:show_success_alert, nil) -.codes.card{ data: { qa_selector: 'codes_content' } } - %ul - - @codes.each do |code| - %li - %span.monospace{ data: { qa_selector: 'code_content' } }= code +- if Feature.enabled?(:vue_2fa_recovery_codes, current_user, default_enabled: true) + .js-2fa-recovery-codes{ data: { codes: @codes.to_json, profile_account_path: profile_account_path(two_factor_auth_enabled_successfully: show_success_alert) } } +- else + %p.slead + - lose_2fa_message = _('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 %{b_start}will%{b_end} lose access to your account.') % { b_start:'<b>', b_end:'</b>' } + = lose_2fa_message.html_safe -.d-flex - = link_to _('Proceed'), profile_account_path, class: 'gl-button btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' } - = link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'gl-button btn btn-default' + .codes.card{ data: { qa_selector: 'codes_content' } } + %ul + - @codes.each do |code| + %li + %span.monospace{ data: { qa_selector: 'code_content' } }= code + + .d-flex + = link_to _('Proceed'), profile_account_path, class: 'gl-button btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' } + = link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'gl-button btn btn-default' diff --git a/app/views/profiles/two_factor_auths/codes.html.haml b/app/views/profiles/two_factor_auths/codes.html.haml index 53907ebffab..0d8c5ec5dbf 100644 --- a/app/views/profiles/two_factor_auths/codes.html.haml +++ b/app/views/profiles/two_factor_auths/codes.html.haml @@ -1,6 +1,4 @@ - page_title _('Recovery Codes'), _('Two-factor Authentication') +- add_page_specific_style 'page_bundles/profile_two_factor_auth' -%h3.page-title - = _('Two-factor Authentication Recovery codes') -%hr = render 'codes' diff --git a/app/views/profiles/two_factor_auths/create.html.haml b/app/views/profiles/two_factor_auths/create.html.haml index 5a756cca0ab..be4800024cf 100644 --- a/app/views/profiles/two_factor_auths/create.html.haml +++ b/app/views/profiles/two_factor_auths/create.html.haml @@ -1,6 +1,8 @@ - page_title _('Two-factor Authentication'), _('Account') +- add_page_specific_style 'page_bundles/profile_two_factor_auth' -.gl-alert.gl-alert-success.gl-mb-5 - = _('Congratulations! You have enabled Two-factor Authentication!') +- unless Feature.enabled?(:vue_2fa_recovery_codes, current_user, default_enabled: true) + .gl-alert.gl-alert-success.gl-mb-5 + = _('Congratulations! You have enabled Two-factor Authentication!') -= render 'codes' += render 'codes', show_success_alert: true diff --git a/app/views/projects/_archived_notice.html.haml b/app/views/projects/_archived_notice.html.haml index 522693ae24a..dcece8ab42f 100644 --- a/app/views/projects/_archived_notice.html.haml +++ b/app/views/projects/_archived_notice.html.haml @@ -1,5 +1,5 @@ - if project.archived? .text-warning.center.prepend-top-20 %p - = icon("exclamation-triangle fw") + = sprite_icon('warning-solid') = _('Archived project! Repository and other project resources are read only') diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml index 5f7ed46297b..87c0933747d 100644 --- a/app/views/projects/_commit_button.html.haml +++ b/app/views/projects/_commit_button.html.haml @@ -1,7 +1,7 @@ .form-actions - = button_tag 'Commit changes', id: 'commit-changes', class: 'btn commit-btn js-commit-button btn-success qa-commit-button' + = button_tag 'Commit changes', id: 'commit-changes', class: 'gl-button btn btn-success js-commit-button qa-commit-button' = link_to 'Cancel', cancel_path, - class: 'btn btn-cancel', data: {confirm: leave_edit_message} + class: 'gl-button btn btn-default btn-cancel', data: {confirm: leave_edit_message} = render 'shared/projects/edit_information' diff --git a/app/views/projects/_customize_workflow.html.haml b/app/views/projects/_customize_workflow.html.haml index a41791f0eca..8e4e5ca93e0 100644 --- a/app/views/projects/_customize_workflow.html.haml +++ b/app/views/projects/_customize_workflow.html.haml @@ -5,4 +5,4 @@ %p Get started with GitLab by enabling features that work best for your project. From issues and wikis, to merge requests and pipelines, GitLab can help manage your workflow from idea to production! - if can?(current_user, :admin_project, @project) - = link_to "Get started", edit_project_path(@project), class: "btn btn-success" + = link_to "Get started", edit_project_path(@project), class: "gl-button btn btn-success" diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 81c42de13f0..88dcc74a465 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -3,14 +3,17 @@ - project = local_assigns.fetch(:project) { @project } - show_auto_devops_callout = show_auto_devops_callout?(@project) - add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0) -- if @tree.readme - - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, @tree.readme.path), viewer: "rich", format: "json") +- if readme_path = @project.repository.readme_path + - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json") #tree-holder.tree-holder.clearfix .nav-block = render 'projects/tree/tree_header', tree: @tree #js-last-commit + .info-well.gl-display-none.gl-display-sm-flex.project-last-commit + .gl-spinner-container.m-auto + = loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom') - if is_project_overview .project-buttons.gl-mb-3.js-show-on-project-root diff --git a/app/views/projects/_fork_suggestion.html.haml b/app/views/projects/_fork_suggestion.html.haml index 0b616a0c1ce..9e6ff4a5d7a 100644 --- a/app/views/projects/_fork_suggestion.html.haml +++ b/app/views/projects/_fork_suggestion.html.haml @@ -6,6 +6,6 @@ edit files in this project directly. Please fork this project, make your changes there, and submit a merge request. - = link_to 'Fork', nil, method: :post, class: 'js-fork-suggestion-button btn btn-grouped btn-inverted btn-success' - %button.js-cancel-fork-suggestion-button.btn.btn-grouped{ type: 'button' } + = link_to 'Fork', nil, method: :post, class: 'js-fork-suggestion-button gl-button btn btn-grouped btn-inverted btn-success' + %button.js-cancel-fork-suggestion-button.gl-button.btn.btn-grouped{ type: 'button' } Cancel diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 569255ec2e5..ebb0dd8b39f 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -19,7 +19,7 @@ = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project .home-panel-metadata.d-flex.flex-wrap.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal - if can?(current_user, :read_project, @project) - %span.text-secondary{ itemprop: 'identifier' } + %span.text-secondary{ itemprop: 'identifier', data: { qa_selector: 'project_id_content' } } = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } - if current_user %span.access-request-links.gl-ml-3 @@ -63,7 +63,7 @@ .home-panel-home-desc.mt-1 - if @project.description.present? .home-panel-description.text-break - .home-panel-description-markdown.read-more-container{ itemprop: 'abstract' } + .home-panel-description-markdown.read-more-container{ itemprop: 'description' } = markdown_field(@project, :description) %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } = _("Read more") diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index 8b94133fd8a..27d75591d3e 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -8,73 +8,77 @@ .import-buttons - if gitlab_project_import_enabled? .import_gitlab_project.has-tooltip{ data: { container: 'body' } } - = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit', **tracking_attrs(track_label, 'click_button', 'gitlab_export') do - = sprite_icon('tanuki') + = link_to new_import_gitlab_project_path, class: 'gl-button btn-default btn btn_import_gitlab_project project-submit', **tracking_attrs(track_label, 'click_button', 'gitlab_export') do + .gl-button-icon + = sprite_icon('tanuki') = _("GitLab export") - if github_import_enabled? %div - = link_to new_import_github_path, class: 'btn js-import-github', **tracking_attrs(track_label, 'click_button', 'github') do - = sprite_icon('github') + = link_to new_import_github_path, class: 'gl-button btn-default btn js-import-github', **tracking_attrs(track_label, 'click_button', 'github') do + .gl-button-icon + = sprite_icon('github') GitHub - if bitbucket_import_enabled? %div - = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}", + = link_to status_import_bitbucket_path, class: "gl-button btn-default btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}", **tracking_attrs(track_label, 'click_button', 'bitbucket_cloud') do - = sprite_icon('bitbucket') + .gl-button-icon + = sprite_icon('bitbucket') Bitbucket Cloud - unless bitbucket_import_configured? = render 'projects/bitbucket_import_modal' - if bitbucket_server_import_enabled? %div - = link_to status_import_bitbucket_server_path, class: "btn import_bitbucket", **tracking_attrs(track_label, 'click_button', 'bitbucket_server') do - = sprite_icon('bitbucket') + = link_to status_import_bitbucket_server_path, class: "gl-button btn-default btn import_bitbucket", **tracking_attrs(track_label, 'click_button', 'bitbucket_server') do + .gl-button-icon + = sprite_icon('bitbucket') Bitbucket Server %div - if gitlab_import_enabled? %div - = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}", + = link_to status_import_gitlab_path, class: "gl-button btn-default btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}", **tracking_attrs(track_label, 'click_button', 'gitlab_com') do - = sprite_icon('tanuki') + .gl-button-icon + = sprite_icon('tanuki') = _("GitLab.com") - unless gitlab_import_configured? = render 'projects/gitlab_import_modal' - - if google_code_import_enabled? - %div - = link_to new_import_google_code_path, class: 'btn import_google_code', **tracking_attrs(track_label, 'click_button', 'google_code') do - = sprite_icon('google') - Google Code - - if fogbugz_import_enabled? %div - = link_to new_import_fogbugz_path, class: 'btn import_fogbugz', **tracking_attrs(track_label, 'click_button', 'fogbugz') do - = sprite_icon('bug') + = link_to new_import_fogbugz_path, class: 'gl-button btn-default btn import_fogbugz', **tracking_attrs(track_label, 'click_button', 'fogbugz') do + .gl-button-icon + = sprite_icon('bug') FogBugz - if gitea_import_enabled? %div - = link_to new_import_gitea_path, class: 'btn import_gitea', **tracking_attrs(track_label, 'click_button', 'gitea') do - = custom_icon('gitea_logo') + = link_to new_import_gitea_path, class: 'gl-button btn-default btn import_gitea', **tracking_attrs(track_label, 'click_button', 'gitea') do + .gl-button-icon + = custom_icon('gitea_logo') Gitea - if git_import_enabled? %div - %button.btn.btn-svg.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' }, **tracking_attrs(track_label, 'click_button', 'repo_url') } - = sprite_icon('link', css_class: 'gl-icon') + %button.gl-button.btn-default.btn.btn-svg.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' }, **tracking_attrs(track_label, 'click_button', 'repo_url') } + .gl-button-icon + = sprite_icon('link', css_class: 'gl-icon') = _('Repo by URL') - if manifest_import_enabled? %div - = link_to new_import_manifest_path, class: 'btn import_manifest', **tracking_attrs(track_label, 'click_button', 'manifest_file') do - = sprite_icon('doc-text') + = link_to new_import_manifest_path, class: 'gl-button btn-default btn import_manifest', **tracking_attrs(track_label, 'click_button', 'manifest_file') do + .gl-button-icon + = sprite_icon('doc-text') Manifest file - if phabricator_import_enabled? %div - = link_to new_import_phabricator_path, class: 'btn import_phabricator', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "phabricator" } do - = custom_icon('issues') + = link_to new_import_phabricator_path, class: 'gl-button btn-default btn import_phabricator', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "phabricator" } do + .gl-button-icon + = custom_icon('issues') = _("Phabricator Tasks") diff --git a/app/views/projects/_invite_members.html.haml b/app/views/projects/_invite_members.html.haml new file mode 100644 index 00000000000..ef030cabc93 --- /dev/null +++ b/app/views/projects/_invite_members.html.haml @@ -0,0 +1,8 @@ +%h4.gl-mt-0.gl-mb-3{ data: { testid: 'invite-member-section', + track_label: 'invite_members_empty_project', + track_event: 'render' } } + = s_('InviteMember|Invite your team') +%p= s_('InviteMember|Add members to this project and start collaborating with your team.') += link_to s_('InviteMember|Invite members'), project_project_members_path(@project, sort: :access_level_desc), + class: 'gl-button btn btn-success gl-mb-8 gl-xs-w-full', + data: { track_event: 'click_button', track_label: 'invite_members_empty_project' } diff --git a/app/views/projects/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml index 79221c59ae4..d1ff52548cd 100644 --- a/app/views/projects/_project_templates.html.haml +++ b/app/views/projects/_project_templates.html.haml @@ -5,17 +5,11 @@ %li.built-in-tab %a.nav-link.active{ href: "#built-in", data: { toggle: 'tab'} } = _('Built-in') - %span.badge.badge-pill= Gitlab::ProjectTemplate.all.count - %li.sample-data-templates-tab - %a.nav-link{ href: "#sample-data-templates", data: { toggle: 'tab'} } - = _('Sample Data') - %span.badge.badge-pill= Gitlab::SampleDataTemplate.all.count + %span.badge.badge-pill= Gitlab::SampleDataTemplate.all.count + Gitlab::ProjectTemplate.all.count .tab-content .project-templates-buttons.import-buttons.tab-pane.active#built-in - = render partial: 'projects/project_templates/template', collection: Gitlab::ProjectTemplate.all - .project-templates-buttons.import-buttons.tab-pane#sample-data-templates - = render partial: 'projects/project_templates/template', collection: Gitlab::SampleDataTemplate.all + = render partial: 'projects/project_templates/template', collection: Gitlab::SampleDataTemplate.all + Gitlab::ProjectTemplate.all .project-fields-form = render 'projects/project_templates/project_fields_form' diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index 7c08955983a..3b2b3a2ba67 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -12,6 +12,7 @@ enabled: "#{@project.service_desk_enabled}", incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled), custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled), + custom_email_enabled: "#{@project.service_desk_custom_address_enabled?}", selected_template: "#{@project.service_desk_setting&.issue_template_key}", outgoing_name: "#{@project.service_desk_setting&.outgoing_name}", project_key: "#{@project.service_desk_setting&.project_key}", diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml index 5b77e31eb00..7afbd85cd6d 100644 --- a/app/views/projects/blob/_content.html.haml +++ b/app/views/projects/blob/_content.html.haml @@ -1,10 +1,6 @@ - simple_viewer = blob.simple_viewer - rich_viewer = blob.rich_viewer - rich_viewer_active = rich_viewer && params[:viewer] != 'simple' -- blob_data = defined?(@blob) ? @blob.data : {} -- is_ci_config_file = defined?(@blob) && defined?(@project) ? editing_ci_config?.to_s : 'false' - -#js-blob-toggle-graph-preview{ data: { blob_data: blob_data, is_ci_config_file: is_ci_config_file } } = render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml index 8e3cf607bbf..c6b13deaece 100644 --- a/app/views/projects/blob/_viewer_switcher.html.haml +++ b/app/views/projects/blob/_viewer_switcher.html.haml @@ -8,5 +8,5 @@ = sprite_icon(simple_viewer.switcher_icon) - rich_label = "Display #{rich_viewer.switcher_title}" - %button.btn.gl-button.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }> + %button.btn.gl-button.btn-default.btn-sm.js-blob-viewer-switch-btn.gl-mr-3.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }> = sprite_icon(rich_viewer.switcher_icon) diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 54c47e7af38..abfed450316 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -9,9 +9,8 @@ = link_to "the file", project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer', class: 'gl-link' and make sure your changes will not unintentionally remove theirs. -.editor-title-row - %h3.page-title.blob-edit-page-title - Edit file +%h3.page-title.blob-edit-page-title + Edit file .file-editor %ul.nav-links.no-bottom.js-edit-mode.nav.nav-tabs %li.active diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml index 2a33afabb7c..8722819fe4f 100644 --- a/app/views/projects/blob/new.html.haml +++ b/app/views/projects/blob/new.html.haml @@ -1,9 +1,8 @@ - breadcrumb_title _("Repository") - page_title _("New File"), @path.presence, @ref -.editor-title-row - %h3.page-title.blob-new-page-title - New file +%h3.page-title.blob-new-page-title + New file .file-editor = form_tag(project_create_blob_path(@project, @id), method: :post, class: 'js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths(@project)) do = render 'projects/blob/editor', ref: @ref diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml index aedfb64d3e4..db4b04eaeb8 100644 --- a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml +++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml @@ -1,4 +1,4 @@ -= icon('spinner spin fw') += loading_icon(css_class: "gl-vertical-align-text-bottom mr-1") = _('Metrics Dashboard YAML definition') + '…' = link_to _('Learn more'), help_page_path('operations/metrics/dashboards/yaml.md') diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index cf58cff7445..938dfc69500 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -2,7 +2,7 @@ - dropdown_class = local_assigns.fetch(:dropdown_class, '') .git-clone-holder.js-git-clone-holder - %a#clone-dropdown.gl-button.btn.btn-primary.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } + %a#clone-dropdown.gl-button.btn.btn-info.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } %span.gl-mr-2.js-clone-dropdown-label = _('Clone') = sprite_icon("chevron-down", css_class: "icon") @@ -12,7 +12,7 @@ %label.label-bold = _('Clone with SSH') .input-group - = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: 'Project clone URL' } + = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: _('Repository clone URL') } .input-group-append = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' @@ -21,7 +21,7 @@ %label.label-bold = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } .input-group - = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: 'Project clone URL' } + = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: _('Repository clone URL') } .input-group-append = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 138f5569218..8b4411776bc 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -97,7 +97,7 @@ #{job.coverage}% %td - .float-right + .gl-display-flex - if can?(current_user, :read_build, job) && job.artifacts? = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build gl-button btn-icon btn-svg' do = sprite_icon('download') diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml index 0e032f2575e..f1f8658fa3b 100644 --- a/app/views/projects/ci/pipeline_editor/show.html.haml +++ b/app/views/projects/ci/pipeline_editor/show.html.haml @@ -3,4 +3,6 @@ #js-pipeline-editor{ data: { "ci-config-path": @project.ci_config_path_or_default, "project-path" => @project.full_path, "default-branch" => @project.default_branch, + "commit-id" => @project.commit ? @project.commit.id : '', + "new-merge-request-path" => namespace_project_new_merge_request_path, } } diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 86c80f1a8ae..6f2797654d0 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -30,7 +30,7 @@ .dropdown.inline %a.btn.gl-button.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } } %span= _('Options') - = icon('caret-down') + = sprite_icon('chevron-down', css_class: 'gl-text-gray-500') %ul.dropdown-menu.dropdown-menu-right %li.d-block.d-sm-none = link_to project_tree_path(@project, @commit) do diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml index 4964b1b8ee7..357ad467539 100644 --- a/app/views/projects/commit/_verified_signature_badge.html.haml +++ b/app/views/projects/commit/_verified_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = capture do - = _('This commit was signed with a <strong>verified</strong> signature and the committer email is verified to belong to the same user.').html_safe + = html_escape(_('This commit was signed with a %{strong_open}verified%{strong_close} signature and the committer email is verified to belong to the same user.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true } diff --git a/app/views/projects/commit/x509/_unverified_signature_badge.html.haml b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml index 680cc32c7e6..6204a6977c0 100644 --- a/app/views/projects/commit/x509/_unverified_signature_badge.html.haml +++ b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = capture do - = _('This commit was signed with an <strong>unverified</strong> signature.').html_safe + = html_escape(_('This commit was signed with an %{strong_open}unverified%{strong_close} signature.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true } diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 63cc96c2c05..a8a928515fe 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -37,7 +37,9 @@ = _('Add previously merged commits') - if commits.size == 0 && context_commits.nil? - .mt-4.text-center - .bold + .commits-empty.gl-mt-6 + = custom_icon('illustration_no_commits') + %h4 = _('Your search didn\'t match any commits.') - = _('Try changing or removing filters.') + %p + = _('Try changing or removing filters.') diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 94bdab53cd0..a14f75259ec 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -24,7 +24,7 @@ .control = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do - = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control search-text-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false } + = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control gl-form-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full gl-inset-border-1-gray-200!', spellcheck: false } .control.d-none.d-md-block = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn gl-button btn-svg' do = sprite_icon('rss', css_class: 'qa-rss-icon') diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index a257f2e9433..0c0530110c5 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -28,4 +28,4 @@ - if @merge_request.present? = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'gl-ml-3 btn' - elsif create_mr_button? - = link_to _("Create merge request"), create_mr_path, class: 'gl-ml-3 btn' + = link_to _("Create merge request"), create_mr_path, class: 'gl-ml-3 btn gl-button' diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index b98ab9757fa..fc3710d3609 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -2,13 +2,6 @@ - add_page_specific_style 'page_bundles/cycle_analytics' #cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - - if @cycle_analytics_no_data - %banner{ "v-if" => "!isOverviewDialogDismissed", - "documentation-link": help_page_path('user/analytics/value_stream_analytics.md'), - "v-on:dismiss-overview-dialog" => "dismissOverviewDialog()" } - .mb-3 - %h3 - = _("Value Stream Analytics") %gl-loading-icon{ "v-show" => "isLoading", "size" => "lg" } .wrapper{ "v-show" => "!isLoading && !hasError" } .card @@ -49,7 +42,7 @@ %span.has-tooltip{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" } = sprite_icon('question-o', css_class: 'gl-text-gray-500') %li.event-header.pl-3 - %span.stage-name.font-weight-bold + %span.stage-name.font-weight-bold{ "v-if" => "currentStage && currentStage.legend" } {{ currentStage ? __(currentStage.legend) : __('Related Issues') }} %span.has-tooltip{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" } = sprite_icon('question-o', css_class: 'gl-text-gray-500') diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml index 7f4b99f1a3f..c0fe143020a 100644 --- a/app/views/projects/deployments/_actions.haml +++ b/app/views/projects/deployments/_actions.haml @@ -5,7 +5,7 @@ .dropdown %button.dropdown.dropdown-new.btn.gl-button.btn-default.has-tooltip{ type: 'button', 'data-toggle' => 'dropdown', title: s_('Environments|Deploy to...') } = sprite_icon('play') - = icon('caret-down') + = sprite_icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right - actions.each do |action| - next unless can?(current_user, :update_build, action) diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml index 52e3e0fd997..509ed62b39d 100644 --- a/app/views/projects/deployments/_commit.html.haml +++ b/app/views/projects/deployments/_commit.html.haml @@ -2,7 +2,7 @@ .branch-commit.cgray - if deployment.ref %span.icon-container.gl-display-inline-block - = deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite') + = deployment.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('fork', css_class: 'sprite') = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name" .icon-container.commit-icon = custom_icon("icon_commit") diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index cb43527def1..4a00e0af9d9 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -1,5 +1,7 @@ - if local_assigns.fetch(:show_toggle, true) - %i.fa.diff-toggle-caret.fa-fw + %span.diff-toggle-caret + = sprite_icon('chevron-right', css_class: 'chevron-right gl-display-none') + = sprite_icon('chevron-down', css_class: 'chevron-down gl-display-none') - if diff_file.submodule? %span diff --git a/app/views/projects/diffs/_replaced_image_diff.html.haml b/app/views/projects/diffs/_replaced_image_diff.html.haml index 566dfe798c6..1f9533ade83 100644 --- a/app/views/projects/diffs/_replaced_image_diff.html.haml +++ b/app/views/projects/diffs/_replaced_image_diff.html.haml @@ -14,7 +14,7 @@ .wrap .frame.deleted = image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false) - %p.image-info.hide + %p.image-info.gl-display-none %span.meta-filesize= number_to_human_size(old_blob.size) | %strong W: @@ -24,7 +24,7 @@ %span.meta-height .wrap = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path } - %p.image-info.hide + %p.image-info.gl-display-none %span.meta-filesize= number_to_human_size(blob.size) | %strong W: @@ -33,7 +33,7 @@ %strong H: %span.meta-height - .swipe.view.hide + .swipe.view.gl-display-none .swipe-frame .frame.deleted.old-diff = image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false) @@ -43,7 +43,7 @@ %span.top-handle %span.bottom-handle - .onion-skin.view.hide + .onion-skin.view.gl-display-none .onion-skin-frame .frame.deleted = image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false) @@ -54,7 +54,7 @@ .dragger{ :style => "left: 0px;" } .opaque -.view-modes.hide +.view-modes.gl-display-none %ul.view-modes-menu %li.two-up{ data: { mode: 'two-up' } } 2-up %li.swipe{ data: { mode: 'swipe' } } Swipe diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml index 6429cf31bc3..8edaacf7552 100644 --- a/app/views/projects/diffs/_stats.html.haml +++ b/app/views/projects/diffs/_stats.html.haml @@ -4,7 +4,7 @@ Showing %button.diff-stats-summary-toggler.js-diff-stats-dropdown{ type: "button", data: { toggle: "dropdown", display: "static" } }< = pluralize(diff_files.size, "changed file") - = icon("caret-down", class: "gl-ml-2") + = sprite_icon("chevron-down", css_class: "gl-ml-2") %span.diff-stats-additions-deletions-expanded#diff-stats with %strong.cgreen= pluralize(sum_added_lines, 'addition') diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 10dd80501e0..387564f6408 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -7,7 +7,7 @@ .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar') %button.btn.btn-default.js-settings-toggle{ type: 'button' }= _('Collapse') - %p= _('Update your project name, topics, description and avatar.') + %p= _('Update your project name, topics, description, and avatar.') .settings-content= render 'projects/settings/general' %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded), data: { qa_selector: 'visibility_features_permissions_content' } } diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index c6d39f5bba0..2936eff45df 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,12 +1,13 @@ - @content_class = "limit-container-width" unless fluid_layout - default_branch_name = @project.default_branch || "master" -- breadcrumb_title _("Details") -- page_title _("Details") +- @skip_current_level_breadcrumb = true = render partial: 'flash_messages', locals: { project: @project } = render "home_panel" += render "invite_members" if experiment_enabled?(:invite_members_empty_project_version_a) && can_import_members? + %h4.gl-mt-0.gl-mb-3 = _('The repository for this project is empty') diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index 67dc07fb785..89c2c826067 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -15,7 +15,7 @@ = sort_options_hash[@sort] - else = sort_title_recently_created - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right %li - excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id] diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index 24d92e947bc..a92b02701c5 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -25,7 +25,7 @@ = (_("Code coverage statistics for master %{start_date} - %{end_date}") % {start_date: start_date, end_date: end_date}) - download_path = capture do #{@daily_coverage_options[:download_path]} - %a.btn.btn-sm{ href: "#{download_path}?#{@daily_coverage_options[:base_params].to_query}" } + %a.btn.gl-button.btn-sm{ href: "#{download_path}?#{@daily_coverage_options[:base_params].to_query}" } %small = _("Download raw data (.csv)") #js-code-coverage-chart{ data: { graph_endpoint: "#{@daily_coverage_options[:graph_api_path]}?#{@daily_coverage_options[:base_params].to_query}" } } diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index a73e367733b..c7508ef4d47 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -3,6 +3,6 @@ .sub-header-block.bg-gray-light.gl-p-5 .tree-ref-holder.inline.vertical-align-middle = render 'shared/ref_switcher', destination: 'graphs' - = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn' + = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn gl-button' .js-contributors-graph{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json),'data-project-branch': current_ref } diff --git a/app/views/projects/issuable/_show.html.haml b/app/views/projects/issuable/_show.html.haml index 48920c4e342..8015b205568 100644 --- a/app/views/projects/issuable/_show.html.haml +++ b/app/views/projects/issuable/_show.html.haml @@ -3,7 +3,6 @@ - if issuable.relocation_target - page_canonical_link issuable.relocation_target.present(current_user: current_user).web_url -= render_if_exists "projects/issues/alert_blocked", issue: issuable, current_user: current_user = render "projects/issues/alert_moved_from_service_desk", issue: issuable = render 'shared/issue_type/details_header', issuable: issuable diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 51130ae666c..2fbaa5812c0 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -3,11 +3,6 @@ - @gfm_form = true -- content_for :note_actions do - - if can?(current_user, :update_issue, @issue) - = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "gl-button btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "gl-button btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - %section.issuable-discussion.js-vue-notes-event #js-vue-notes{ data: { notes_data: notes_data(@issue).to_json, noteable_data: serialize_issuable(@issue, with_blocking_issues: true), diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index d9ad171a6cc..23510713494 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -1,67 +1,68 @@ -# DANGER: Any changes to this file need to be reflected in issuables_list/components/issuable.vue! %li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id, qa_selector: 'issue_container', qa_issue_title: issue.title } } - .issue-box + .issuable-info-container - if @can_bulk_update .issue-check.hidden = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected-issuable" - .issuable-info-container - .issuable-main-info - .issue-title.title - %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" } - - if issue.confidential? - %span.has-tooltip{ title: _('Confidential') } - = confidential_icon(issue) - = link_to issue.title, issue_path(issue) - = render_if_exists 'projects/issues/subepic_flag', issue: issue - - if issue.tasks? - %span.task-status.d-none.d-sm-inline-block - - = issue.task_status + .issuable-main-info + .issue-title.title + %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" } + - if issue.confidential? + %span.has-tooltip{ title: _('Confidential') } + = confidential_icon(issue) + = link_to issue.title, issue_path(issue) + = render_if_exists 'projects/issues/subepic_flag', issue: issue + - if issue.tasks? + %span.task-status.d-none.d-sm-inline-block + + = issue.task_status - .issuable-info - %span.issuable-reference - #{issuable_reference(issue)} - %span.issuable-authored.d-none.d-sm-inline-block - · - opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} - by #{link_to_member(@project, issue.author, avatar: false)} - = render_if_exists 'shared/issuable/gitlab_team_member_badge', {author: issue.author} - - if issue.milestone - %span.issuable-milestone.d-none.d-sm-inline-block - - = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do - = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom') - = issue.milestone.title - - if issue.due_date - %span.issuable-due-date.d-none.d-sm-inline-block.has-tooltip{ class: "#{'cred' if issue.overdue?}", title: _('Due date') } - - = sprite_icon('calendar') - = issue.due_date.to_s(:medium) + .issuable-info + %span.issuable-reference + #{issuable_reference(issue)} + %span.issuable-authored.d-none.d-sm-inline-block + · + opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} by + - if issue.service_desk_reply_to + #{issue.service_desk_reply_to} via + #{link_to_member(@project, issue.author, avatar: false)} + = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: issue.author + - if issue.milestone + %span.issuable-milestone.d-none.d-sm-inline-block + + = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do + = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom') + = issue.milestone.title + - if issue.due_date + %span.issuable-due-date.d-none.d-sm-inline-block.has-tooltip{ class: "#{'cred' if issue.overdue?}", title: _('Due date') } + + = sprite_icon('calendar') + = issue.due_date.to_s(:medium) - = render_if_exists "projects/issues/issue_weight", issue: issue - = render_if_exists "projects/issues/health_status", issue: issue + = render_if_exists "projects/issues/issue_weight", issue: issue + = render_if_exists "projects/issues/health_status", issue: issue - - if issue.labels.any? - - - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label| - = link_to_label(label, small: true) + - if issue.labels.any? + + - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label| + = link_to_label(label, small: true) - = render "projects/issues/issue_estimate", issue: issue + = render "projects/issues/issue_estimate", issue: issue - .issuable-meta - %ul.controls - - if issue.closed? && issue.moved? - %li.issuable-status - = _('CLOSED (MOVED)') - - elsif issue.closed? - %li.issuable-status - = _('CLOSED') - - if issue.assignees.any? - %li.gl-display-flex - = render 'shared/issuable/assignees', project: @project, issuable: issue + .issuable-meta + %ul.controls + - if issue.closed? && issue.moved? + %li.issuable-status + = _('CLOSED (MOVED)') + - elsif issue.closed? + %li.issuable-status + = _('CLOSED') + - if issue.assignees.any? + %li.gl-display-flex + = render 'shared/issuable/assignees', project: @project, issuable: issue - = render 'shared/issuable_meta_data', issuable: issue + = render 'shared/issuable_meta_data', issuable: issue - .float-right.issuable-updated-at.d-none.d-sm-inline-block - %span - = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago') } + .float-right.issuable-updated-at.d-none.d-sm-inline-block + %span + = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago') } diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 34260899d94..008340a3fe7 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -21,8 +21,8 @@ %button.btn.js-create-merge-request.btn-success.btn-inverted{ type: 'button', data: { action: data_action } } = value - %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle.flex-grow-0{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } } - = icon('caret-down') + %button.btn.gl-button.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle.gl-flex-grow-0.gl-h-7{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } } + = sprite_icon('chevron-down') .droplab-dropdown %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-right.gl-show-field-errors{ class: ("create-confidential-merge-request-dropdown-menu" if can_create_confidential_merge_request?), data: { dropdown: true } } diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml index b08223546f7..b126b452dea 100644 --- a/app/views/projects/jobs/_table.html.haml +++ b/app/views/projects/jobs/_table.html.haml @@ -1,8 +1,20 @@ - admin = local_assigns.fetch(:admin, false) - if builds.blank? - %div - .nothing-here-block No jobs to show + - if experiment_enabled?(:jobs_empty_state) + .row.empty-state + .col-12 + .svg-content.svg-250 + = image_tag('jobs-empty-state.svg') + .col-12 + .text-content.gl-text-center + %h4 + = s_('Jobs|Use jobs to automate your tasks') + %p + = s_('Jobs|Jobs are the building blocks of a GitLab CI/CD pipeline. Each job has a specific task, like testing code. To set up jobs in a CI/CD pipeline, add a CI/CD configuration file to your project.') + = link_to s_('Jobs|Create CI/CD configuration file'), help_page_path('ci/quick_start/README'), class: 'btn gl-button btn-info js-empty-state-button' + - else + .nothing-here-block= s_('Jobs|No jobs to show') - else .table-holder %table.table.ci-table.builds-page diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml index a1960fc99cf..cd062fcf675 100644 --- a/app/views/projects/jobs/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -7,8 +7,8 @@ .nav-controls - if can?(current_user, :update_build, @project) - - unless @repository.gitlab_ci_yml - = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn gl-button btn-info' + - if !@repository.gitlab_ci_yml && !experiment_enabled?(:jobs_empty_state) + = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn gl-button btn-info js-empty-state-button' = link_to project_ci_lint_path(@project), class: 'btn gl-button btn-default' do %span CI lint diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml new file mode 100644 index 00000000000..3a8629b3b6e --- /dev/null +++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml @@ -0,0 +1,37 @@ +- display_issuable_type = issuable_display_type(@merge_request) +- button_action_class = @merge_request.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary' +- button_class = "btn gl-button #{!@merge_request.closed? && 'js-draft-toggle-button'}" +- toggle_class = "btn gl-button dropdown-toggle" + +.float-left.btn-group.gl-ml-3.gl-display-none.gl-display-md-flex + = link_to @merge_request.closed? ? reopen_issuable_path(@merge_request) : toggle_draft_merge_request_path(@merge_request), method: :put, class: "#{button_class} #{button_action_class}" do + - if @merge_request.closed? + = _('Reopen') + = display_issuable_type + - else + = @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft') + + - if !@merge_request.closed? || !issuable_author_is_current_user(@merge_request) + = button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do + %span.gl-sr-only= _('Toggle dropdown') + = sprite_icon "angle-down", size: 12 + + %ul.dropdown-menu.dropdown-menu-right + - if @merge_request.open? + %li + = link_to close_issuable_path(@merge_request), method: :put do + .description + %strong.title + = _('Close') + = display_issuable_type + + - unless issuable_author_is_current_user(@merge_request) + - unless @merge_request.closed? + %li.divider.droplab-item-ignore + + %li + %a{ href: new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) } + .description + %strong.title= _('Report abuse') + %p.text.gl-mb-0 + = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize } diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml deleted file mode 100644 index a831972a823..00000000000 --- a/app/views/projects/merge_requests/_how_to_merge.html.haml +++ /dev/null @@ -1,56 +0,0 @@ -#modal_merge_info.modal{ tabindex: '-1' } - .modal-dialog.modal-lg - .modal-content - .modal-header - %h3.modal-title Check out, review, and merge locally - %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × - .modal-body - %p - %strong Step 1. - Fetch and check out the branch for this merge request - = clipboard_button(target: "pre#merge-info-1", title: _("Copy commands")) - %pre.dark#merge-info-1 - - if @merge_request.for_fork? - -# All repo/branch refs have been quoted to allow support for special characters (such as #my-branch) - :preserve - git fetch "#{h default_url_to_repo(@merge_request.source_project)}" "#{h @merge_request.source_branch}" - git checkout -b "#{h @merge_request.source_project_path}-#{h @merge_request.source_branch}" FETCH_HEAD - - else - :preserve - git fetch origin - git checkout -b "#{h @merge_request.source_branch}" "origin/#{h @merge_request.source_branch}" - %p - %strong Step 2. - Review the changes locally - - %p - %strong Step 3. - Merge the branch and fix any conflicts that come up - = clipboard_button(target: "pre#merge-info-3", title: _("Copy commands")) - %pre.dark#merge-info-3 - - if @merge_request.for_fork? - :preserve - git fetch origin - git checkout "#{h @merge_request.target_branch}" - git merge --no-ff "#{h @merge_request.source_project_path}-#{h @merge_request.source_branch}" - - else - :preserve - git fetch origin - git checkout "#{h @merge_request.target_branch}" - git merge --no-ff "#{h @merge_request.source_branch}" - %p - %strong Step 4. - Push the result of the merge to GitLab - = clipboard_button(target: "pre#merge-info-4", title: _("Copy commands")) - %pre.dark#merge-info-4 - :preserve - git push origin "#{h @merge_request.target_branch}" - - unless @merge_request.can_be_merged_by?(current_user) - %p - Note that pushing to GitLab requires write access to this repository. - %p - %strong Tip: - = succeed '.' do - You can also checkout merge requests locally by - = link_to 'following these guidelines', help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: "checkout-merge-requests-locally-through-the-head-ref"), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 092055a5f85..4711143c900 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -20,7 +20,7 @@ · opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')} by #{link_to_member(@project, merge_request.author, avatar: false)} - = render_if_exists 'shared/issuable/gitlab_team_member_badge', {author: merge_request.author} + = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: merge_request.author - if merge_request.milestone %span.issuable-milestone.d-none.d-sm-inline-block @@ -55,7 +55,7 @@ - if merge_request.assignees.any? %li.gl-display-flex.gl-align-items-center = render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request - - if Feature.enabled?(:merge_request_reviewers, @project) && merge_request.reviewers.any? + - if Feature.enabled?(:merge_request_reviewers, @project, default_enabled: true) && merge_request.reviewers.any? %li.gl-display-flex.issuable-reviewers = render 'shared/issuable/reviewers', project: merge_request.project, issuable: merge_request = render 'projects/merge_requests/approvals_count', merge_request: merge_request diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index cd4ffa8602e..1691a304e8b 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -2,8 +2,9 @@ - can_update_merge_request = can?(current_user, :update_merge_request, @merge_request) - can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request) - state_human_name, state_icon_name = state_name_with_icon(@merge_request) +- are_close_and_open_buttons_hidden = merge_request_button_hidden?(@merge_request, true) && merge_request_button_hidden?(@merge_request, false) -- if @merge_request.closed_without_fork? +- if @merge_request.closed_or_merged_without_fork? .gl-alert.gl-alert-danger.gl-mb-5 = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') .gl-alert-body @@ -18,33 +19,35 @@ .issuable-meta #js-issuable-header-warnings - = issuable_meta(@merge_request, @project, "Merge request") + = issuable_meta(@merge_request, @project) %a.btn.btn-default.float-right.d-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } = sprite_icon('chevron-double-lg-left') .detail-page-header-actions.js-issuable-actions - .clearfix.issue-btn-group.dropdown - %button.btn.btn-default.float-left.d-md-none{ type: "button", data: { toggle: "dropdown" } } + .clearfix.dropdown + %button.gl-button.btn.btn-default.float-left.gl-display-md-none.gl-w-full{ type: "button", data: { toggle: "dropdown" } } Options - = icon('caret-down') + = sprite_icon('chevron-down', css_class: 'gl-text-gray-500') .dropdown-menu.dropdown-menu-right %ul - if can_update_merge_request %li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - - if can_update_merge_request - - unless @merge_request.closed? + - if @merge_request.opened? %li - = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_issuable_path(@merge_request), method: :put, class: "js-draft-toggle-button" + = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_merge_request_path(@merge_request), method: :put, class: "js-draft-toggle-button" %li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] } = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request' - if can_reopen_merge_request %li{ class: merge_request_button_visibility(@merge_request, false) } - = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request' + = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, title: 'Reopen merge request' - unless @merge_request.merged? || current_user == @merge_request.author %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) - if can_update_merge_request = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-grouped js-issuable-edit qa-edit-button" - = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_reopen_merge_request + - if can_update_merge_request && !are_close_and_open_buttons_hidden + = render 'projects/merge_requests/close_reopen_draft_report_toggle' + - elsif !@merge_request.merged? + = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-display-md-block gl-button btn btn-warning-secondary float-right gl-ml-3', title: _('Report abuse') diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 9736071b03f..123affeb5d6 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -1,4 +1,4 @@ -= javascript_tag nonce: true do += javascript_tag do :plain window.gl = window.gl || {}; window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)} diff --git a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml index e6205f24ae6..cb1cb41eb71 100644 --- a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml +++ b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml @@ -1,16 +1,11 @@ .content-block.oneline-block.files-changed{ "v-if" => "!isLoading && !hasError" } .inline-parallel-buttons{ "v-if" => "showDiffViewTypeSwitcher" } .btn-group - %button.btn{ ":class" => "{'active': !isParallel}", "@click" => "handleViewTypeChange('inline')" } - Inline - %button.btn{ ":class" => "{'active': isParallel}", "@click" => "handleViewTypeChange('parallel')" } - Side-by-side + %button.btn.gl-button{ ":class" => "{'active': !isParallel}", "@click" => "handleViewTypeChange('inline')" } + = _('Inline') + %button.btn.gl-button{ ":class" => "{'active': isParallel}", "@click" => "handleViewTypeChange('parallel')" } + = _('Side-by-side') .js-toggle-container .commit-stat-summary - Showing - %strong.cred {{conflictsCountText}} - between - %strong.ref-name {{conflictsData.sourceBranch}} - and - %strong.ref-name {{conflictsData.targetBranch}} + = _('Showing %{conflict_start}%{conflicts_text}%{strong_end} between %{ref_start}%{source_branch}%{strong_end} and %{ref_start}%{target_branch}%{strong_end}').html_safe % { conflict_start: '<strong class="cred">'.html_safe, ref_start: '<strong class="ref-name">'.html_safe, strong_end: '</strong>'.html_safe, conflicts_text: '{{conflictsCountText}}', source_branch: '{{conflictsData.sourceBranch}}', target_branch: '{{conflictsData.targetBranch}}' } diff --git a/app/views/projects/merge_requests/conflicts/_file_actions.html.haml b/app/views/projects/merge_requests/conflicts/_file_actions.html.haml index 0839880713f..220ddf1bad3 100644 --- a/app/views/projects/merge_requests/conflicts/_file_actions.html.haml +++ b/app/views/projects/merge_requests/conflicts/_file_actions.html.haml @@ -1,12 +1,12 @@ -.file-actions - .btn-group{ "v-if" => "file.type === 'text'" } - %button.btn{ ":class" => "{ 'active': file.resolveMode == 'interactive' }", +.file-actions.d-flex.align-items-center.gl-ml-auto.gl-align-self-start + .btn-group.gl-mr-3{ "v-if" => "file.type === 'text'" } + %button.btn.gl-button{ ":class" => "{ 'active': file.resolveMode == 'interactive' }", '@click' => "onClickResolveModeButton(file, 'interactive')", type: 'button' } - Interactive mode - %button.btn{ ':class' => "{ 'active': file.resolveMode == 'edit' }", + = _('Interactive mode') + %button.btn.gl-button{ ':class' => "{ 'active': file.resolveMode == 'edit' }", '@click' => "onClickResolveModeButton(file, 'edit')", type: 'button' } - Edit inline - %a.btn.view-file{ ":href" => "file.blobPath" } - View file @{{conflictsData.shortCommitSha}} + = _('Edit inline') + %a.btn.gl-button.view-file{ ":href" => "file.blobPath" } + = _('View file @%{commit_sha}') % { commit_sha: '{{conflictsData.shortCommitSha}}' } diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml index 94c262d300e..15655e2b162 100644 --- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml +++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml @@ -18,7 +18,7 @@ .offset-md-4.col-md-8 .row .col-6 - %button.btn.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" } + %button.btn.gl-button.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" } %span {{commitButtonText}} .col-6.text-right = link_to "Cancel", project_merge_request_path(@merge_request.project, @merge_request), class: "gl-button btn btn-cancel" diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index decdbce3fa7..827df540629 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -20,9 +20,10 @@ .files-wrapper{ "v-if" => "!isLoading && !hasError" } .files .diff-file.file-holder.conflict{ "v-for" => "file in conflictsData.files" } - .js-file-title.file-title - %i.fa.fa-fw{ ":class" => "file.iconClass" } - %strong {{file.filePath}} + .js-file-title.file-title.file-title-flex-parent.cursor-default + .file-header-content + %file-icon{ ':file-name': 'file.filePath', ':size': '18', 'css-classes': 'gl-mr-2' } + %strong.file-title-name {{file.filePath}} = render partial: 'projects/merge_requests/conflicts/file_actions' .diff-content.diff-wrap-lines .file-content{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" } diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 6b506c38795..c70fc624dde 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -16,9 +16,6 @@ .merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } } = render "projects/merge_requests/mr_title" - - if @merge_request.source_branch_exists? - = render "projects/merge_requests/how_to_merge" - .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } = render "projects/merge_requests/mr_box" .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } @@ -58,6 +55,8 @@ = render "projects/merge_requests/description" = render "projects/merge_requests/widget" = render "projects/merge_requests/awards_block" + - if mr_action === "show" + - add_page_startup_api_call discussions_path(@merge_request) #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json, noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'), noteable_type: 'MergeRequest', diff --git a/app/views/projects/merge_requests/widget/open/_error.html.haml b/app/views/projects/merge_requests/widget/open/_error.html.haml index bbdc053609f..31efa64c672 100644 --- a/app/views/projects/merge_requests/widget/open/_error.html.haml +++ b/app/views/projects/merge_requests/widget/open/_error.html.haml @@ -1,5 +1,5 @@ %h4 - = icon('exclamation-triangle') + = sprite_icon('warning-solid') This merge request failed to be merged automatically %p diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 4366676bd45..30ba22ba53c 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -8,7 +8,7 @@ = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control input-mx-250 search-sha' = button_tag class: 'btn btn-success' do = sprite_icon('search') - .inline.prepend-left-20 + .inline.gl-ml-5 .form-check.light = check_box_tag :filter_ref, 1, @options[:filter_ref], class: 'form-check-input' = label_tag :filter_ref, class: 'form-check-label' do diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index f2972a9617b..a407aa9ac13 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -8,10 +8,9 @@ .project-edit-errors = render 'projects/errors' - - if experiment_enabled?(:new_create_project_ui) - .js-experiment-new-project-creation{ data: { is_ci_cd_available: ci_cd_projects_available?, has_errors: @project.errors.any? } } + .js-experiment-new-project-creation{ data: { is_ci_cd_available: (ci_cd_projects_available? if Gitlab.ee?), has_errors: @project.errors.any? } } - .row{ 'v-cloak': experiment_enabled?(:new_create_project_ui) } + .row{ 'v-cloak': true } .col-lg-3.profile-settings-sidebar %h4.gl-mt-0 = _('New project') diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml index 65c4232b240..d7853c1b466 100644 --- a/app/views/projects/no_repo.html.haml +++ b/app/views/projects/no_repo.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title _("Details") -- page_title _("Details") +- page_title _('No repository') +- @skip_current_level_breadcrumb = true %h2.gl-display-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 8955b568741..b41c3f4fc27 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -26,7 +26,7 @@ = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project .tab-content - #js-tab-pipeline.tab-pane.gl-absolute.gl-left-0.gl-w-full + #js-tab-pipeline.tab-pane.gl-w-full #js-pipeline-graph-vue #js-tab-builds.tab-pane diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index 55f1b9098c3..f3360e150ad 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -1,7 +1,10 @@ - page_title _('CI / CD Analytics') -#js-project-pipelines-charts-app{ data: { counts: @counts, success_ratio: success_ratio(@counts), - times_chart: { labels: @charts[:pipeline_times].labels, values: @charts[:pipeline_times].pipeline_times }, - last_week_chart: { labels: @charts[:week].labels, totals: @charts[:week].total, success: @charts[:week].success }, - last_month_chart: { labels: @charts[:month].labels, totals: @charts[:month].total, success: @charts[:month].success }, - last_year_chart: { labels: @charts[:year].labels, totals: @charts[:year].total, success: @charts[:year].success } } } +- if Feature.enabled?(:graphql_pipeline_analytics) + #js-project-pipelines-charts-app{ data: { project_path: @project.full_path } } +- else + #js-project-pipelines-charts-app{ data: { counts: @counts, success_ratio: success_ratio(@counts), + times_chart: { labels: @charts[:pipeline_times].labels, values: @charts[:pipeline_times].pipeline_times }, + last_week_chart: { labels: @charts[:week].labels, totals: @charts[:week].total, success: @charts[:week].success }, + last_month_chart: { labels: @charts[:month].labels, totals: @charts[:month].total, success: @charts[:month].success }, + last_year_chart: { labels: @charts[:year].labels, totals: @charts[:year].total, success: @charts[:year].success } } } diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index 6aa1a564499..64ae4ff8daf 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -8,7 +8,7 @@ project_id: @project.id, params: params.to_json, "help-page-path" => help_page_path('ci/quick_start/README'), - "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'), + "auto-devops-help-path" => help_page_path('topics/autodevops/index.md'), "pipeline-schedule-url" => pipeline_schedules_path(@project), "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'), "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'), diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index bc8e6a6d9cc..7d5cef2015d 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -10,10 +10,12 @@ #js-new-pipeline{ data: { project_id: @project.id, pipelines_path: project_pipelines_path(@project), config_variables_path: config_variables_namespace_project_pipelines_path(@project.namespace, @project), + default_branch: @project.default_branch, ref_param: params[:ref] || @project.default_branch, var_param: params[:var].to_json, file_param: params[:file_var].to_json, - ref_names: @project.repository.ref_names.to_json.html_safe, + branch_refs: @project.repository.branch_names.to_json.html_safe, + tag_refs: @project.repository.tag_names.to_json.html_safe, settings_link: project_settings_ci_cd_path(@project), max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } } diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 0b07fe9921e..847b96cbd0e 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -23,4 +23,4 @@ = render "projects/pipelines/with_tabs", pipeline: @pipeline, pipeline_has_errors: pipeline_has_errors -.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } } +.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid } } diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 9ac1fda169f..b53fbc97c02 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -17,6 +17,7 @@ "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'), "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'), - + "project_path": @project.full_path, + "gid_prefix": container_repository_gid_prefix, "is_admin": current_user&.admin.to_s, character_error: @character_error.to_s } } diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml index c6fae2cc7a1..a4d4a1bb2dd 100644 --- a/app/views/projects/registry/settings/_index.haml +++ b/app/views/projects/registry/settings/_index.haml @@ -5,4 +5,5 @@ older_than_options: older_than_options.to_json, is_admin: current_user&.admin.to_s, admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), - enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s} } + enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s, + tags_regex_help_page_path: help_page_path('user/packages/container_registry/index', anchor: 'regex-pattern-examples') } } diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml index c567b453bf2..4093f0a0719 100644 --- a/app/views/projects/runners/_shared_runners.html.haml +++ b/app/views/projects/runners/_shared_runners.html.haml @@ -1,16 +1,22 @@ +- isVueifySharedRunnersToggleEnabled = Feature.enabled?(:vueify_shared_runners_toggle, @project) + = render layout: 'shared/runners/shared_runners_description' do - %hr - - if @project.group&.shared_runners_setting == 'disabled_and_unoverridable' - %h5.gl-text-red-500 - = _('Shared runners disabled on group level') - - else - - if @project.shared_runners_enabled? - = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do - = _('Disable shared runners') + - if !isVueifySharedRunnersToggleEnabled + %hr + - if @project.group&.shared_runners_setting == 'disabled_and_unoverridable' + %h5.gl-text-red-500 + = _('Shared runners disabled on group level') - else - = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do - = _('Enable shared runners') - for this project + - if @project.shared_runners_enabled? + = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do + = _('Disable shared runners') + - else + = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do + = _('Enable shared runners') + for this project + +- if isVueifySharedRunnersToggleEnabled + #toggle-shared-runners-form{ data: toggle_shared_runners_settings_data(@project) } - if @shared_runners_count == 0 = _('This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area.') diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index 9d81fda68cb..549ca36cb6a 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -1,4 +1,4 @@ -- pretty_name = html_escape(@project&.full_name) || html_escape_once(_('<project name>')).html_safe +- pretty_name = @project&.full_name ? html_escape(@project&.full_name) : '<' + _('project name') + '>' - run_actions_text = html_escape(s_("ProjectService|Perform common operations on GitLab project: %{project_name}")) % { project_name: pretty_name } %p= s_("ProjectService|To set up this service:") diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 86486d95eb7..67c43bd2f33 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -1,4 +1,4 @@ -- pretty_name = @project&.full_name || _('<project name>') +- pretty_name = @project&.full_name ? html_escape(@project&.full_name) : '<' + _('project name') + '>' - run_actions_text = html_escape_once(s_("ProjectService|Perform common operations on GitLab project: %{project_name}") % { project_name: pretty_name }) .info-well diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index f6ecb923100..0bef82ee325 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -66,11 +66,11 @@ %section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) } .settings-header %h4 - = _("Cleanup policy for tags") + = _("Clean up image tags") %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _("Save space and find tags in the Container Registry more easily. Enable the cleanup policy to remove stale tags and keep only the ones you need.") + = _("Save space and find images in the Container Registry. Remove unneeded tags and keep only the ones you want.") = link_to _('More information'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy', target: '_blank', rel: 'noopener noreferrer') .settings-content = render 'projects/registry/settings/index' diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index e5d34ff0fc9..73722a5a789 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -2,7 +2,7 @@ - page_title _('Operations Settings') - breadcrumb_title _('Operations Settings') -= render 'projects/settings/operations/alert_management', alerts_service: alerts_service, prometheus_service: prometheus_service += render 'projects/settings/operations/alert_management' = render 'projects/settings/operations/incidents' = render 'projects/settings/operations/error_tracking' = render 'projects/settings/operations/prometheus', service: prometheus_service if Feature.enabled?(:settings_operations_prometheus_service) diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index f7c51e9ada9..5b9f868a71a 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title _("Details") - @content_class = "limit-container-width" unless fluid_layout +- @skip_current_level_breadcrumb = true = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 7679e0714fe..9d4e5d629f4 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -27,9 +27,6 @@ = sprite_icon("rocket", size: 12) = _("Release") = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'gl-text-blue-600!' - - if release.description.present? - .md.gl-mt-3 - = markdown_field(release, :description) .row-fixed-content.controls.flex-row - if tag.has_signature? diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index e0def8cf155..2fe5c5888f5 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -16,7 +16,7 @@ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown'} } %span.light = tags_sort_options_hash[@sort] - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li.dropdown-header = s_('TagsPage|Sort by') diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index fe42394d919..73b2a92dcc0 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -24,7 +24,7 @@ = hidden_field_tag :ref, default_ref = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide js-branch-select monospace', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do .text-left.dropdown-toggle-text= default_ref - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') = render 'shared/ref_dropdown', dropdown_class: 'wide' .form-text.text-muted = s_('TagsPage|Existing branch name, tag, or commit SHA') diff --git a/app/views/projects/terraform/index.html.haml b/app/views/projects/terraform/index.html.haml index 136e7ded224..21a4fe5eae6 100644 --- a/app/views/projects/terraform/index.html.haml +++ b/app/views/projects/terraform/index.html.haml @@ -1,4 +1,6 @@ +- add_page_specific_style 'page_bundles/ci_status' + - breadcrumb_title _('Terraform') - page_title _('Terraform') -#js-terraform-list{ data: js_terraform_list_data(@project) } +#js-terraform-list{ data: js_terraform_list_data(current_user, @project) } diff --git a/app/views/projects/tree/_truncated_notice_tree_row.html.haml b/app/views/projects/tree/_truncated_notice_tree_row.html.haml index 693b641888b..a03e0a549ee 100644 --- a/app/views/projects/tree/_truncated_notice_tree_row.html.haml +++ b/app/views/projects/tree/_truncated_notice_tree_row.html.haml @@ -1,6 +1,6 @@ %tr.tree-truncated-warning %td{ colspan: '3' } - = icon('exclamation-triangle fw') + = sprite_icon('warning-solid') %span Too many items to show. To preserve performance only %strong #{number_with_delimiter(limit)} of #{number_with_delimiter(total)} diff --git a/app/views/registrations/experience_levels/show.html.haml b/app/views/registrations/experience_levels/show.html.haml index 24b87790e18..f878245a48c 100644 --- a/app/views/registrations/experience_levels/show.html.haml +++ b/app/views/registrations/experience_levels/show.html.haml @@ -15,8 +15,8 @@ = image_tag 'novice.svg', width: '78', height: '78', alt: '' %div %p.gl-font-lg.gl-font-weight-bold.gl-mb-2= _('Novice') - %p= _('I’m not very familiar with the basics of project management and DevOps.') - = link_to _('Show me everything'), users_sign_up_experience_level_path(experience_level: :novice, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link' + %p= _('I’m not familiar with the basics of DevOps.') + = link_to _('Show me the basics'), users_sign_up_experience_level_path(experience_level: :novice, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link' .card .card-body.gl-display-flex.gl-py-8.gl-pr-5.gl-pl-7 @@ -24,5 +24,5 @@ = image_tag 'experienced.svg', width: '78', height: '78', alt: '' %div %p.gl-font-lg.gl-font-weight-bold.gl-mb-2= _('Experienced') - %p= _('I’m familiar with the basics of project management and DevOps.') - = link_to _('Show me more advanced stuff'), users_sign_up_experience_level_path(experience_level: :experienced, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link' + %p= _('I’m familiar with the basics of DevOps.') + = link_to _('Show me advanced features'), users_sign_up_experience_level_path(experience_level: :experienced, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link' diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml index 278c0ff7739..68de80f26f6 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -14,12 +14,20 @@ .row .form-group.col-sm-12 = f.label :role, _('Role'), class: 'label-bold' - = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control', autofocus: true - .form-text.gl-text-gray-500.gl-mt-3= _('This will help us personalize your onboarding experience.') + = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control js-user-role-dropdown', autofocus: true + - if Feature.enabled?(:user_other_role_details) + .row + .form-group.col-sm-12.js-other-role-group{ class: ("hidden") } + = f.label :other_role, _('What is your job title? (optional)'), class: 'form-check-label gl-mb-3' + = f.text_field :other_role, class: 'form-control' + - else + .row + .form-group.col-sm-12 + .form-text.gl-text-gray-500.gl-mt-0.gl-line-height-normal.gl-px-1= _('This will help us personalize your onboarding experience.') = render_if_exists "registrations/welcome/setup_for_company", f: f .row .form-group.col-sm-12.gl-mb-0 - if partial_exists? "registrations/welcome/button" = render "registrations/welcome/button" - else - = f.submit _('Get started!'), class: 'btn-register gl-button btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' } + = f.submit _('Get started!'), class: 'btn-success gl-button btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' } diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml index 964a2a2772a..e9c6b581c90 100644 --- a/app/views/search/_filter.html.haml +++ b/app/views/search/_filter.html.haml @@ -2,21 +2,13 @@ = hidden_field_tag :group_id, params[:group_id] - if params[:project_id].present? = hidden_field_tag :project_id, params[:project_id] +- project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace) + .dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "group-filter" } } %label.d-block{ for: "dashboard_search_group" } = _("Group") - %input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-group-data": @group.to_json } } -.dropdown.form-group.mb-lg-0.mx-lg-1{ data: { testid: "project-filter" } } + %input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": @group.to_json } } +.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "project-filter" } } %label.d-block{ for: "dashboard_search_project" } = _("Project") - %button.dropdown-menu-toggle.gl-display-inline-flex.js-search-project-dropdown.gl-mt-0{ type: "button", id: "dashboard_search_project", data: { toggle: "dropdown" } } - %span.dropdown-toggle-text.gl-flex-grow-1.str-truncated-100 - = @project&.full_name || _("Any") - - if @project.present? - = link_to sprite_icon("clear"), url_for(safe_params.except(:project_id)), class: 'search-clear js-search-clear has-tooltip', title: _('Clear') - = icon("chevron-down") - .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-right - = dropdown_title(_("Filter results by project")) - = dropdown_filter(_("Search projects")) - = dropdown_content - = dropdown_loading + %input#js-search-project-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": project_attributes.to_json } } diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml index 80973c2b273..a9eee1dd2d6 100644 --- a/app/views/search/_form.html.haml +++ b/app/views/search/_form.html.haml @@ -7,9 +7,9 @@ .search-field-holder.form-group.mr-lg-1.mb-lg-0 %label{ for: "dashboard_search" } = _("What are you searching for?") - .position-relative - = search_field_tag :search, params[:search], placeholder: _("Search for projects, issues, etc."), class: "form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false - = sprite_icon('search', css_class: 'search-icon') + .gl-search-box-by-type + = search_field_tag :search, params[:search], placeholder: _("Search for projects, issues, etc."), class: "gl-form-input form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false + = sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon') %button.search-clear.js-search-clear{ class: [("hidden" if params[:search].blank?), "has-tooltip"], type: "button", tabindex: "-1", title: _('Clear') } = sprite_icon('clear') %span.sr-only @@ -17,4 +17,4 @@ - unless params[:snippets].eql? 'true' = render 'filter' .d-flex-center.flex-column.flex-lg-row - = button_tag _("Search"), class: "gl-button btn btn-success btn-search form-control mt-lg-0 ml-lg-1 align-self-end" + = button_tag _("Search"), class: "gl-button btn btn-success btn-search mt-lg-0 ml-lg-1 align-self-end" diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 855112bdba2..80d0253d273 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,37 +1,20 @@ +- search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4' + - if @search_objects.to_a.empty? .gl-display-md-flex - if %w(issues merge_requests).include?(@scope) - #js-search-sidebar.gl-display-flex.gl-flex-direction-column.col-md-3.gl-mr-4{ } - .gl-w-full + #js-search-sidebar{ class: search_bar_classes } + .gl-w-full.gl-flex-fill-1.gl-overflow-x-hidden = render partial: "search/results/empty" = render_if_exists 'shared/promotions/promote_advanced_search' - else - .search-results-status - .row-content-block.gl-display-flex - .gl-display-md-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1 - - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount) - = search_entries_info(@search_objects, @scope, @search_term) - - unless @show_snippets - - if @project - - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1') - - if @scope == 'blobs' - = s_("SearchCodeResults|in") - .mx-md-1 - = render partial: "shared/ref_switcher", locals: { ref: repository_ref(@project), form_path: request.fullpath, field_name: 'repository_ref' } - = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } - - else - = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } - - elsif @group - - link_to_group = link_to(@group.name, @group, class: 'ml-md-1') - = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } - .gl-display-md-flex.gl-flex-direction-column - = render partial: 'search/sort_dropdown' + = render partial: 'search/results_status', locals: { search_service: @search_service } = render_if_exists 'shared/promotions/promote_advanced_search' .results.gl-display-md-flex.gl-mt-3 - if %w(issues merge_requests).include?(@scope) - #js-search-sidebar.gl-display-flex.gl-flex-direction-column.col-md-3.gl-mr-4{ } - .gl-w-full + #js-search-sidebar{ class: search_bar_classes } + .gl-w-full.gl-flex-fill-1.gl-overflow-x-hidden - if @scope == 'commits' %ul.content-list.commit-list = render partial: "search/results/commit", collection: @search_objects diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml new file mode 100644 index 00000000000..e55f225b162 --- /dev/null +++ b/app/views/search/_results_status.html.haml @@ -0,0 +1,25 @@ +- search_service = local_assigns.fetch(:search_service) + +- return unless search_service.show_results_status? + +.search-results-status + .row-content-block.gl-display-flex + .gl-display-md-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1 + - unless search_service.without_count? + = search_entries_info(search_service.search_objects, search_service.scope, params[:search]) + - unless search_service.show_snippets? + - if search_service.project + - link_to_project = link_to(search_service.project.full_name, search_service.project, class: 'ml-md-1') + - if search_service.scope == 'blobs' + = _("in") + .mx-md-1 + = render partial: "shared/ref_switcher", locals: { ref: repository_ref(search_service.project), form_path: request.fullpath, field_name: 'repository_ref' } + = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } + - else + = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } + - elsif search_service.group + - link_to_group = link_to(search_service.group.name, search_service.group, class: 'ml-md-1') + = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } + - if search_service.show_sort_dropdown? + .gl-display-md-flex.gl-flex-direction-column + = render partial: 'search/sort_dropdown' diff --git a/app/views/search/_sort_dropdown.html.haml b/app/views/search/_sort_dropdown.html.haml index 085e2f348f7..4ae6513d395 100644 --- a/app/views/search/_sort_dropdown.html.haml +++ b/app/views/search/_sort_dropdown.html.haml @@ -1,5 +1,3 @@ -- return unless ['issues', 'merge_requests'].include?(@scope) - - sort_value = @sort - sort_title = search_sort_option_title(sort_value) @@ -8,7 +6,7 @@ .btn-group{ role: 'group' } %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } = sort_title - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort %li = render_if_exists('search/sort_by_relevancy', sort_title: sort_title) diff --git a/app/views/shared/_alert_info.html.haml b/app/views/shared/_alert_info.html.haml new file mode 100644 index 00000000000..e47c100909a --- /dev/null +++ b/app/views/shared/_alert_info.html.haml @@ -0,0 +1,6 @@ +.gl-alert.gl-alert-info + = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', css_class: 'gl-icon') + .gl-alert-body + = body diff --git a/app/views/shared/_choose_avatar_button.html.haml b/app/views/shared/_choose_avatar_button.html.haml index caf2bdce899..e3f2e1aa436 100644 --- a/app/views/shared/_choose_avatar_button.html.haml +++ b/app/views/shared/_choose_avatar_button.html.haml @@ -1 +1 @@ -= render 'shared/file_picker_button', f: f, field: :avatar, help_text: _("The maximum file size allowed is 200KB.") += render 'shared/file_picker_button', f: f, field: :avatar, help_text: _("Max file size is 200 KB.") diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 9ec8d3c18cd..fd52f7f40d2 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -1,24 +1,22 @@ -- project = project || @project - .git-clone-holder.js-git-clone-holder.input-group .input-group-prepend - if allowed_protocols_present? .input-group-text.clone-dropdown-btn.btn %span.js-clone-dropdown-label - = enabled_project_button(project, enabled_protocol) + = enabled_protocol_button(container, enabled_protocol) - else %a#clone-dropdown.input-group-text.btn.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } %span.js-clone-dropdown-label = default_clone_protocol.upcase - = icon('caret-down') + = sprite_icon('chevron-down') %ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown %li - = ssh_clone_button(project) + = ssh_clone_button(container) %li - = http_clone_button(project) - = render_if_exists 'shared/kerberos_clone_button', project: project + = http_clone_button(container) + = render_if_exists 'shared/kerberos_clone_button', container: container - = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Project clone URL') } + = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') } .input-group-append - = clipboard_button(target: '#project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") + = clipboard_button(target: '#clone_url', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") diff --git a/app/views/shared/_file_picker_button.html.haml b/app/views/shared/_file_picker_button.html.haml index 7c9a3bd3d31..8c10e4958b9 100644 --- a/app/views/shared/_file_picker_button.html.haml +++ b/app/views/shared/_file_picker_button.html.haml @@ -1,5 +1,7 @@ +- classes = local_assigns.fetch(:classes, '') + %span.js-filepicker - %button.btn.js-filepicker-button{ type: 'button' }= _("Choose file…") + %button.btn.js-filepicker-button{ type: 'button', class: classes }= _("Choose file…") %span.file_name.js-filepicker-filename= _("No file chosen") = f.file_field field, class: "js-filepicker-input hidden" - if help_text.present? diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index ca603eed703..c3fac5cd464 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -47,11 +47,3 @@ = f.label :id, class: 'label-bold' do = _("Group ID") = f.text_field :id, class: 'form-control', readonly: true - -.row - .form-group.group-description-holder.col-sm-8 - = f.label :description, class: 'label-bold' do - = _("Group description") - %span (optional) - = f.text_area :description, maxlength: 250, - class: 'form-control js-gfm-input', rows: 4 diff --git a/app/views/shared/_group_form_description.html.haml b/app/views/shared/_group_form_description.html.haml new file mode 100644 index 00000000000..9a895cee884 --- /dev/null +++ b/app/views/shared/_group_form_description.html.haml @@ -0,0 +1,5 @@ +.row + .form-group.group-description-holder.col-sm-8 + = f.label :description, _('Group description (optional)'), class: 'label-bold' + = f.text_area :description, maxlength: 250, + class: 'form-control js-gfm-input', rows: 4 diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml index 0f38d0e3b39..57575f89803 100644 --- a/app/views/shared/_issues.html.haml +++ b/app/views/shared/_issues.html.haml @@ -1,7 +1,6 @@ - if @issues.to_a.any? - .card.card-without-border - %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } } - = render partial: 'projects/issues/issue', collection: @issues + %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } } + = render partial: 'projects/issues/issue', collection: @issues = paginate @issues, theme: "gitlab" - else = render 'shared/empty_states/issues' diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml index c7c36d79fa0..0976defea1b 100644 --- a/app/views/shared/_md_preview.html.haml +++ b/app/views/shared/_md_preview.html.haml @@ -28,7 +28,7 @@ - if referenced_users .referenced-users.hide %span - = icon("exclamation-triangle") + = sprite_icon('warning-solid') You are about to add %strong %span.js-referenced-users-count 0 diff --git a/app/views/shared/_merge_requests.html.haml b/app/views/shared/_merge_requests.html.haml index d280df8b370..dc8efa3e734 100644 --- a/app/views/shared/_merge_requests.html.haml +++ b/app/views/shared/_merge_requests.html.haml @@ -1,7 +1,6 @@ - if @merge_requests.to_a.any? - .card.card-without-border - %ul.content-list.mr-list.issuable-list - = render partial: 'projects/merge_requests/merge_request', collection: @merge_requests + %ul.content-list.mr-list.issuable-list + = render partial: 'projects/merge_requests/merge_request', collection: @merge_requests = paginate @merge_requests, theme: "gitlab" diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml index 06da990e071..29c01343358 100644 --- a/app/views/shared/_milestones_sort_dropdown.html.haml +++ b/app/views/shared/_milestones_sort_dropdown.html.haml @@ -5,7 +5,7 @@ = milestone_sort_options_hash[@sort] - else = sort_title_due_date_soon - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort %li = link_to page_filter_path(sort: sort_value_due_date_soon) do diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml index 76ae63ca5e8..9c1e5a49b44 100644 --- a/app/views/shared/_no_password.html.haml +++ b/app/views/shared/_no_password.html.haml @@ -5,7 +5,7 @@ = sprite_icon('close', size: 16, css_class: 'gl-icon') .gl-alert-body - translation_params = { protocol: gitlab_config.protocol.upcase, set_password_link: link_to_set_password } - - set_password_message = _("You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account") % translation_params + - set_password_message = _("You won't be able to pull or push repositories via %{protocol} until you %{set_password_link} on your account") % translation_params = set_password_message.html_safe .gl-alert-actions = link_to _('Remind later'), '#', class: 'hide-no-password-message btn gl-alert-action btn-info btn-md gl-button' diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml index a083a772233..0a7fa2a3c1e 100644 --- a/app/views/shared/_no_ssh.html.haml +++ b/app/views/shared/_no_ssh.html.haml @@ -4,7 +4,7 @@ %button{ class: 'gl-alert-dismiss hide-no-ssh-message', type: 'button', 'aria-label': _('Dismiss') } = sprite_icon('close', css_class: 'gl-icon s16') .gl-alert-body - = s_("MissingSSHKeyWarningLink|You won't be able to pull or push project code via SSH until you add an SSH key to your profile").html_safe + = s_("MissingSSHKeyWarningLink|You won't be able to pull or push repositories via SSH until you add an SSH key to your profile") .gl-alert-actions = link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "btn gl-alert-action btn-warning btn-md new-gl-button" = link_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: {hide_no_ssh_key: true}), method: :put, role: 'button', class: 'btn gl-alert-action btn-md btn-warning gl-button btn-warning-secondary' diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 647421a8fbe..194e0eb57f2 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -9,5 +9,5 @@ .service-settings - if @default_integration - .js-vue-default-integration-settings{ data: integration_form_data(@default_integration) } - .js-vue-integration-settings{ data: integration_form_data(integration) } + .js-vue-default-integration-settings{ data: integration_form_data(@default_integration, group: @group) } + .js-vue-integration-settings{ data: integration_form_data(integration, group: @group) } diff --git a/app/views/shared/_web_ide_button.html.haml b/app/views/shared/_web_ide_button.html.haml index 75f5b8647f2..f9c6afcbc32 100644 --- a/app/views/shared/_web_ide_button.html.haml +++ b/app/views/shared/_web_ide_button.html.haml @@ -1,8 +1,8 @@ - type = blob ? 'blob' : 'tree' -.d-inline-block{ data: { options: web_ide_button_data(blob: blob).to_json }, id: "js-#{type}-web-ide-link" } +.d-inline-block{ data: { options: web_ide_button_data({ blob: blob }).to_json }, id: "js-#{type}-web-ide-link" } -- if show_edit_button? +- if show_edit_button?({ blob: blob }) = render 'shared/confirm_fork_modal', fork_path: fork_and_edit_path(@project, @ref, @path), type: 'edit' - if show_web_ide_button? = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path), type: 'webide' diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml index 255ec9995db..50daa400e6c 100644 --- a/app/views/shared/access_tokens/_table.html.haml +++ b/app/views/shared/access_tokens/_table.html.haml @@ -42,7 +42,7 @@ = _('In %{time_to_now}') % { time_to_now: distance_of_time_in_words_to_now(token.expires_at) } - else %span.token-never-expires-label= _('Never') - %td= token.scopes.present? ? token.scopes.join(', ') : html_escape_once(_('<no scopes selected>')).html_safe + %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected') %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: 'btn btn-danger float-right qa-revoke-button', data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } } - else .settings-message.text-center diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index ce48691166b..e4222d8a4fe 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -13,27 +13,15 @@ - content_for :page_specific_javascripts do %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal, show_sorting_dropdown: false - %script#js-board-promotion{ type: "text/x-template" }= render_if_exists "shared/promotions/promote_issue_board" = render 'shared/issuable/search_bar', type: :boards, board: board #board-app.boards-app.position-relative{ "v-cloak" => "true", data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" } - - if Feature.enabled?(:boards_with_swimlanes, current_board_parent, default_enabled: true) || Feature.enabled?(:graphql_board_lists, current_board_parent) - %board-content{ "v-cloak" => "true", - "ref" => "board_content", - ":lists" => "state.lists", - ":can-admin-list" => can_admin_list, - ":disabled" => "disabled" } - - else - .boards-list.w-100.py-3.px-2.text-nowrap{ data: { qa_selector: "boards_list" } } - .boards-app-loading.w-100.text-center{ "v-if" => "loading" } - = loading_icon(css_class: 'gl-mb-3') - %board{ "v-cloak" => "true", - "v-for" => "list in state.lists", - "ref" => "board", - ":can-admin-list" => can_admin_list, - ":list" => "list", - ":disabled" => "disabled", - ":key" => "list.id" } + %board-content{ "v-cloak" => "true", + "ref" => "board_content", + ":lists" => "state.lists", + ":can-admin-list" => can_admin_list, + ":disabled" => "disabled", + data: { qa_selector: "boards_list" } } = render "shared/boards/components/sidebar", group: group %board-settings-sidebar{ ":can-admin-list" => can_admin_list } - if @project diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml index ad73442807e..361471af0ad 100644 --- a/app/views/shared/deploy_tokens/_table.html.haml +++ b/app/views/shared/deploy_tokens/_table.html.haml @@ -23,7 +23,7 @@ In #{distance_of_time_in_words_to_now(token.expires_at)} - else %span.token-never-expires-label= _('Never') - %td= token.scopes.present? ? token.scopes.join(", ") : html_escape_once(_('<no scopes selected>')).html_safe + %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected') %td= link_to s_('DeployTokens|Revoke'), "#", class: "btn btn-danger float-right", data: { toggle: "modal", target: "#revoke-modal-#{token.id}"} = render 'shared/deploy_tokens/revoke_modal', token: token, group_or_project: group_or_project - else diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 9d2d3ce20c7..75c34102935 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -1,24 +1,17 @@ - options_hash = local_assigns.fetch(:options_hash, groups_sort_options_hash) - show_archive_options = local_assigns.fetch(:show_archive_options, false) -- if @sort.present? - - default_sort_by = @sort -- else - - if params[:sort] - - default_sort_by = params[:sort] - - else - - default_sort_by = sort_value_recently_created .dropdown.inline.js-group-filter-dropdown-wrap.gl-mr-3 %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.dropdown-label - = options_hash[default_sort_by] - = icon('chevron-down') + = options_hash[project_list_sort_by] + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li.dropdown-header = _("Sort by") - options_hash.each do |value, title| %li.js-filter-sort-order - = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do + = link_to filter_groups_path(sort: value), class: ("is-active" if project_list_sort_by == value) do = title - if show_archive_options %li.divider diff --git a/app/views/shared/groups/_visibility_level.html.haml b/app/views/shared/groups/_visibility_level.html.haml new file mode 100644 index 00000000000..1a13de9b76a --- /dev/null +++ b/app/views/shared/groups/_visibility_level.html.haml @@ -0,0 +1,3 @@ += f.label :visibility_level, class: 'label-bold' do + = _('Visibility level') +.js-visibility-level-dropdown{ data: { visibility_level_options: visibility_level_options(@group).to_json, default_level: f.object.visibility_level } } diff --git a/app/views/shared/icons/_icon_mattermost.svg b/app/views/shared/icons/_icon_mattermost.svg index d1c541523ab..3cf10851003 100644 --- a/app/views/shared/icons/_icon_mattermost.svg +++ b/app/views/shared/icons/_icon_mattermost.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><path d="M250.05 34c1.9.04 3.8.11 5.6.2l-29.79 35.51c-.07.01-.15.03-.23.04C149.26 84.1 98.22 146.5 98.22 222.97c0 41.56 23.07 90.5 59.75 119.1 28.61 22.32 64.29 36.9 101.21 36.9 93.4 0 160.15-68.61 160.15-156 0-34.91-15.99-72.77-41.76-100.76l-1.63-47.39c54.45 39.15 89.95 103.02 90.06 175.17v.01c0 119.29-96.7 216-216 216-119.29 0-216-96.71-216-216S130.71 34 250 34h.05zm64.1 20.29c.66-.04 1.32.03 1.96.25 3.01 1 3.85 3.57 3.93 6.45l3.84 146.88c.76 28.66-17.16 68.44-60.39 68.56-30.97.08-63.68-20.83-63.68-60.13.01-14.73 5.61-31.26 19.25-48.11l90.03-111.18c1.15-1.42 3.08-2.58 5.06-2.72z"/></svg> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 500 500"><path d="M250.05 34c1.9.04 3.8.11 5.6.2l-29.79 35.51c-.07.01-.15.03-.23.04C149.26 84.1 98.22 146.5 98.22 222.97c0 41.56 23.07 90.5 59.75 119.1 28.61 22.32 64.29 36.9 101.21 36.9 93.4 0 160.15-68.61 160.15-156 0-34.91-15.99-72.77-41.76-100.76l-1.63-47.39c54.45 39.15 89.95 103.02 90.06 175.17v.01c0 119.29-96.7 216-216 216-119.29 0-216-96.71-216-216S130.71 34 250 34h.05zm64.1 20.29c.66-.04 1.32.03 1.96.25 3.01 1 3.85 3.57 3.93 6.45l3.84 146.88c.76 28.66-17.16 68.44-60.39 68.56-30.97.08-63.68-20.83-63.68-60.13.01-14.73 5.61-31.26 19.25-48.11l90.03-111.18c1.15-1.42 3.08-2.58 5.06-2.72z"/></svg> diff --git a/app/views/shared/integrations/_index.html.haml b/app/views/shared/integrations/_index.html.haml index 2f299ad5c89..edc85f04d91 100644 --- a/app/views/shared/integrations/_index.html.haml +++ b/app/views/shared/integrations/_index.html.haml @@ -1,4 +1,4 @@ -%table.table.b-table.gl-table.mt-3{ role: 'table', 'aria-busy': false, 'aria-colcount': 4 } +%table.table.b-table.gl-table{ role: 'table', 'aria-busy': false, 'aria-colcount': 4 } %colgroup %col %col @@ -15,11 +15,10 @@ - integrations.each do |integration| - activated_label = (integration.activated? ? s_("ProjectService|%{service_title}: status on") : s_("ProjectService|%{service_title}: status off")) % { service_title: integration.title } %tr{ role: 'row' } - %td{ role: 'cell', 'aria-colindex': 1, 'aria-label': activated_label } + %td{ role: 'cell', 'aria-colindex': 1, 'aria-label': activated_label, title: activated_label } = boolean_to_icon integration.operating? %td{ role: 'cell', 'aria-colindex': 2 } - = link_to scoped_edit_integration_path(integration), { data: { qa_selector: "#{integration.to_param}_link" } } do - %strong= integration.title + = link_to integration.title, scoped_edit_integration_path(integration), class: 'gl-font-weight-bold', data: { qa_selector: "#{integration.to_param}_link" } %td.d-none.d-sm-table-cell{ role: 'cell', 'aria-colindex': 3 } = integration.description %td{ role: 'cell', 'aria-colindex': 4 } diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index 09abe9e89c4..2f30958c877 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -1,5 +1,5 @@ - type = local_assigns.fetch(:type) -- bulk_issue_health_status_flag = Feature.enabled?(:bulk_update_health_status, @project&.group, default_enabled: true) && type == :issues && @project&.group&.feature_available?(:issuable_health_status) +- bulk_issue_health_status_flag = type == :issues && @project&.group&.feature_available?(:issuable_health_status) - epic_bulk_edit_flag = @project&.group&.feature_available?(:epics) && type == :issues %aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } } diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml deleted file mode 100644 index 3453db9f209..00000000000 --- a/app/views/shared/issuable/_close_reopen_button.html.haml +++ /dev/null @@ -1,26 +0,0 @@ -- is_current_user = issuable_author_is_current_user(issuable) -- display_issuable_type = issuable_display_type(issuable) -- are_close_and_open_buttons_hidden = issuable_button_hidden?(issuable, true) && issuable_button_hidden?(issuable, false) -- add_blocked_class = false -- if defined? warn_before_close - - add_blocked_class = warn_before_close - -- if is_current_user && !issuable.is_a?(MergeRequest) - - if can_update - %button{ class: "d-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", - data: { remote: 'true', endpoint: close_issuable_path(issuable), qa_selector: 'close_issue_button' } } - = _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type } - - if can_reopen - %button{ class: "d-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", - data: { remote: 'true', endpoint: reopen_issuable_path(issuable), qa_selector: 'reopen_issue_button' } } - = _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type } -- else - - if can_update && !are_close_and_open_buttons_hidden - - if issuable.is_a?(MergeRequest) - = render 'shared/issuable/close_reopen_draft_report_toggle', issuable: issuable - - else - = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class - - else - - unless issuable.is_a?(MergeRequest) && issuable.merged? - = link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), - class: 'd-none d-md-block btn btn-grouped btn-close-color', title: _('Report abuse') diff --git a/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml deleted file mode 100644 index bdb53dfe323..00000000000 --- a/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml +++ /dev/null @@ -1,37 +0,0 @@ -- display_issuable_type = issuable_display_type(issuable) -- button_action_class = issuable.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary' -- button_class = "btn gl-button #{!issuable.closed? && 'js-draft-toggle-button'}" -- toggle_class = "btn gl-button dropdown-toggle" - -.float-left.btn-group.gl-ml-3.issuable-close-dropdown.d-none.d-md-inline-flex.js-issuable-close-dropdown - = link_to issuable.closed? ? reopen_issuable_path(issuable) : toggle_draft_issuable_path(issuable), method: :put, class: "#{button_class} #{button_action_class}" do - - if issuable.closed? - = _('Reopen') - = display_issuable_type - - else - = issuable.work_in_progress? ? _('Mark as ready') : _('Mark as draft') - - - if !issuable.closed? || !issuable_author_is_current_user(issuable) - = button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do - %span.sr-only= _('Toggle dropdown') - = sprite_icon "angle-down", size: 12 - - %ul.js-issuable-close-menu.dropdown-menu.dropdown-menu-right - - if issuable.open? - %li - = link_to close_issuable_path(issuable), method: :put do - .description - %strong.title - = _('Close') - = display_issuable_type - - - unless issuable_author_is_current_user(issuable) - - unless issuable.closed? - %li.divider.droplab-item-ignore - - %li.report-item - %a.report-abuse-link{ href: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)) } - .description - %strong.title= _('Report abuse') - %p.text - = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize } diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml deleted file mode 100644 index 48d1e146629..00000000000 --- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml +++ /dev/null @@ -1,47 +0,0 @@ -- display_issuable_type = issuable_display_type(issuable) -- button_action = issuable.closed? ? 'reopen' : 'close' -- display_button_action = button_action.capitalize -- button_responsive_class = 'd-none d-md-block' -- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button" -- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle" -- add_blocked_class = false -- if defined? warn_before_close - - add_blocked_class = !issuable.closed? && warn_before_close - -.float-left.btn-group.gl-ml-3.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown - %button{ class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", data: { testid: 'close-issue-button', qa_selector: 'close_issue_button', endpoint: close_reopen_issuable_path(issuable) } } - #{display_button_action} #{display_issuable_type} - - = button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color", - data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => _('Toggle dropdown') do - = icon('caret-down', class: 'toggle-icon icon') - - %ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ data: { dropdown: true } } - %li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}", - data: { text: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: close_issuable_path(issuable), - button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color" } } - %button.btn.btn-transparent - = sprite_icon('check', css_class: 'icon') - .description - %strong.title - = _('Close') - = display_issuable_type - - %li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}", - data: { text: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: reopen_issuable_path(issuable), - button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color" } } - %button.btn.btn-transparent - = sprite_icon('check', css_class: 'icon') - .description - %strong.title - = _('Reopen') - = display_issuable_type - - %li.divider.droplab-item-ignore - - %li.report-item{ data: { text: _('Report abuse'), button_class: "#{button_class} btn-close-color", toggle_class: "#{toggle_class} btn-close-color", method: '' } } - %a.report-abuse-link{ :href => new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)) } - .description - %strong.title= _('Report abuse') - %p.text - = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize } diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index c0aba0eef7f..552f83906e1 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -32,7 +32,7 @@ = form.label :confidential, class: 'form-check-label' do This issue is confidential and should only be visible to team members with at least Reporter access. -= render 'shared/issuable/form/metadata', issuable: issuable, form: form, project: project += render 'shared/issuable/form/metadata', issuable: issuable, form: form, project: project, presenter: presenter = render_if_exists 'shared/issuable/approvals', issuable: issuable, presenter: presenter, form: form @@ -88,3 +88,6 @@ = form.hidden_field :issue_type = form.hidden_field :lock_version + +- if @vulnerability_id + = hidden_field_tag 'vulnerability_id', @vulnerability_id diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 00b235809ed..79d86500bd9 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -75,6 +75,22 @@ = render 'shared/issuable/user_dropdown_item', user: User.new(username: '{{username}}', name: '{{name}}'), avatar: { lazy: true, url: '{{avatar_url}}' } + #js-dropdown-reviewer.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + - if current_user + = render 'shared/issuable/user_dropdown_item', + user: current_user + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + = render 'shared/issuable/user_dropdown_item', + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } = render_if_exists 'shared/issuable/approver_dropdown' = render_if_exists 'shared/issuable/approved_by_dropdown' #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu @@ -182,7 +198,7 @@ = render 'shared/issuable/board_create_list_dropdown', board: board - if @project #js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } - - if current_user && Feature.enabled?(:boards_with_swimlanes, @group, default_enabled: true) + - if current_user #js-board-epics-swimlanes-toggle #js-toggle-focus-btn - elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 1f20c1a30aa..cd265c10451 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -25,7 +25,7 @@ .block.assignee.qa-assignee-block = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in - - if Feature.enabled?(:merge_request_reviewers, @project) && reviewers + - if Feature.enabled?(:merge_request_reviewers, @project, default_enabled: true) && reviewers .block.reviewer.qa-reviewer-block = render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers, signed_in: signed_in @@ -58,7 +58,7 @@ = f.hidden_field 'milestone_id', value: milestone[:id], id: nil = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }}) - if @project.group.present? - = render_if_exists 'shared/issuable/iteration_select', { can_edit: can_edit_issuable, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type } + = render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type - if issuable_sidebar[:supports_time_tracking] #issuable-time-tracker.block diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 94fa43746e2..a425f5f810e 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -2,7 +2,7 @@ - form = local_assigns.fetch(:form) - return unless issuable.is_a?(MergeRequest) -- return if issuable.closed_without_fork? +- return if issuable.closed_or_merged_without_fork? - source_title, target_title = format_mr_branch_names(@merge_request) diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index e29627304b4..7233e671caa 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -2,7 +2,7 @@ - project = local_assigns.fetch(:project) - return unless issuable.is_a?(MergeRequest) -- return if issuable.closed_without_fork? +- return if issuable.closed_or_merged_without_fork? .form-group.row .col-sm-2.col-form-label.pt-sm-0 diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 459eb112e4f..366e819d252 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -1,5 +1,6 @@ - project = local_assigns.fetch(:project) - issuable = local_assigns.fetch(:issuable) +- presenter = local_assigns.fetch(:presenter) - return unless can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project) @@ -14,7 +15,7 @@ - if issuable.allows_reviewers? .form-group.row.merge-request-reviewer - = render "shared/issuable/form/metadata_issuable_reviewer", issuable: issuable, form: form, has_due_date: has_due_date + = render "shared/issuable/form/metadata_issuable_reviewer", issuable: issuable, form: form, has_due_date: has_due_date, presenter: presenter = render_if_exists "shared/issuable/form/epic", issuable: issuable, form: form, project: project diff --git a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml index 60dc893d9f9..b437ee1ec5f 100644 --- a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml @@ -1,4 +1,4 @@ -= form.label :assignee_id, "Assignee", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" += form.label :assignee_id, issuable.allows_multiple_assignees? ? _('Assignees') : _('Assignee'), class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" .col-sm-10{ class: ("col-md-8" if has_due_date) } .issuable-form-select-holder.selectbox - issuable.assignees.each do |assignee| diff --git a/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml b/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml index a8b033bba36..a0df007f8ca 100644 --- a/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml +++ b/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml @@ -1,5 +1,5 @@ -= form.label :reviewer_id, "Reviewer", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" -.col-sm-10{ class: ("col-md-8" if has_due_date) } += form.label :reviewer_id, issuable.allows_multiple_reviewers? ? _('Reviewers') : _('Reviewer'), class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" +.col-sm-10.gl-mb-2{ class: ("col-md-8" if has_due_date) } .issuable-form-select-holder.selectbox - issuable.reviewers.each do |reviewer| = hidden_field_tag "#{issuable.to_ability_name}[reviewer_ids][]", reviewer.id, id: nil, data: { meta: reviewer.name, avatar_url: reviewer.avatar_url, name: reviewer.name, username: reviewer.username } @@ -7,4 +7,6 @@ - if issuable.reviewers.empty? = hidden_field_tag "#{issuable.to_ability_name}[reviewer_ids][]", 0, id: nil, data: { meta: '' } - = dropdown_tag(users_dropdown_label(issuable.reviewers), options: reviewers_dropdown_options(issuable.to_ability_name)) + = dropdown_tag(users_dropdown_label(issuable.reviewers), options: reviewers_dropdown_options(issuable.to_ability_name, issuable.iid, issuable.target_branch)) + - if Feature.enabled?(:mr_collapsed_approval_rules, @project) + = render_if_exists 'shared/issuable/approver_suggestion', issuable: issuable, presenter: presenter diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml index 5d64c15d9f9..67bc4019a82 100644 --- a/app/views/shared/issuable/form/_type_selector.html.haml +++ b/app/views/shared/issuable/form/_type_selector.html.haml @@ -13,7 +13,7 @@ .dropdown-title.gl-display-flex %span.gl-ml-auto = _("Select type") - %button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ "aria-label" => _('Close') } + %button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ type: 'button', "aria-label" => _('Close') } = sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon') .dropdown-content %ul diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml index ea4df288839..d6226760ba5 100644 --- a/app/views/shared/issue_type/_details_header.html.haml +++ b/app/views/shared/issue_type/_details_header.html.haml @@ -1,10 +1,3 @@ -- can_update_issue = can?(current_user, :update_issue, issuable) -- can_reopen_issue = can?(current_user, :reopen_issue, issuable) -- can_report_spam = issuable.submittable_as_spam_by?(current_user) -- can_create_issue = show_new_issue_link?(@project) -- display_issuable_type = issuable_display_type(issuable) -- new_issuable_params = ({ issuable_template: 'incident', issue: { issue_type: 'incident' } } if issuable.incident?) - .detail-page-header .detail-page-header-body .issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(issuable, status_box: :closed) } @@ -18,38 +11,9 @@ .issuable-meta #js-issuable-header-warnings - = issuable_meta(issuable, @project, display_issuable_type) + = issuable_meta(issuable, @project) %a.btn.gl-button.btn-default.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } = sprite_icon('chevron-double-lg-left') - - if Feature.enabled?(:vue_issue_header, @project, default_enabled: true) - .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user) } - - else - .detail-page-header-actions.js-issuable-actions.js-issuable-buttons{ data: { "action": "close-reopen" } } - .clearfix.issue-btn-group.dropdown - %button.btn.gl-button.btn-default.float-left.gl-display-md-none{ type: "button", data: { toggle: "dropdown" } } - = _('Options') - = icon('caret-down') - .dropdown-menu.dropdown-menu-right - %ul - - unless current_user == issuable.author - %li= link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issue_url(issuable)) - - if can_update_issue - %li= link_to _('Close %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, issue_path(issuable, issue: { state_event: :close }, format: 'json'), class: "btn-close js-btn-issue-action #{issue_button_visibility(issuable, true)}", title: _('Close %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, data: { endpoint: close_reopen_issuable_path(issuable) } - - if can_reopen_issue - %li= link_to _('Reopen %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, issue_path(issuable, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(issuable, false)}", title: _('Reopen %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, data: { endpoint: close_reopen_issuable_path(issuable) } - - if can_report_spam - %li= link_to _('Submit as spam'), mark_as_spam_project_issue_path(@project, issuable), method: :post, class: 'btn-spam', title: 'Submit as spam' - - if can_create_issue - - if can_update_issue || can_report_spam - %li.divider - %li= link_to _('New %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, new_project_issue_path(@project, new_issuable_params), id: 'new_%{display_issuable_type}_link' % { display_issuable_type: display_issuable_type } - - = render 'shared/issuable/close_reopen_button', issuable: issuable, can_update: can_update_issue, can_reopen: can_reopen_issue, warn_before_close: defined?(issuable.blocked?) && issuable.blocked? - - - if can_report_spam - = link_to _('Submit as spam'), mark_as_spam_project_issue_path(@project, issuable), method: :post, class: 'gl-display-none gl-display-md-block gl-button btn btn-grouped btn-spam', title: 'Submit as spam' - - if can_create_issue - = link_to new_project_issue_path(@project, new_issuable_params), class: 'gl-display-none gl-display-md-block gl-button btn btn-grouped btn-success btn-inverted', title: _('New %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, id: 'new_%{display_issuable_type}_link' % { display_issuable_type: display_issuable_type } do - = _('New %{display_issuable_type}') % { display_issuable_type: display_issuable_type } + .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user) } diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml index 07e96eea062..cfc00bd41ca 100644 --- a/app/views/shared/labels/_sort_dropdown.html.haml +++ b/app/views/shared/labels/_sort_dropdown.html.haml @@ -2,7 +2,7 @@ .dropdown.inline %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } = sort_title - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort %li - label_sort_options_hash.each do |value, title| diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 42e12d92a7d..d98ba074687 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -27,7 +27,7 @@ data: { toggle: "dropdown", field_name: "group_link[group_access]" } } %span.dropdown-toggle-text = group_link.human_access - = icon("chevron-down") + = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3") .dropdown-menu.dropdown-select.dropdown-menu-right.dropdown-menu-selectable = dropdown_title(_("Change permissions")) .dropdown-content diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index e294936f82c..79bbb74d601 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -79,7 +79,7 @@ data: { toggle: "dropdown", field_name: "#{f.object_name}[access_level]", qa_selector: "access_level_dropdown" } } %span.dropdown-toggle-text = member.human_access - = icon("chevron-down") + = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3") .dropdown-menu.dropdown-select.dropdown-menu-right.dropdown-menu-selectable = dropdown_title(_("Change permissions")) .dropdown-content diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml index 93da319fce7..19ca00ce482 100644 --- a/app/views/shared/milestones/_header.html.haml +++ b/app/views/shared/milestones/_header.html.haml @@ -28,7 +28,7 @@ - if milestone.active? = link_to _('Close milestone'), update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'btn gl-button btn-grouped btn-close' - else - = link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn gl-button btn-grouped btn-reopen' + = link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn gl-button btn-grouped' = render 'shared/milestones/delete_button' diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 1597a011a45..92ac6929e6a 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -59,6 +59,6 @@ - if can?(current_user, :admin_milestone, milestone) - if milestone.closed? - = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn gl-button btn-sm btn-grouped btn-reopen" + = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn gl-button btn-sm btn-grouped" - else = link_to s_('Milestones|Close Milestone'), milestone_path(milestone, milestone: { state_event: :close }), method: :put, class: "btn gl-button btn-warning-secondary btn-sm btn-grouped btn-close" diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index 45af4b51b27..eb03608e18a 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -1,11 +1,11 @@ - noteable_name = @note.noteable.human_class_name .float-left.btn-group.gl-mr-3.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown - %input.btn.btn-nr.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } } + %input.btn.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } } - if @note.can_be_discussion_note? - = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle btn-success js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do - = icon('caret-down', class: 'toggle-icon') + = button_tag type: 'button', class: 'btn dropdown-toggle btn-success js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do + = sprite_icon('chevron-down') %ul#resolvable-comment-menu.dropdown-menu.dropdown-open-top{ data: { dropdown: true } } %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => _('Comment'), 'close-text' => _("Comment & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Comment & reopen %{noteable_name}") % { noteable_name: noteable_name } } } diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index 79feb12bed5..d783fa0d777 100644 --- a/app/views/shared/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -9,6 +9,6 @@ .note-form-actions.clearfix .settings-message.note-edit-warning.js-finish-edit-warning = _("Finish editing this message first!") - = submit_tag _('Save comment'), class: 'btn btn-nr btn-success js-comment-save-button', data: { qa_selector: 'save_comment_button' } - %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' } + = submit_tag _('Save comment'), class: 'btn btn-success js-comment-save-button', data: { qa_selector: 'save_comment_button' } + %button.btn.btn-cancel.note-edit-cancel{ type: 'button' } = _("Cancel") diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index f1686417f8d..2cf074b9d3f 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -38,7 +38,5 @@ .note-form-actions.clearfix = render partial: 'shared/notes/comment_button' - = yield(:note_actions) - %a.btn.btn-cancel.js-close-discussion-note-form.hide{ role: "button", data: { cancel_text: _("Cancel") } } = _('Cancel') diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index d7b53810f76..e12531b8a8d 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -20,8 +20,8 @@ %button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } = sprite_icon("notifications", css_class: "js-notification-loading") = notification_title(notification_setting.level) - %button.btn.dropdown-toggle.d-flex{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } - = icon('caret-down') + %button.btn.dropdown-toggle.gl-display-flex.gl-align-items-center{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + = sprite_icon('chevron-down') .sr-only Toggle dropdown - else %button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } @@ -29,7 +29,7 @@ = sprite_icon("notifications", css_class: "js-notification-loading") = notification_title(notification_setting.level) .float-right - = icon("caret-down") + = sprite_icon("chevron-down") = render "shared/notifications/notification_dropdown", notification_setting: notification_setting diff --git a/app/views/shared/projects/_sort_dropdown.html.haml b/app/views/shared/projects/_sort_dropdown.html.haml index f5f940db189..3e810dc6f08 100644 --- a/app/views/shared/projects/_sort_dropdown.html.haml +++ b/app/views/shared/projects/_sort_dropdown.html.haml @@ -5,7 +5,7 @@ .btn-group.w-100.dropdown.js-project-filter-dropdown-wrap{ role: "group" } %button#sort-projects-dropdown.btn.btn-default.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } = toggle_text - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li.dropdown-header = _("Sort by") diff --git a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml index eafc402f210..cb954c20b48 100644 --- a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml @@ -1,3 +1,5 @@ +- select_mode_for_dropdown = Feature.enabled?(:deploy_keys_on_protected_branches, protected_branch.project) ? 'js-multiselect' : '' + - merge_access_levels = protected_branch.merge_access_levels.for_role - push_access_levels = protected_branch.push_access_levels.for_role @@ -23,7 +25,7 @@ %td.push_access_levels-container = hidden_field_tag "allowed_to_push_#{protected_branch.id}", push_access_levels.first&.access_level = dropdown_tag( (push_access_levels.first&.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', + options: { toggle_class: "js-allowed-to-push #{select_mode_for_dropdown}", dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', data: { field_name: "allowed_to_push_#{protected_branch.id}", preselected_items: access_levels_data(push_access_levels) }}) - if user_push_access_levels.any? %p.small diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index c5234f14090..c37a34f9be8 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -10,89 +10,91 @@ = s_('Webhooks|Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header.') .form-group = form.label :url, s_('Webhooks|Trigger'), class: 'label-bold' - %ul.list-unstyled.prepend-left-20 + %ul.list-unstyled.gl-ml-6 %li = form.check_box :push_events, class: 'form-check-input' - = form.label :push_events, class: 'list-label form-check-label ml-1' do + = form.label :push_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Push events') = form.text_field :push_events_branch_filter, class: 'form-control', placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)' - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered by a push to the repository') %li = form.check_box :tag_push_events, class: 'form-check-input' - = form.label :tag_push_events, class: 'list-label form-check-label ml-1' do + = form.label :tag_push_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Tag push events') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered when a new tag is pushed to the repository') %li = form.check_box :note_events, class: 'form-check-input' - = form.label :note_events, class: 'list-label form-check-label ml-1' do + = form.label :note_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Comments') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered when someone adds a comment') %li = form.check_box :confidential_note_events, class: 'form-check-input' - = form.label :confidential_note_events, class: 'list-label form-check-label ml-1' do + = form.label :confidential_note_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Confidential Comments') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered when someone adds a comment on a confidential issue') %li = form.check_box :issues_events, class: 'form-check-input' - = form.label :issues_events, class: 'list-label form-check-label ml-1' do + = form.label :issues_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Issues events') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered when an issue is created/updated/merged') %li = form.check_box :confidential_issues_events, class: 'form-check-input' - = form.label :confidential_issues_events, class: 'list-label form-check-label ml-1' do + = form.label :confidential_issues_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Confidential Issues events') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered when a confidential issue is created/updated/merged') + - if @group + = render_if_exists 'groups/hooks/member_events', form: form %li = form.check_box :merge_requests_events, class: 'form-check-input' - = form.label :merge_requests_events, class: 'list-label form-check-label ml-1' do + = form.label :merge_requests_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Merge request events') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered when a merge request is created/updated/merged') %li = form.check_box :job_events, class: 'form-check-input' - = form.label :job_events, class: 'list-label form-check-label ml-1' do + = form.label :job_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Job events') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered when the job status changes') %li = form.check_box :pipeline_events, class: 'form-check-input' - = form.label :pipeline_events, class: 'list-label form-check-label ml-1' do + = form.label :pipeline_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Pipeline events') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered when the pipeline status changes') %li = form.check_box :wiki_page_events, class: 'form-check-input' - = form.label :wiki_page_events, class: 'list-label form-check-label ml-1' do + = form.label :wiki_page_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Wiki Page events') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered when a wiki page is created/updated') %li = form.check_box :deployment_events, class: 'form-check-input' - = form.label :deployment_events, class: 'list-label form-check-label ml-1' do + = form.label :deployment_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Deployment events') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL is triggered when a deployment starts, finishes, fails, or is canceled') %li = form.check_box :feature_flag_events, class: 'form-check-input' - = form.label :feature_flag_events, class: 'list-label form-check-label ml-1' do + = form.label :feature_flag_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Feature Flag events') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL is triggered when a feature flag is turned on or off') %li = form.check_box :releases_events, class: 'form-check-input' - = form.label :releases_events, class: 'list-label form-check-label ml-1' do + = form.label :releases_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Releases events') - %p.text-muted.ml-1 + %p.text-muted.gl-ml-1 = s_('Webhooks|This URL is triggered when a release is created/updated') .form-group = form.label :enable_ssl_verification, s_('Webhooks|SSL verification'), class: 'label-bold checkbox' .form-check = form.check_box :enable_ssl_verification, class: 'form-check-input' - = form.label :enable_ssl_verification, class: 'form-check-label ml-1' do + = form.label :enable_ssl_verification, class: 'form-check-label gl-ml-1' do %strong= s_('Webhooks|Enable SSL verification') diff --git a/app/views/shared/web_hooks/_test_button.html.haml b/app/views/shared/web_hooks/_test_button.html.haml index fc24e425ab6..c46b8a99886 100644 --- a/app/views/shared/web_hooks/_test_button.html.haml +++ b/app/views/shared/web_hooks/_test_button.html.haml @@ -5,7 +5,7 @@ .hook-test-button.dropdown.inline> %button.btn{ 'data-toggle' => 'dropdown', class: button_class } = _('Test') - = icon('caret-down') + = sprite_icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } - triggers.each_value do |event| %li diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml index dde1b3afa2d..b6504c7a17e 100644 --- a/app/views/shared/wikis/_form.html.haml +++ b/app/views/shared/wikis/_form.html.haml @@ -36,7 +36,7 @@ .col-sm-10 .select-wrapper = f.select :format, options_for_select(Wiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control select-control' - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'gl-absolute gl-top-3 gl-right-3 gl-text-gray-200') .form-group.row .col-sm-2.col-form-label= f.label :content, class: 'control-label-full-width' diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml index c0ed7b4c6f2..a906bf7aa63 100644 --- a/app/views/shared/wikis/_sidebar.html.haml +++ b/app/views/shared/wikis/_sidebar.html.haml @@ -4,17 +4,19 @@ %a.gutter-toggle.float-right.d-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" } = sprite_icon('chevron-double-lg-right', css_class: 'gl-icon') - - if @wiki.container.is_a?(Project) - - git_access_url = wiki_path(@wiki, action: :git_access) - = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '', data: { qa_selector: 'clone_repository_link' } do - = sprite_icon('download', css_class: 'gl-mr-2') - %span= _("Clone repository") + - git_access_url = wiki_path(@wiki, action: :git_access) + = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '', data: { qa_selector: 'clone_repository_link' } do + = sprite_icon('download', css_class: 'gl-mr-2') + %span= _("Clone repository") + + - if @sidebar_error.present? + = render 'shared/alert_info', body: s_('Wiki|The sidebar failed to load. You can reload the page to try again.') .blocks-container .block.block-first.w-100 - if @sidebar_page = render_wiki_content(@sidebar_page) - - else + - elsif @sidebar_wiki_entries %ul.wiki-pages = render @sidebar_wiki_entries, context: 'sidebar' .block.w-100 diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/shared/wikis/git_access.html.haml index c166642bae2..2542860c742 100644 --- a/app/views/projects/wikis/git_access.html.haml +++ b/app/views/shared/wikis/git_access.html.haml @@ -11,7 +11,7 @@ %strong= @wiki.full_path .pt-3.pt-lg-0.w-100 - = render "shared/clone_panel", project: @wiki + = render "shared/clone_panel", container: @wiki .wiki-git-access %h3= s_("WikiClone|Install Gollum") diff --git a/app/views/shared/wikis/git_error.html.haml b/app/views/shared/wikis/git_error.html.haml new file mode 100644 index 00000000000..dab3b940b9a --- /dev/null +++ b/app/views/shared/wikis/git_error.html.haml @@ -0,0 +1,14 @@ +- if @page + - wiki_page_title @page + +- add_page_specific_style 'page_bundles/wiki' + +- git_access_url = wiki_path(@wiki, action: :git_access) + +.wiki-page-header.top-area.gl-flex-direction-column.gl-lg-flex-direction-row + .gl-mt-5.gl-mb-3 + .gl-display-flex.gl-justify-content-space-between + %h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page ? @page.human_title : _('Failed to retrieve page') + .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content' } } + = _('The page could not be displayed because it timed out.') + = html_escape(_('You can view the source or %{linkStart}%{cloneIcon} clone the repository%{linkEnd}')) % { linkStart: "<a href=\"#{git_access_url}\">".html_safe, linkEnd: '</a>'.html_safe, cloneIcon: sprite_icon('download', css_class: 'gl-mr-2').html_safe } diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index 1367d80cf54..a78971967ff 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -18,7 +18,7 @@ %h4.gl-flex-grow-1 = Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity') = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all" - .overview-content-list{ data: { href: user_path } } + .overview-content-list{ data: { href: user_activity_path } } .center.light.loading .spinner.spinner-md diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index ee037a7d66a..9f6b0bc2373 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -1,7 +1,7 @@ - @hide_top_links = true - @hide_breadcrumbs = true - @no_container = true -- page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name +- page_title user_display_name(@user) - page_description @user.bio_html - header_title @user.name, user_path(@user) - page_itemtype 'http://schema.org/Person' @@ -38,10 +38,10 @@ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '', itemprop: 'image' - - if @user.blocked? + - if @user.blocked? || !@user.confirmed? .user-info .cover-title - = s_('UserProfile|Blocked user') + = user_display_name(@user) = render "users/profile_basic_info" - else .user-info @@ -139,7 +139,7 @@ - if can?(current_user, :read_cross_project) %h4.prepend-top-20 = s_('UserProfile|Most Recent Activity') - .content_list{ data: { href: user_path } } + .content_list{ data: { href: user_activity_path } } .loading .spinner.spinner-md - unless @user.bot? diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 6f080a97f7a..1f2e8213b64 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -124,7 +124,7 @@ :idempotent: :tags: [] - :name: cronjob:analytics_instance_statistics_count_job_trigger - :feature_category: :instance_statistics + :feature_category: :devops_reports :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -323,6 +323,22 @@ :weight: 1 :idempotent: :tags: [] +- :name: cronjob:releases_create_evidence + :feature_category: :release_evidence + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] +- :name: cronjob:releases_manage_evidence + :feature_category: :release_evidence + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: cronjob:remove_expired_group_links :feature_category: :authentication_and_authorization :has_external_dependencies: @@ -380,7 +396,7 @@ :idempotent: :tags: [] - :name: cronjob:schedule_merge_request_cleanup_refs - :feature_category: :source_code_management + :feature_category: :code_review :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -388,7 +404,7 @@ :idempotent: true :tags: [] - :name: cronjob:schedule_migrate_external_diffs - :feature_category: :source_code_management + :feature_category: :code_review :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -412,7 +428,7 @@ :idempotent: :tags: [] - :name: cronjob:stuck_merge_jobs - :feature_category: :source_code_management + :feature_category: :code_review :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -691,6 +707,22 @@ :weight: 1 :idempotent: :tags: [] +- :name: github_importer:github_import_import_pull_request_merged_by + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] +- :name: github_importer:github_import_import_pull_request_review + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: github_importer:github_import_refresh_import_jid :feature_category: :importers :has_external_dependencies: @@ -747,6 +779,22 @@ :weight: 1 :idempotent: :tags: [] +- :name: github_importer:github_import_stage_import_pull_requests_merged_by + :feature_category: :importers + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] +- :name: github_importer:github_import_stage_import_pull_requests_reviews + :feature_category: :importers + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: github_importer:github_import_stage_import_repository :feature_category: :importers :has_external_dependencies: @@ -829,15 +877,23 @@ :tags: [] - :name: jira_connect:jira_connect_sync_branch :feature_category: :integrations - :has_external_dependencies: + :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: :tags: [] +- :name: jira_connect:jira_connect_sync_builds + :feature_category: :integrations + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: jira_connect:jira_connect_sync_merge_request :feature_category: :integrations - :has_external_dependencies: + :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1045,6 +1101,14 @@ :weight: 1 :idempotent: true :tags: [] +- :name: pipeline_background:ci_test_failure_history + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: pipeline_cache:expire_job_cache :feature_category: :continuous_integration :has_external_dependencies: @@ -1313,7 +1377,15 @@ :idempotent: true :tags: [] - :name: analytics_instance_statistics_counter_job - :feature_category: :instance_statistics + :feature_category: :devops_reports + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: approve_blocked_pending_approval_users + :feature_category: :users :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1377,16 +1449,8 @@ :weight: 2 :idempotent: true :tags: [] -- :name: create_evidence - :feature_category: :release_evidence - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 2 - :idempotent: - :tags: [] - :name: create_note_diff_file - :feature_category: :source_code_management + :feature_category: :code_review :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1402,7 +1466,7 @@ :idempotent: :tags: [] - :name: delete_diff_files - :feature_category: :source_code_management + :feature_category: :code_review :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1497,6 +1561,14 @@ :weight: 2 :idempotent: :tags: [] +- :name: environments_canary_ingress_update + :feature_category: :continuous_delivery + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: error_tracking_issue_link :feature_category: :error_tracking :has_external_dependencies: true @@ -1562,6 +1634,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: gitlab_performance_bar_stats + :feature_category: :metrics + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: gitlab_shell :feature_category: :source_code_management :has_external_dependencies: @@ -1601,7 +1681,7 @@ :urgency: :low :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: true :tags: [] - :name: invalid_gpg_signature_update :feature_category: :source_code_management @@ -1660,7 +1740,7 @@ :idempotent: :tags: [] - :name: merge_request_cleanup_refs - :feature_category: :source_code_management + :feature_category: :code_review :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1668,7 +1748,7 @@ :idempotent: true :tags: [] - :name: merge_request_mergeability_check - :feature_category: :source_code_management + :feature_category: :code_review :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1692,7 +1772,7 @@ :idempotent: true :tags: [] - :name: migrate_external_diffs - :feature_category: :source_code_management + :feature_category: :code_review :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1707,6 +1787,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: namespaces_onboarding_user_added + :feature_category: :users + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: new_issue :feature_category: :issue_tracking :has_external_dependencies: @@ -1716,7 +1804,7 @@ :idempotent: :tags: [] - :name: new_merge_request - :feature_category: :source_code_management + :feature_category: :code_review :has_external_dependencies: :urgency: :high :resource_boundary: :cpu @@ -1812,7 +1900,7 @@ :urgency: :high :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: true :tags: [] - :name: project_daily_statistics :feature_category: :source_code_management @@ -1839,6 +1927,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: project_schedule_bulk_repository_shard_moves + :feature_category: :gitaly + :has_external_dependencies: + :urgency: :throttled + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: project_service :feature_category: :integrations :has_external_dependencies: true @@ -1973,7 +2069,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: true :tags: [] - :name: self_monitoring_project_create :feature_category: :metrics @@ -2024,7 +2120,7 @@ :idempotent: true :tags: [] - :name: update_merge_requests - :feature_category: :source_code_management + :feature_category: :code_review :has_external_dependencies: :urgency: :high :resource_boundary: :cpu diff --git a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb index bf57619fc6e..81a765d5d08 100644 --- a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb +++ b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb @@ -8,7 +8,7 @@ module Analytics DEFAULT_DELAY = 3.minutes.freeze - feature_category :instance_statistics + feature_category :devops_reports urgency :low idempotent! diff --git a/app/workers/analytics/instance_statistics/counter_job_worker.rb b/app/workers/analytics/instance_statistics/counter_job_worker.rb index 7fc715419b8..c07b2569453 100644 --- a/app/workers/analytics/instance_statistics/counter_job_worker.rb +++ b/app/workers/analytics/instance_statistics/counter_job_worker.rb @@ -5,7 +5,7 @@ module Analytics class CounterJobWorker include ApplicationWorker - feature_category :instance_statistics + feature_category :devops_reports urgency :low idempotent! diff --git a/app/workers/approve_blocked_pending_approval_users_worker.rb b/app/workers/approve_blocked_pending_approval_users_worker.rb new file mode 100644 index 00000000000..8ca61d68bfd --- /dev/null +++ b/app/workers/approve_blocked_pending_approval_users_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ApproveBlockedPendingApprovalUsersWorker + include ApplicationWorker + + idempotent! + + feature_category :users + + def perform(current_user_id) + current_user = User.find(current_user_id) + + User.blocked_pending_approval.find_each do |user| + Users::ApproveService.new(current_user).execute(user) + end + end +end diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index af2305528ce..d7a5fcf4f18 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -33,11 +33,6 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker BuildCoverageWorker.new.perform(build.id) Ci::BuildReportResultWorker.new.perform(build.id) - # TODO: As per https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/194, it may be - # best to avoid creating more workers that we have no intention of calling async. - # Change the previous worker calls on top to also just call the service directly. - Ci::TestCasesService.new.execute(build) - # We execute these async as these are independent operations. BuildHooksWorker.perform_async(build.id) ExpirePipelineCacheWorker.perform_async(build.pipeline_id) if build.pipeline.cacheable? diff --git a/app/workers/ci/test_failure_history_worker.rb b/app/workers/ci/test_failure_history_worker.rb new file mode 100644 index 00000000000..e1562cb3836 --- /dev/null +++ b/app/workers/ci/test_failure_history_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Ci + class TestFailureHistoryWorker + include ApplicationWorker + include PipelineBackgroundQueue + + idempotent! + + def perform(pipeline_id) + Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| + Ci::TestFailureHistoryService.new(pipeline).execute + end + end + end +end diff --git a/app/workers/clusters/applications/check_prometheus_health_worker.rb b/app/workers/clusters/applications/check_prometheus_health_worker.rb index 2e8ee739946..cf9534c9a78 100644 --- a/app/workers/clusters/applications/check_prometheus_health_worker.rb +++ b/app/workers/clusters/applications/check_prometheus_health_worker.rb @@ -20,7 +20,7 @@ module Clusters demo_project_ids = Gitlab::Monitor::DemoProjects.primary_keys clusters = Clusters::Cluster.with_application_prometheus - .with_project_alert_service_data(demo_project_ids) + .with_project_http_integrations(demo_project_ids) # Move to a seperate worker with scoped context if expanded to do work on customer projects clusters.each { |cluster| Clusters::Applications::PrometheusHealthCheckService.new(cluster).execute } diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index 63c1ba8e699..575cd4862b0 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -15,17 +15,25 @@ module Gitlab feature_category :importers worker_has_external_dependencies! + + def logger + @logger ||= Gitlab::Import::Logger.build + end end # project - An instance of `Project` to import the data into. # client - An instance of `Gitlab::GithubImport::Client` # hash - A Hash containing the details of the object to import. def import(project, client, hash) - object = representation_class.from_json_hash(hash) + info(project.id, message: 'starting importer') + object = representation_class.from_json_hash(hash) importer_class.new(object, project, client).execute counter.increment + info(project.id, message: 'importer finished') + rescue => e + error(project.id, e) end def counter @@ -52,6 +60,35 @@ module Gitlab def counter_description raise NotImplementedError end + + private + + def info(project_id, extra = {}) + logger.info(log_attributes(project_id, extra)) + end + + def error(project_id, exception) + logger.error( + log_attributes( + project_id, + message: 'importer failed', + 'error.message': exception.message + ) + ) + + Gitlab::ErrorTracking.track_and_raise_exception( + exception, + log_attributes(project_id) + ) + end + + def log_attributes(project_id, extra = {}) + extra.merge( + import_source: :github, + project_id: project_id, + importer: importer_class.name + ) + end end end end diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb index 1c6413674a0..eb1af0869bd 100644 --- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb +++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb @@ -6,7 +6,7 @@ module Gitlab # importing GitHub projects. module ReschedulingMethods # project_id - The ID of the GitLab project to import the note into. - # hash - A Hash containing the details of the GitHub object to imoprt. + # hash - A Hash containing the details of the GitHub object to import. # notify_key - The Redis key to notify upon completion, if any. # rubocop: disable CodeReuse/ActiveRecord def perform(project_id, hash, notify_key = nil) diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index e2dee315cde..e5985fb94da 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -5,11 +5,17 @@ module Gitlab module StageMethods # project_id - The ID of the GitLab project to import the data into. def perform(project_id) + info(project_id, message: 'starting stage') + return unless (project = find_project(project_id)) client = GithubImport.new_client_for(project) try_import(client, project) + + info(project_id, message: 'stage finished') + rescue => e + error(project_id, e) end # client - An instance of Gitlab::GithubImport::Client. @@ -27,6 +33,39 @@ module Gitlab Project.joins_import_state.where(import_state: { status: :started }).find_by(id: id) end # rubocop: enable CodeReuse/ActiveRecord + + private + + def info(project_id, extra = {}) + logger.info(log_attributes(project_id, extra)) + end + + def error(project_id, exception) + logger.error( + log_attributes( + project_id, + message: 'stage failed', + 'error.message': exception.message + ) + ) + + Gitlab::ErrorTracking.track_and_raise_exception( + exception, + log_attributes(project_id) + ) + end + + def log_attributes(project_id, extra = {}) + extra.merge( + import_source: :github, + project_id: project_id, + import_stage: self.class.name + ) + end + + def logger + @logger ||= Gitlab::Import::Logger.build + end end end end diff --git a/app/workers/concerns/limited_capacity/worker.rb b/app/workers/concerns/limited_capacity/worker.rb index b5a97e49300..9dd8d942146 100644 --- a/app/workers/concerns/limited_capacity/worker.rb +++ b/app/workers/concerns/limited_capacity/worker.rb @@ -73,7 +73,7 @@ module LimitedCapacity raise ensure job_tracker.remove(jid) - report_prometheus_metrics + report_prometheus_metrics(*args) re_enqueue(*args) unless exception end diff --git a/app/workers/concerns/reenqueuer.rb b/app/workers/concerns/reenqueuer.rb index 6f399b6d90b..641ca691868 100644 --- a/app/workers/concerns/reenqueuer.rb +++ b/app/workers/concerns/reenqueuer.rb @@ -37,6 +37,7 @@ module Reenqueuer include ReenqueuerSleeper sidekiq_options retry: false + deduplicate :none end def perform(*args) @@ -52,7 +53,11 @@ module Reenqueuer private def reenqueue(*args) - self.class.perform_async(*args) if yield + result = yield + + self.class.perform_async(*args) if result + + result end # Override as needed diff --git a/app/workers/concerns/worker_context.rb b/app/workers/concerns/worker_context.rb index f2ff3ecfb6b..6acb9acceeb 100644 --- a/app/workers/concerns/worker_context.rb +++ b/app/workers/concerns/worker_context.rb @@ -5,7 +5,7 @@ module WorkerContext class_methods do def worker_context(attributes) - @worker_context = Gitlab::ApplicationContext.new(attributes) + @worker_context = Gitlab::ApplicationContext.new(**attributes) end def get_worker_context @@ -60,6 +60,6 @@ module WorkerContext end def with_context(context, &block) - Gitlab::ApplicationContext.new(context).use { yield(**context) } + Gitlab::ApplicationContext.new(**context).use { yield(**context) } end end diff --git a/app/workers/create_evidence_worker.rb b/app/workers/create_evidence_worker.rb deleted file mode 100644 index b18028e4114..00000000000 --- a/app/workers/create_evidence_worker.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - feature_category :release_evidence - weight 2 - - # pipeline_id is optional for backward compatibility with existing jobs - # caller should always try to provide the pipeline and pass nil only - # if pipeline is absent - def perform(release_id, pipeline_id = nil) - release = Release.find_by_id(release_id) - return unless release - - pipeline = Ci::Pipeline.find_by_id(pipeline_id) - - ::Releases::CreateEvidenceService.new(release, pipeline: pipeline).execute - end -end diff --git a/app/workers/create_note_diff_file_worker.rb b/app/workers/create_note_diff_file_worker.rb index 8a1709f04e1..06790cc89d9 100644 --- a/app/workers/create_note_diff_file_worker.rb +++ b/app/workers/create_note_diff_file_worker.rb @@ -3,7 +3,7 @@ class CreateNoteDiffFileWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - feature_category :source_code_management + feature_category :code_review def perform(diff_note_id) diff_note = DiffNote.find(diff_note_id) diff --git a/app/workers/delete_diff_files_worker.rb b/app/workers/delete_diff_files_worker.rb index a31cf650b83..289df8873ec 100644 --- a/app/workers/delete_diff_files_worker.rb +++ b/app/workers/delete_diff_files_worker.rb @@ -3,7 +3,7 @@ class DeleteDiffFilesWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - feature_category :source_code_management + feature_category :code_review # rubocop: disable CodeReuse/ActiveRecord def perform(merge_request_diff_id) diff --git a/app/workers/environments/canary_ingress/update_worker.rb b/app/workers/environments/canary_ingress/update_worker.rb new file mode 100644 index 00000000000..53cc38e9eec --- /dev/null +++ b/app/workers/environments/canary_ingress/update_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Environments + module CanaryIngress + class UpdateWorker + include ApplicationWorker + + sidekiq_options retry: false + idempotent! + worker_has_external_dependencies! + feature_category :continuous_delivery + + def perform(environment_id, params) + Environment.find_by_id(environment_id).try do |environment| + Environments::CanaryIngress::UpdateService + .new(environment.project, nil, params.with_indifferent_access) + .execute(environment) + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index 834c2f7791c..af406b32415 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -16,6 +16,8 @@ module Gitlab # The known importer stages and their corresponding Sidekiq workers. STAGES = { + pull_requests_merged_by: Stage::ImportPullRequestsMergedByWorker, + pull_request_reviews: Stage::ImportPullRequestsReviewsWorker, issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker, notes: Stage::ImportNotesWorker, lfs_objects: Stage::ImportLfsObjectsWorker, diff --git a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb new file mode 100644 index 00000000000..79ef917bbc5 --- /dev/null +++ b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ImportPullRequestMergedByWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def representation_class + Gitlab::GithubImport::Representation::PullRequest + end + + def importer_class + Importer::PullRequestMergedByImporter + end + + def counter_name + :github_importer_imported_pull_requests_merged_by + end + + def counter_description + 'The number of imported GitHub pull requests merged by' + end + end + end +end diff --git a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb new file mode 100644 index 00000000000..b8516fb8670 --- /dev/null +++ b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ImportPullRequestReviewWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def representation_class + Gitlab::GithubImport::Representation::PullRequestReview + end + + def importer_class + Importer::PullRequestReviewImporter + end + + def counter_name + :github_importer_imported_pull_request_reviews + end + + def counter_description + 'The number of imported GitHub pull request reviews' + end + end + end +end diff --git a/app/workers/gitlab/github_import/import_pull_request_worker.rb b/app/workers/gitlab/github_import/import_pull_request_worker.rb index ec806ad170b..9560874f247 100644 --- a/app/workers/gitlab/github_import/import_pull_request_worker.rb +++ b/app/workers/gitlab/github_import/import_pull_request_worker.rb @@ -6,7 +6,7 @@ module Gitlab include ObjectImporter def representation_class - Representation::PullRequest + Gitlab::GithubImport::Representation::PullRequest end def importer_class diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb index 73699a74a4a..058e1a0853d 100644 --- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb +++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb @@ -20,12 +20,15 @@ module Gitlab def report_import_time(project) duration = Time.zone.now - project.created_at - path = project.full_path - histogram.observe({ project: path }, duration) + histogram.observe({ project: project.full_path }, duration) counter.increment - logger.info("GitHub importer finished for #{path} in #{duration.round(2)} seconds") + info( + project.id, + message: "GitHub project import finished", + duration_s: duration.round(2) + ) end def histogram diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb index 11c2a2ac9b4..202bb335ca1 100644 --- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb @@ -20,6 +20,7 @@ module Gitlab # project - An instance of Project. def import(client, project) IMPORTERS.each do |klass| + info(project.id, message: "starting importer", importer: klass.name) klass.new(project, client).execute end diff --git a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb index 68b6e159fa4..486057804b4 100644 --- a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb @@ -19,6 +19,7 @@ module Gitlab # project - An instance of Project. def import(client, project) waiters = IMPORTERS.each_with_object({}) do |klass, hash| + info(project.id, message: "starting importer", importer: klass.name) waiter = klass.new(project, client).execute hash[waiter.key] = waiter.jobs_remaining end diff --git a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb index a19df399969..de2a7f9fc29 100644 --- a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb @@ -16,6 +16,8 @@ module Gitlab # project - An instance of Project. def import(project) + info(project.id, message: "starting importer", importer: 'Importer::LfsObjectsImporter') + waiter = Importer::LfsObjectsImporter .new(project, nil) .execute diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb index 49b9821cd45..e1da26a9d48 100644 --- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb @@ -11,6 +11,7 @@ module Gitlab # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) + info(project.id, message: "starting importer", importer: 'Importer::NotesImporter') waiter = Importer::NotesImporter .new(project, client) .execute diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb new file mode 100644 index 00000000000..3e15c346659 --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportPullRequestsMergedByWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include GithubImport::Queue + include StageMethods + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def import(client, project) + waiter = Importer::PullRequestsMergedByImporter + .new(project, client) + .execute + + project.import_state.refresh_jid_expiration + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :pull_request_reviews + ) + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb new file mode 100644 index 00000000000..0809d0b7c29 --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportPullRequestsReviewsWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include GithubImport::Queue + include StageMethods + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def import(client, project) + waiter = + if Feature.enabled?(:github_import_pull_request_reviews, project, default_enabled: true) + waiter = Importer::PullRequestsReviewsImporter + .new(project, client) + .execute + + project.import_state.refresh_jid_expiration + + waiter + else + JobWaiter.new + end + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :issues_and_diff_notes + ) + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb index 3299db5653b..bf2defa6326 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb @@ -11,6 +11,7 @@ module Gitlab # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) + info(project.id, message: "starting importer", importer: 'Importer::PullRequestsImporter') waiter = Importer::PullRequestsImporter .new(project, client) .execute @@ -20,7 +21,7 @@ module Gitlab AdvanceStageWorker.perform_async( project.id, { waiter.key => waiter.jobs_remaining }, - :issues_and_diff_notes + :pull_requests_merged_by ) end end diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb index cb9ef1cd198..3338f7e58c0 100644 --- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb @@ -21,6 +21,7 @@ module Gitlab # expiration time. RefreshImportJidWorker.perform_in_the_future(project.id, jid) + info(project.id, message: "starting importer", importer: 'Importer::RepositoryImporter') importer = Importer::RepositoryImporter.new(project, client) return unless importer.execute diff --git a/app/workers/gitlab_performance_bar_stats_worker.rb b/app/workers/gitlab_performance_bar_stats_worker.rb new file mode 100644 index 00000000000..d63f8111864 --- /dev/null +++ b/app/workers/gitlab_performance_bar_stats_worker.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class GitlabPerformanceBarStatsWorker + include ApplicationWorker + + LEASE_KEY = 'gitlab:performance_bar_stats' + LEASE_TIMEOUT = 600 + WORKER_DELAY = 120 + STATS_KEY = 'performance_bar_stats:pending_request_ids' + + feature_category :metrics + idempotent! + + def perform(lease_uuid) + Gitlab::Redis::SharedState.with do |redis| + request_ids = fetch_request_ids(redis, lease_uuid) + stats = Gitlab::PerformanceBar::Stats.new(redis) + + request_ids.each do |id| + stats.process(id) + end + end + end + + private + + def fetch_request_ids(redis, lease_uuid) + ids = redis.smembers(STATS_KEY) + redis.del(STATS_KEY) + Gitlab::ExclusiveLease.cancel(LEASE_KEY, lease_uuid) + + ids + end +end diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb index a696c6e746a..1bb600bbd13 100644 --- a/app/workers/gitlab_usage_ping_worker.rb +++ b/app/workers/gitlab_usage_ping_worker.rb @@ -13,6 +13,10 @@ class GitlabUsagePingWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_retry_in { |count| (count + 1) * 8.hours.to_i } def perform + # Disable usage ping for GitLab.com + # See https://gitlab.com/gitlab-org/gitlab/-/issues/292929 for details + return if Gitlab.com? + # Multiple Sidekiq workers could run this. We should only do this at most once a day. in_lock(LEASE_KEY, ttl: LEASE_TIMEOUT) do # Splay the request over a minute to avoid thundering herd problems. diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb index c7b5f8cd0a7..521e5b8fbc2 100644 --- a/app/workers/import_issues_csv_worker.rb +++ b/app/workers/import_issues_csv_worker.rb @@ -3,6 +3,7 @@ class ImportIssuesCsvWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + idempotent! feature_category :issue_tracking worker_resource_boundary :cpu weight 2 @@ -12,13 +13,15 @@ class ImportIssuesCsvWorker # rubocop:disable Scalability/IdempotentWorker end def perform(current_user_id, project_id, upload_id) - @user = User.find(current_user_id) - @project = Project.find(project_id) - @upload = Upload.find(upload_id) + user = User.find(current_user_id) + project = Project.find(project_id) + upload = Upload.find(upload_id) - importer = Issues::ImportCsvService.new(@user, @project, @upload.retrieve_uploader) + importer = Issues::ImportCsvService.new(user, project, upload.retrieve_uploader) importer.execute - @upload.destroy + upload.destroy + rescue ActiveRecord::RecordNotFound + # Resources have been removed, job should not be retried end end diff --git a/app/workers/jira_connect/sync_branch_worker.rb b/app/workers/jira_connect/sync_branch_worker.rb index 4c1c987353d..d7e773b0861 100644 --- a/app/workers/jira_connect/sync_branch_worker.rb +++ b/app/workers/jira_connect/sync_branch_worker.rb @@ -7,6 +7,7 @@ module JiraConnect queue_namespace :jira_connect feature_category :integrations loggable_arguments 1, 2 + worker_has_external_dependencies! def perform(project_id, branch_name, commit_shas, update_sequence_id = nil) project = Project.find_by_id(project_id) diff --git a/app/workers/jira_connect/sync_builds_worker.rb b/app/workers/jira_connect/sync_builds_worker.rb new file mode 100644 index 00000000000..c1c749f6041 --- /dev/null +++ b/app/workers/jira_connect/sync_builds_worker.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module JiraConnect + class SyncBuildsWorker + include ApplicationWorker + + idempotent! + worker_has_external_dependencies! + + queue_namespace :jira_connect + feature_category :integrations + + def perform(pipeline_id, sequence_id) + pipeline = Ci::Pipeline.find_by_id(pipeline_id) + + return unless pipeline + return unless Feature.enabled?(:jira_sync_builds, pipeline.project) + + ::JiraConnect::SyncService + .new(pipeline.project) + .execute(pipelines: [pipeline], update_sequence_id: sequence_id) + end + end +end diff --git a/app/workers/jira_connect/sync_merge_request_worker.rb b/app/workers/jira_connect/sync_merge_request_worker.rb index f45ab38f35d..6ef426790b3 100644 --- a/app/workers/jira_connect/sync_merge_request_worker.rb +++ b/app/workers/jira_connect/sync_merge_request_worker.rb @@ -7,6 +7,8 @@ module JiraConnect queue_namespace :jira_connect feature_category :integrations + worker_has_external_dependencies! + def perform(merge_request_id, update_sequence_id = nil) merge_request = MergeRequest.find_by_id(merge_request_id) diff --git a/app/workers/member_invitation_reminder_emails_worker.rb b/app/workers/member_invitation_reminder_emails_worker.rb index 50f583005c0..971d6abaa51 100644 --- a/app/workers/member_invitation_reminder_emails_worker.rb +++ b/app/workers/member_invitation_reminder_emails_worker.rb @@ -8,8 +8,6 @@ class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/Idempot urgency :low def perform - return unless Gitlab::Experimentation.enabled?(:invitation_reminders) - Member.not_accepted_invitations.not_expired.last_ten_days_excluding_today.find_in_batches do |invitations| invitations.each do |invitation| Members::InvitationReminderEmailService.new(invitation).execute diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb index 37774658ba8..6b991a2253f 100644 --- a/app/workers/merge_request_cleanup_refs_worker.rb +++ b/app/workers/merge_request_cleanup_refs_worker.rb @@ -3,7 +3,7 @@ class MergeRequestCleanupRefsWorker include ApplicationWorker - feature_category :source_code_management + feature_category :code_review idempotent! def perform(merge_request_id) diff --git a/app/workers/merge_request_mergeability_check_worker.rb b/app/workers/merge_request_mergeability_check_worker.rb index 1a84efb4e52..70d5f49d70e 100644 --- a/app/workers/merge_request_mergeability_check_worker.rb +++ b/app/workers/merge_request_mergeability_check_worker.rb @@ -3,7 +3,7 @@ class MergeRequestMergeabilityCheckWorker include ApplicationWorker - feature_category :source_code_management + feature_category :code_review idempotent! def perform(merge_request_id) diff --git a/app/workers/migrate_external_diffs_worker.rb b/app/workers/migrate_external_diffs_worker.rb index 0a95f40aa8f..3ef399bd9fc 100644 --- a/app/workers/migrate_external_diffs_worker.rb +++ b/app/workers/migrate_external_diffs_worker.rb @@ -3,7 +3,7 @@ class MigrateExternalDiffsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - feature_category :source_code_management + feature_category :code_review def perform(merge_request_diff_id) diff = MergeRequestDiff.find_by_id(merge_request_diff_id) diff --git a/app/workers/namespaces/onboarding_user_added_worker.rb b/app/workers/namespaces/onboarding_user_added_worker.rb new file mode 100644 index 00000000000..02608268d6f --- /dev/null +++ b/app/workers/namespaces/onboarding_user_added_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Namespaces + class OnboardingUserAddedWorker + include ApplicationWorker + + feature_category :users + urgency :low + + idempotent! + + def perform(namespace_id) + namespace = Namespace.find(namespace_id) + OnboardingProgressService.new(namespace).execute(action: :user_added) + end + end +end diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index f672d37a83e..2d28561488b 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -4,7 +4,7 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker include NewIssuable - feature_category :source_code_management + feature_category :code_review urgency :high worker_resource_boundary :cpu weight 2 diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index b114c67de47..8a9c166e5df 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Worker for updating any project specific caches. -class ProjectCacheWorker # rubocop:disable Scalability/IdempotentWorker +class ProjectCacheWorker include ApplicationWorker LEASE_TIMEOUT = 15.minutes.to_i @@ -9,6 +9,7 @@ class ProjectCacheWorker # rubocop:disable Scalability/IdempotentWorker feature_category :source_code_management urgency :high loggable_arguments 1, 2, 3 + idempotent! # project_id - The ID of the project for which to flush the cache. # files - An Array containing extra types of files to refresh such as diff --git a/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb b/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb new file mode 100644 index 00000000000..4d2a6b47e3c --- /dev/null +++ b/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ProjectScheduleBulkRepositoryShardMovesWorker + include ApplicationWorker + + idempotent! + feature_category :gitaly + urgency :throttled + + def perform(source_storage_name, destination_storage_name = nil) + Projects::ScheduleBulkRepositoryShardMovesService.new.execute(source_storage_name, destination_storage_name) + end +end diff --git a/app/workers/purge_dependency_proxy_cache_worker.rb b/app/workers/purge_dependency_proxy_cache_worker.rb index 594cdd3ed11..b4c88592543 100644 --- a/app/workers/purge_dependency_proxy_cache_worker.rb +++ b/app/workers/purge_dependency_proxy_cache_worker.rb @@ -15,6 +15,7 @@ class PurgeDependencyProxyCacheWorker return unless valid? @group.dependency_proxy_blobs.destroy_all # rubocop:disable Cop/DestroyAll + @group.dependency_proxy_manifests.destroy_all # rubocop:disable Cop/DestroyAll end private diff --git a/app/workers/releases/create_evidence_worker.rb b/app/workers/releases/create_evidence_worker.rb new file mode 100644 index 00000000000..db75fae1639 --- /dev/null +++ b/app/workers/releases/create_evidence_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Releases + class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + feature_category :release_evidence + + # pipeline_id is optional for backward compatibility with existing jobs + # caller should always try to provide the pipeline and pass nil only + # if pipeline is absent + def perform(release_id, pipeline_id = nil) + release = Release.find_by_id(release_id) + + return unless release + + pipeline = Ci::Pipeline.find_by_id(pipeline_id) + + ::Releases::CreateEvidenceService.new(release, pipeline: pipeline).execute + end + end +end diff --git a/app/workers/releases/manage_evidence_worker.rb b/app/workers/releases/manage_evidence_worker.rb new file mode 100644 index 00000000000..8a925d22cea --- /dev/null +++ b/app/workers/releases/manage_evidence_worker.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Releases + class ManageEvidenceWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + feature_category :release_evidence + + def perform + releases = Release.without_evidence.released_within_2hrs + + releases.each do |release| + project = release.project + params = { tag: release.tag } + + evidence_pipeline = Releases::EvidencePipelineFinder.new(project, params).execute + + # perform_at released_at + ::Releases::CreateEvidenceWorker.perform_async(release.id, evidence_pipeline&.id) + end + end + end +end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 51fe60e25fc..90764d7374d 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -30,7 +30,7 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker return if service.async? if result[:status] == :error - fail_import(result[:message]) if template_import? + fail_import(result[:message]) raise result[:message] end diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb index 21b5916f459..483aae84a3b 100644 --- a/app/workers/repository_update_remote_mirror_worker.rb +++ b/app/workers/repository_update_remote_mirror_worker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class RepositoryUpdateRemoteMirrorWorker # rubocop:disable Scalability/IdempotentWorker +class RepositoryUpdateRemoteMirrorWorker UpdateError = Class.new(StandardError) include ApplicationWorker @@ -11,6 +11,7 @@ class RepositoryUpdateRemoteMirrorWorker # rubocop:disable Scalability/Idempoten sidekiq_options retry: 3, dead: false feature_category :source_code_management loggable_arguments 1 + idempotent! LOCK_WAIT_TIME = 30.seconds MAX_TRIES = 3 diff --git a/app/workers/schedule_merge_request_cleanup_refs_worker.rb b/app/workers/schedule_merge_request_cleanup_refs_worker.rb index 17cabba4278..59b8993f78f 100644 --- a/app/workers/schedule_merge_request_cleanup_refs_worker.rb +++ b/app/workers/schedule_merge_request_cleanup_refs_worker.rb @@ -4,7 +4,7 @@ class ScheduleMergeRequestCleanupRefsWorker include ApplicationWorker include CronjobQueue # rubocop:disable Scalability/CronWorkerContext - feature_category :source_code_management + feature_category :code_review idempotent! # Based on existing data, MergeRequestCleanupRefsWorker can run 3 jobs per diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb index 4e7b60c4ab7..70e4d56562b 100644 --- a/app/workers/schedule_migrate_external_diffs_worker.rb +++ b/app/workers/schedule_migrate_external_diffs_worker.rb @@ -10,7 +10,7 @@ class ScheduleMigrateExternalDiffsWorker # rubocop:disable Scalability/Idempoten include Gitlab::ExclusiveLeaseHelpers - feature_category :source_code_management + feature_category :code_review def perform in_lock(self.class.name.underscore, ttl: 2.hours, retries: 0) do diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb index 0f9b4ddb980..bea9d67b3e8 100644 --- a/app/workers/stuck_merge_jobs_worker.rb +++ b/app/workers/stuck_merge_jobs_worker.rb @@ -4,7 +4,7 @@ class StuckMergeJobsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker include CronjobQueue # rubocop:disable Scalability/CronWorkerContext - feature_category :source_code_management + feature_category :code_review def self.logger Gitlab::AppLogger diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb index eb1a7f4fef9..5876cfb1fe7 100644 --- a/app/workers/trending_projects_worker.rb +++ b/app/workers/trending_projects_worker.rb @@ -2,10 +2,6 @@ class TrendingProjectsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - # rubocop:disable Scalability/CronWorkerContext - # This worker does not perform work scoped to a context - include CronjobQueue - # rubocop:enable Scalability/CronWorkerContext include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :source_code_management diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index 402c1777662..46cb32e7f08 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -3,7 +3,7 @@ class UpdateMergeRequestsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - feature_category :source_code_management + feature_category :code_review urgency :high worker_resource_boundary :cpu weight 3 |